From 8c4498c2075b3d87ca1ea2c283dcd6ee811e6e46 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:13:57 +0200 Subject: [PATCH 001/185] feat: plugin scaffold with dependency check Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .gitignore | 3 + composer.json | 36 + composer.lock | 2918 +++++++++++++++++++++++++++++++ phpcs.xml.dist | 23 + phpunit.xml.dist | 11 + src/Dependencies.php | 40 + src/Plugin.php | 46 + tests/Stubs/WpError.php | 30 + tests/Stubs/WpUser.php | 17 + tests/Unit/DependenciesTest.php | 27 + tests/Unit/TestCase.php | 26 + tests/bootstrap.php | 11 + wp-lit-mcp.php | 37 + 13 files changed, 3225 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 phpcs.xml.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Dependencies.php create mode 100644 src/Plugin.php create mode 100644 tests/Stubs/WpError.php create mode 100644 tests/Stubs/WpUser.php create mode 100644 tests/Unit/DependenciesTest.php create mode 100644 tests/Unit/TestCase.php create mode 100644 tests/bootstrap.php create mode 100644 wp-lit-mcp.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3be7de --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/vendor.lit-bak/ +.phpunit.result.cache diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f7ed2a4 --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "loosen-it/wp-lit-mcp", + "description": "MCP-Server für WordPress, Divi 5 und Pods mit OAuth für claude.ai", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.1", + "wp-media/mcp-oauth": "^1.1" + }, + "require-dev": { + "brain/monkey": "^2.6", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "mockery/mockery": "^1.6", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.10", + "wp-coding-standards/wpcs": "^3.1" + }, + "autoload": { + "psr-4": { "LIT\\MCP\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "LIT\\MCP\\Tests\\": "tests/" } + }, + "scripts": { + "test": "@php vendor/bin/phpunit", + "lint": "@php vendor/bin/phpcs" + }, + "config": { + "sort-packages": true, + "platform": { "php": "8.1" }, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..b8fef02 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2918 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "538b1dbb81faf068bc0ff5f6a99efde9", + "packages": [ + { + "name": "wordpress/mcp-adapter", + "version": "v0.5.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/mcp-adapter.git", + "reference": "7bfc49f46b3ea7544dded49d5a606089f825a80b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/7bfc49f46b3ea7544dded49d5a606089f825a80b", + "reference": "7bfc49f46b3ea7544dded49d5a606089f825a80b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "wordpress/php-mcp-schema": "^0.1.0" + }, + "require-dev": { + "automattic/vipwpcs": "^3.0", + "php-stubs/wordpress-stubs": "^6.9", + "php-stubs/wp-cli-stubs": "^2.12", + "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpstan/extension-installer": "^1.3", + "phpstan/php-8-stubs": "^0.4.24", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.0", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-phpunit/wp-phpunit": "^6.5", + "wpackagist-plugin/plugin-check": "^1.6", + "yoast/phpunit-polyfills": "^4.0" + }, + "type": "wordpress-plugin", + "extra": { + "installer-paths": { + "vendor/{$vendor}/{$name}/": [ + "wpackagist-plugin/plugin-check" + ] + } + }, + "autoload": { + "psr-4": { + "WP\\MCP\\": "includes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "Adapter for Abilities API, letting WordPress abilities to be used as MCP tools, resources or prompts", + "homepage": "https://github.com/wordpress/mcp-adapter", + "keywords": [ + "abilities-api", + "adapter", + "ai", + "api", + "integration", + "mcp", + "model-context-protocol", + "wordpress" + ], + "support": { + "issues": "https://github.com/wordpress/mcp-adapter/issues", + "source": "https://github.com/wordpress/mcp-adapter" + }, + "time": "2026-04-14T12:12:58+00:00" + }, + { + "name": "wordpress/php-mcp-schema", + "version": "v0.1.3", + "source": { + "type": "git", + "url": "https://github.com/WordPress/php-mcp-schema", + "reference": "b2fcf97aa023ce46e9f03493c194a72d5a46bea2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/php-mcp-schema/zipball/b2fcf97aa023ce46e9f03493c194a72d5a46bea2", + "reference": "b2fcf97aa023ce46e9f03493c194a72d5a46bea2", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "WP\\McpSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress", + "homepage": "https://wordpress.org" + } + ], + "description": "PHP DTOs for the Model Context Protocol (MCP) specification", + "homepage": "https://github.com/WordPress/php-mcp-schema", + "keywords": [ + "dto", + "mcp", + "model-context-protocol", + "php", + "schema" + ], + "time": "2026-08-10T09:12:14+00:00" + }, + { + "name": "wp-media/apply-filters-typed", + "version": "v1.2", + "source": { + "type": "git", + "url": "https://github.com/wp-media/apply-filters-typed.git", + "reference": "d8f5bca83e85196d1f01d92283b63ef6f886ac4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-media/apply-filters-typed/zipball/d8f5bca83e85196d1f01d92283b63ef6f886ac4d", + "reference": "d8f5bca83e85196d1f01d92283b63ef6f886ac4d", + "shasum": "" + }, + "require-dev": { + "phpcompatibility/phpcompatibility-wp": "^2.0", + "phpstan/extension-installer": "^1.4", + "szepeviktor/phpstan-wordpress": "^1.3", + "wp-coding-standards/wpcs": "^3", + "wp-media/phpunit": "^3" + }, + "type": "library", + "autoload": { + "files": [ + "functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "authors": [ + { + "name": "WP Media", + "email": "contact@wp-media.me" + } + ], + "description": "Library for usage of WordPress filters in a safe-type way", + "support": { + "issues": "https://github.com/wp-media/apply-filters-typed/issues", + "source": "https://github.com/wp-media/apply-filters-typed/tree/v1.2" + }, + "time": "2024-09-16T12:21:47+00:00" + }, + { + "name": "wp-media/mcp-oauth", + "version": "v1.1.2", + "source": { + "type": "git", + "url": "https://github.com/wp-media/mcp-oauth.git", + "reference": "2f6b10d8bb81a9ba8b2b33bb0450995486262b09" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-media/mcp-oauth/zipball/2f6b10d8bb81a9ba8b2b33bb0450995486262b09", + "reference": "2f6b10d8bb81a9ba8b2b33bb0450995486262b09", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "wordpress/mcp-adapter": "^0.5", + "wp-media/apply-filters-typed": "^1.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1", + "php": ">=7.4", + "php-stubs/wordpress-tests-stubs": "^6.8", + "phpcompatibility/phpcompatibility-wp": "^2.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "roave/security-advisories": "dev-master", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-coding-standards/wpcs": "^3", + "wp-media/phpunit": "^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "WPMedia\\MCP\\OAuth\\": "inc/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "WP Media", + "email": "contact@wp-media.me" + } + ], + "description": "MCP OAuth layer for the WordPress MCP Adapter", + "homepage": "https://github.com/wp-media/mcp-oauth", + "keywords": [ + "plugin", + "wordpress", + "wp" + ], + "support": { + "issues": "https://github.com/wp-media/mcp-oauth/issues", + "source": "https://github.com/wp-media/mcp-oauth/tree/v1.1.2" + }, + "time": "2026-09-02T18:52:01+00:00" + } + ], + "packages-dev": [ + { + "name": "antecedent/patchwork", + "version": "2.2.3", + "source": { + "type": "git", + "url": "https://github.com/antecedent/patchwork.git", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=4" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignas Rudaitis", + "email": "ignas.rudaitis@gmail.com" + } + ], + "description": "Method redefinition (monkey-patching) functionality for PHP.", + "homepage": "https://antecedent.github.io/patchwork/", + "keywords": [ + "aop", + "aspect", + "interception", + "monkeypatching", + "redefinition", + "runkit", + "testing" + ], + "support": { + "issues": "https://github.com/antecedent/patchwork/issues", + "source": "https://github.com/antecedent/patchwork/tree/2.2.3" + }, + "time": "2025-09-17T09:00:56+00:00" + }, + { + "name": "brain/monkey", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/Brain-WP/BrainMonkey.git", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "shasum": "" + }, + "require": { + "antecedent/patchwork": "^2.1.17", + "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10", + "php": ">=5.6.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", + "phpcompatibility/php-compatibility": "^9.3.0", + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev", + "dev-version/1": "1.x-dev" + } + }, + "autoload": { + "files": [ + "inc/api.php" + ], + "psr-4": { + "Brain\\Monkey\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Giuseppe Mazzapica", + "email": "giuseppe.mazzapica@gmail.com", + "homepage": "https://gmazzap.me", + "role": "Developer" + } + ], + "description": "Mocking utility for PHP functions and WordPress plugin API", + "keywords": [ + "Monkey Patching", + "interception", + "mock", + "mock functions", + "mockery", + "patchwork", + "redefinition", + "runkit", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/Brain-WP/BrainMonkey/issues", + "source": "https://github.com/Brain-WP/BrainMonkey" + }, + "time": "2026-02-05T09:22:14+00:00" + }, + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-07-27T11:13:17+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-07-27T10:28:41+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.37", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aceaa623912caf8e80bffea22fc4103a0a01565f", + "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.9", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.37" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-09-23T09:59:59+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-08-11T04:55:59+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "c85be6922b7fd365942b986b9a50397d65407611" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611", + "reference": "c85be6922b7fd365942b986b9a50397d65407611", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-11T05:25:24+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.6", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T00:17:32+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2026-07-27T11:53:23+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.1" + }, + "plugin-api-version": "2.6.0" +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..a792708 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,23 @@ + + + src + wp-lit-mcp.php + /vendor/* + + + + + + + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6e7b6d4 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + + tests/Unit + + + diff --git a/src/Dependencies.php b/src/Dependencies.php new file mode 100644 index 0000000..6524ea3 --- /dev/null +++ b/src/Dependencies.php @@ -0,0 +1,40 @@ + 'WP\\MCP\\Core\\McpAdapter', + 'wp-media/mcp-oauth' => 'WPMedia\\MCP\\OAuth\\Bootstrap', + 'WordPress Abilities API' => 'WP_Ability', + ]; + + /** @var callable(string): bool */ + private $classExists; + + public function __construct(?callable $classExists = null) + { + $this->classExists = $classExists ?? static fn (string $class): bool => class_exists($class); + } + + /** + * @return list Human-readable names of missing components. + */ + public function missing(): array + { + $missing = []; + foreach (self::REQUIRED as $name => $class) { + if (! ($this->classExists)($class)) { + $missing[] = $name; + } + } + + return $missing; + } +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..9484ad7 --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,46 @@ +missing(); + if ($missing !== []) { + self::noticeMissing($missing); + return; + } + } + + public static function pluginDir(): string + { + return dirname(self::$pluginFile); + } + + /** + * @param list $missing + */ + public static function noticeMissing(array $missing): void + { + add_action('admin_notices', static function () use ($missing): void { + printf( + '

%s

', + esc_html(sprintf( + /* translators: %s: comma separated list of missing components */ + __('WP-LIT-MCP ist inaktiv. Es fehlt: %s', 'wp-lit-mcp'), + implode(', ', $missing) + )) + ); + }); + } +} diff --git a/tests/Stubs/WpError.php b/tests/Stubs/WpError.php new file mode 100644 index 0000000..70fd2a5 --- /dev/null +++ b/tests/Stubs/WpError.php @@ -0,0 +1,30 @@ +code; + } + + public function get_error_message(): string + { + return $this->message; + } + + public function get_error_data(): mixed + { + return $this->data; + } + } +} diff --git a/tests/Stubs/WpUser.php b/tests/Stubs/WpUser.php new file mode 100644 index 0000000..bb99769 --- /dev/null +++ b/tests/Stubs/WpUser.php @@ -0,0 +1,17 @@ +ID = $id; + $this->roles = $roles; + } + } +} diff --git a/tests/Unit/DependenciesTest.php b/tests/Unit/DependenciesTest.php new file mode 100644 index 0000000..f7d9de8 --- /dev/null +++ b/tests/Unit/DependenciesTest.php @@ -0,0 +1,27 @@ + true); + + self::assertSame([], $deps->missing()); + } + + public function testNamesEveryMissingComponent(): void + { + $deps = new Dependencies(static fn (string $class): bool => false); + + self::assertSame( + ['wordpress/mcp-adapter', 'wp-media/mcp-oauth', 'WordPress Abilities API'], + $deps->missing() + ); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php new file mode 100644 index 0000000..3dc5f11 --- /dev/null +++ b/tests/Unit/TestCase.php @@ -0,0 +1,26 @@ +

' + . esc_html__( + 'WP-LIT-MCP: Abhängigkeiten fehlen. Bitte "composer install --no-dev" im Plugin-Ordner ausführen.', + 'wp-lit-mcp' + ) + . '

'; + }); + return; +} + +require_once $lit_mcp_autoload; + +add_action('plugins_loaded', static function (): void { + \LIT\MCP\Plugin::boot(__FILE__); +}); From 9669ccb36a5bf4ba0667dadf9f45fb0007956dfe Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:15:28 +0200 Subject: [PATCH 002/185] docs: add per-ability isAvailable() to plan and spec Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../plans/2026-09-25-etappe-0-anbindung.md | 44 +++++++++++++++++-- .../specs/2026-09-25-wp-lit-mcp-design.md | 1 + 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md b/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md index 6787380..19b0cb1 100644 --- a/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md +++ b/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md @@ -670,10 +670,10 @@ Co-Authored-By: Claude Opus 5.5 (1M context) " **Interfaces:** - Consumes: `Access::currentUserMayUse()`, `Access::CAPABILITY` (Task 2). - Produces: - - `interface Ability { name(): string; label(): string; description(): string; inputSchema(): array; outputSchema(): ?array; annotations(): array; isDirect(): bool; permission(array $input): bool|\WP_Error; execute(array $input): mixed; }` + - `interface Ability { name(): string; label(): string; description(): string; inputSchema(): array; outputSchema(): ?array; annotations(): array; isDirect(): bool; isAvailable(): bool; permission(array $input): bool|\WP_Error; execute(array $input): mixed; }` - `Errors::forbidden(string $message): \WP_Error` (Code `lit_forbidden`), `Errors::invalidInput(string $message, array $fields = []): \WP_Error` (Code `lit_validation_failed`), `Errors::notFound(string $message): \WP_Error` (Code `lit_not_found`), `Errors::internal(): \WP_Error` (Code `lit_internal_error`). - `interface Integration { id(): string; isAvailable(): bool; /** @return list */ abilities(): array; }` - - `IntegrationRegistry::__construct(Integration ...$integrations)`, `available(): list`, `abilities(): list`, `directAbilityNames(): list`. + - `IntegrationRegistry::__construct(Integration ...$integrations)`, `available(): list`, `abilities(): list` (nur Abilities verfügbarer Integrationen, die selbst `isAvailable()` true liefern), `directAbilityNames(): list`. - `AbilityRegistrar::CATEGORY = 'lit-mcp'`, `__construct(IntegrationRegistry $registry)`, `register(): void`, `registerCategory(): void`, `registerAbilities(): void`, `argsFor(Ability $ability): array`. - [ ] **Step 1: Failing Tests** @@ -710,11 +710,23 @@ final class IntegrationRegistryTest extends TestCase self::assertSame(['lit/a'], $registry->directAbilityNames()); } - private function ability(string $name, bool $direct): Ability + public function testUnavailableAbilitiesAreSkippedEvenInAvailableIntegration(): void + { + $working = $this->ability('lit/a', true); + $broken = $this->ability('lit/needs-missing-plugin', true, false); + + $registry = new IntegrationRegistry($this->integration('core', true, [$working, $broken])); + + self::assertSame([$working], $registry->abilities()); + self::assertSame(['lit/a'], $registry->directAbilityNames()); + } + + private function ability(string $name, bool $direct, bool $available = true): Ability { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn($name); $ability->allows('isDirect')->andReturn($direct); + $ability->allows('isAvailable')->andReturn($available); return $ability; } @@ -772,6 +784,7 @@ final class AbilityRegistrarTest extends TestCase $ability->allows('outputSchema')->andReturn(['type' => 'object']); $ability->allows('annotations')->andReturn(['readonly' => true]); $ability->allows('isDirect')->andReturn(true); + $ability->allows('isAvailable')->andReturn(true); return $ability; } @@ -893,6 +906,13 @@ interface Ability /** Whether the ability is listed as its own MCP tool (otherwise only via meta-tools). */ public function isDirect(): bool; + /** + * Whether the ability can work on this installation (e.g. a required + * plugin function or version exists). Unavailable abilities are not + * registered at all, so they can never fail at call time. + */ + public function isAvailable(): bool; + /** @param array $input */ public function permission(array $input): bool|WP_Error; @@ -1002,7 +1022,11 @@ final class IntegrationRegistry { $abilities = []; foreach ($this->available() as $integration) { - array_push($abilities, ...$integration->abilities()); + foreach ($integration->abilities() as $ability) { + if ($ability->isAvailable()) { + $abilities[] = $ability; + } + } } return $abilities; @@ -1476,6 +1500,11 @@ final class GuideAbility implements Ability return true; } + public function isAvailable(): bool + { + return true; + } + public function permission(array $input): bool|WP_Error { return true; @@ -1731,6 +1760,11 @@ final class SiteStatusAbility implements Ability return true; } + public function isAvailable(): bool + { + return true; + } + public function permission(array $input): bool|WP_Error { return true; @@ -1843,9 +1877,11 @@ final class ServerRegistrarTest extends TestCase $direct = Mockery::mock(Ability::class); $direct->allows('name')->andReturn('lit/guide'); $direct->allows('isDirect')->andReturn(true); + $direct->allows('isAvailable')->andReturn(true); $indirect = Mockery::mock(Ability::class); $indirect->allows('name')->andReturn('lit/rare-tool'); $indirect->allows('isDirect')->andReturn(false); + $indirect->allows('isAvailable')->andReturn(true); $core = Mockery::mock(Integration::class); $core->allows('id')->andReturn('core'); diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index bfd73e2..3c97bcc 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -138,6 +138,7 @@ src/ Admin/ Einstellungsseite, Protokoll-Ansicht (Settings API, WP_List_Table) Integrations/ Integration.php Interface: isAvailable(), abilities(), prompts() + (jede Ability hat zusätzlich isAvailable(): nicht verfügbare werden nicht registriert) WordPress/ Content, Schema, Status/Kopie, Revisionen, Menüs, Einstellungen Media/ Medien-Tools, Verwendungssuche Divi/ From a8c8cee7e504af20bffaddabef627aa93f776289 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:16:31 +0200 Subject: [PATCH 003/185] docs: drop per-ability isAvailable(); integrations omit unusable abilities instead Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../plans/2026-09-25-etappe-0-anbindung.md | 44 ++----------------- .../specs/2026-09-25-wp-lit-mcp-design.md | 2 +- 2 files changed, 5 insertions(+), 41 deletions(-) diff --git a/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md b/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md index 19b0cb1..6787380 100644 --- a/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md +++ b/docs/superpowers/plans/2026-09-25-etappe-0-anbindung.md @@ -670,10 +670,10 @@ Co-Authored-By: Claude Opus 5.5 (1M context) " **Interfaces:** - Consumes: `Access::currentUserMayUse()`, `Access::CAPABILITY` (Task 2). - Produces: - - `interface Ability { name(): string; label(): string; description(): string; inputSchema(): array; outputSchema(): ?array; annotations(): array; isDirect(): bool; isAvailable(): bool; permission(array $input): bool|\WP_Error; execute(array $input): mixed; }` + - `interface Ability { name(): string; label(): string; description(): string; inputSchema(): array; outputSchema(): ?array; annotations(): array; isDirect(): bool; permission(array $input): bool|\WP_Error; execute(array $input): mixed; }` - `Errors::forbidden(string $message): \WP_Error` (Code `lit_forbidden`), `Errors::invalidInput(string $message, array $fields = []): \WP_Error` (Code `lit_validation_failed`), `Errors::notFound(string $message): \WP_Error` (Code `lit_not_found`), `Errors::internal(): \WP_Error` (Code `lit_internal_error`). - `interface Integration { id(): string; isAvailable(): bool; /** @return list */ abilities(): array; }` - - `IntegrationRegistry::__construct(Integration ...$integrations)`, `available(): list`, `abilities(): list` (nur Abilities verfügbarer Integrationen, die selbst `isAvailable()` true liefern), `directAbilityNames(): list`. + - `IntegrationRegistry::__construct(Integration ...$integrations)`, `available(): list`, `abilities(): list`, `directAbilityNames(): list`. - `AbilityRegistrar::CATEGORY = 'lit-mcp'`, `__construct(IntegrationRegistry $registry)`, `register(): void`, `registerCategory(): void`, `registerAbilities(): void`, `argsFor(Ability $ability): array`. - [ ] **Step 1: Failing Tests** @@ -710,23 +710,11 @@ final class IntegrationRegistryTest extends TestCase self::assertSame(['lit/a'], $registry->directAbilityNames()); } - public function testUnavailableAbilitiesAreSkippedEvenInAvailableIntegration(): void - { - $working = $this->ability('lit/a', true); - $broken = $this->ability('lit/needs-missing-plugin', true, false); - - $registry = new IntegrationRegistry($this->integration('core', true, [$working, $broken])); - - self::assertSame([$working], $registry->abilities()); - self::assertSame(['lit/a'], $registry->directAbilityNames()); - } - - private function ability(string $name, bool $direct, bool $available = true): Ability + private function ability(string $name, bool $direct): Ability { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn($name); $ability->allows('isDirect')->andReturn($direct); - $ability->allows('isAvailable')->andReturn($available); return $ability; } @@ -784,7 +772,6 @@ final class AbilityRegistrarTest extends TestCase $ability->allows('outputSchema')->andReturn(['type' => 'object']); $ability->allows('annotations')->andReturn(['readonly' => true]); $ability->allows('isDirect')->andReturn(true); - $ability->allows('isAvailable')->andReturn(true); return $ability; } @@ -906,13 +893,6 @@ interface Ability /** Whether the ability is listed as its own MCP tool (otherwise only via meta-tools). */ public function isDirect(): bool; - /** - * Whether the ability can work on this installation (e.g. a required - * plugin function or version exists). Unavailable abilities are not - * registered at all, so they can never fail at call time. - */ - public function isAvailable(): bool; - /** @param array $input */ public function permission(array $input): bool|WP_Error; @@ -1022,11 +1002,7 @@ final class IntegrationRegistry { $abilities = []; foreach ($this->available() as $integration) { - foreach ($integration->abilities() as $ability) { - if ($ability->isAvailable()) { - $abilities[] = $ability; - } - } + array_push($abilities, ...$integration->abilities()); } return $abilities; @@ -1500,11 +1476,6 @@ final class GuideAbility implements Ability return true; } - public function isAvailable(): bool - { - return true; - } - public function permission(array $input): bool|WP_Error { return true; @@ -1760,11 +1731,6 @@ final class SiteStatusAbility implements Ability return true; } - public function isAvailable(): bool - { - return true; - } - public function permission(array $input): bool|WP_Error { return true; @@ -1877,11 +1843,9 @@ final class ServerRegistrarTest extends TestCase $direct = Mockery::mock(Ability::class); $direct->allows('name')->andReturn('lit/guide'); $direct->allows('isDirect')->andReturn(true); - $direct->allows('isAvailable')->andReturn(true); $indirect = Mockery::mock(Ability::class); $indirect->allows('name')->andReturn('lit/rare-tool'); $indirect->allows('isDirect')->andReturn(false); - $indirect->allows('isAvailable')->andReturn(true); $core = Mockery::mock(Integration::class); $core->allows('id')->andReturn('core'); diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index 3c97bcc..e81c7fb 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -138,7 +138,7 @@ src/ Admin/ Einstellungsseite, Protokoll-Ansicht (Settings API, WP_List_Table) Integrations/ Integration.php Interface: isAvailable(), abilities(), prompts() - (jede Ability hat zusätzlich isAvailable(): nicht verfügbare werden nicht registriert) + (Sonderfälle innerhalb einer Integration: abilities() lässt nicht nutzbare Tools einfach weg) WordPress/ Content, Schema, Status/Kopie, Revisionen, Menüs, Einstellungen Media/ Medien-Tools, Verwendungssuche Divi/ From aee7efe0a8ff53c119e22b414d631240504efae8 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:18:46 +0200 Subject: [PATCH 004/185] feat: role gate with lit_mcp_use capability Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Access.php | 80 ++++++++++++++++++++++++++++++++++++ src/Plugin.php | 2 + tests/Unit/AccessTest.php | 86 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/Access.php create mode 100644 tests/Unit/AccessTest.php diff --git a/src/Access.php b/src/Access.php new file mode 100644 index 0000000..1b22204 --- /dev/null +++ b/src/Access.php @@ -0,0 +1,80 @@ + self::CAPABILITY + ); + } + } + + /** + * @return list + */ + public function allowedRoles(): array + { + $roles = get_option(self::OPTION, self::DEFAULT_ROLES); + if (! is_array($roles)) { + $roles = self::DEFAULT_ROLES; + } + + $roles = apply_filters('lit_mcp_allowed_roles', $roles); + + return array_values(array_filter($roles, 'is_string')); + } + + /** + * @param array $allcaps + * @param list $caps + * @param array $args + * @return array + */ + public function grantCapability( + array $allcaps, + array $caps, + array $args, + WP_User $user + ): array { + if (! in_array(self::CAPABILITY, $caps, true)) { + return $allcaps; + } + + if (array_intersect($user->roles, $this->allowedRoles()) !== []) { + $allcaps[self::CAPABILITY] = true; + } + + return $allcaps; + } + + public static function currentUserMayUse(): bool + { + return current_user_can(self::CAPABILITY); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 9484ad7..db2ec0e 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -20,6 +20,8 @@ final class Plugin self::noticeMissing($missing); return; } + + (new Access())->register(); } public static function pluginDir(): string diff --git a/tests/Unit/AccessTest.php b/tests/Unit/AccessTest.php new file mode 100644 index 0000000..c7868f1 --- /dev/null +++ b/tests/Unit/AccessTest.php @@ -0,0 +1,86 @@ +once() + ->with(Access::OPTION, ['administrator']) + ->andReturn(['administrator']); + + self::assertSame(['administrator'], (new Access())->allowedRoles()); + } + + public function testRoleListIsFilterable(): void + { + Functions\when('get_option')->justReturn(['administrator']); + Filters\expectApplied('lit_mcp_allowed_roles') + ->once() + ->andReturn(['administrator', 'editor']); + + self::assertSame(['administrator', 'editor'], (new Access())->allowedRoles()); + } + + public function testGrantsCapabilityToAllowedRole(): void + { + Functions\when('get_option')->justReturn(['editor']); + + $caps = (new Access())->grantCapability( + [], + [Access::CAPABILITY], + [], + new WP_User(5, ['editor']) + ); + + self::assertTrue($caps[Access::CAPABILITY]); + } + + public function testDoesNotGrantCapabilityToOtherRoles(): void + { + Functions\when('get_option')->justReturn(['administrator']); + + $caps = (new Access())->grantCapability( + [], + [Access::CAPABILITY], + [], + new WP_User(7, ['subscriber']) + ); + + self::assertArrayNotHasKey(Access::CAPABILITY, $caps); + } + + public function testCorruptOptionFallsBackToAdministrator(): void + { + Functions\when('get_option')->justReturn('not-an-array'); + + self::assertSame(['administrator'], (new Access())->allowedRoles()); + } + + public function testRegisterRoutesAdapterMetaToolCapabilitiesToOwnCapability(): void + { + // Brain Monkey records add_filter() but never runs callbacks, so we + // capture each registered callback and invoke it ourselves. + foreach (['discover_abilities', 'get_ability_info', 'execute_ability'] as $tool) { + Filters\expectAdded("mcp_adapter_{$tool}_capability") + ->once() + ->whenHappen(static function (callable $callback): void { + self::assertSame(Access::CAPABILITY, $callback('read')); + }); + } + + $access = new Access(); + $access->register(); + + self::assertNotFalse(has_filter('user_has_cap', [$access, 'grantCapability'])); + } +} From f945734c6c60f1324883911625d34c97e483eb0e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:19:05 +0200 Subject: [PATCH 005/185] docs: code module always allowed, remove setting Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index e81c7fb..028685e 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -386,7 +386,7 @@ Rückgabe als `structuredContent` plus kurze Text-Zusammenfassung. Listen immer - **Verbindung:** Status Adapter/OAuth, MCP-Server-URL zum Kopieren, Site-Health-Test (Discovery-Dokumente erreichbar, 401 mit `WWW-Authenticate`). - **Zugriff:** erlaubte Rollen (Standard: Administrator); aktive Verbindungen je Benutzer mit Widerruf (über die von mcp-oauth erzeugten Application Passwords). -- **Tools:** direkt / indirekt / deaktiviert je Ability; Code-Modul erlaubt ja/nein (Standard: ja, nachrangig). +- **Tools:** direkt / indirekt / deaktiviert je Ability. (Das Divi-Code-Modul ist immer erlaubt, nur nachrangig – keine Einstellung.) - **Limits:** Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots. - Umsetzung mit Settings API, ohne JS-Framework. From f69efcb9537eb29a5532f1561ac92d95bc346f85 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:23:56 +0200 Subject: [PATCH 006/185] fix: guard allowedRoles() against corrupt filtered results The lit_mcp_allowed_roles filter could return a non-array, causing array_filter() to throw TypeError in PHP 8.1+. Now fallback to ['administrator'] if filter result is not an array. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Access.php | 3 +++ tests/Unit/AccessTest.php | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Access.php b/src/Access.php index 1b22204..e77a0c3 100644 --- a/src/Access.php +++ b/src/Access.php @@ -46,6 +46,9 @@ final class Access } $roles = apply_filters('lit_mcp_allowed_roles', $roles); + if (! is_array($roles)) { + $roles = self::DEFAULT_ROLES; + } return array_values(array_filter($roles, 'is_string')); } diff --git a/tests/Unit/AccessTest.php b/tests/Unit/AccessTest.php index c7868f1..e74b46f 100644 --- a/tests/Unit/AccessTest.php +++ b/tests/Unit/AccessTest.php @@ -31,6 +31,16 @@ final class AccessTest extends TestCase self::assertSame(['administrator', 'editor'], (new Access())->allowedRoles()); } + public function testCorruptFilteredResultFallsBackToAdministrator(): void + { + Functions\when('get_option')->justReturn(['administrator']); + Filters\expectApplied('lit_mcp_allowed_roles') + ->once() + ->andReturn('editor'); + + self::assertSame(['administrator'], (new Access())->allowedRoles()); + } + public function testGrantsCapabilityToAllowedRole(): void { Functions\when('get_option')->justReturn(['editor']); From 3b7fabf17a8f7a0c3540de21493cd61ea042b13d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:27:08 +0200 Subject: [PATCH 007/185] feat: ability and integration infrastructure Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Ability.php | 38 ++++++ src/Abilities/AbilityRegistrar.php | 83 +++++++++++++ src/Abilities/Errors.php | 39 ++++++ src/Integrations/Integration.php | 20 +++ src/Integrations/IntegrationRegistry.php | 51 ++++++++ tests/Unit/Abilities/AbilityRegistrarTest.php | 116 ++++++++++++++++++ .../Integrations/IntegrationRegistryTest.php | 50 ++++++++ 7 files changed, 397 insertions(+) create mode 100644 src/Abilities/Ability.php create mode 100644 src/Abilities/AbilityRegistrar.php create mode 100644 src/Abilities/Errors.php create mode 100644 src/Integrations/Integration.php create mode 100644 src/Integrations/IntegrationRegistry.php create mode 100644 tests/Unit/Abilities/AbilityRegistrarTest.php create mode 100644 tests/Unit/Integrations/IntegrationRegistryTest.php diff --git a/src/Abilities/Ability.php b/src/Abilities/Ability.php new file mode 100644 index 0000000..095f73d --- /dev/null +++ b/src/Abilities/Ability.php @@ -0,0 +1,38 @@ + JSON Schema of the input. */ + public function inputSchema(): array; + + /** @return array|null JSON Schema of the output, null for image results. */ + public function outputSchema(): ?array; + + /** @return array{readonly?: bool, destructive?: bool, idempotent?: bool} */ + public function annotations(): array; + + /** Whether the ability is listed as its own MCP tool (otherwise only via meta-tools). */ + public function isDirect(): bool; + + /** @param array $input */ + public function permission(array $input): bool|WP_Error; + + /** @param array $input */ + public function execute(array $input): mixed; +} diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php new file mode 100644 index 0000000..031074e --- /dev/null +++ b/src/Abilities/AbilityRegistrar.php @@ -0,0 +1,83 @@ + 'LIT MCP', + 'description' => 'Website-Pflege für WordPress, Divi und Pods über MCP.', + ]); + } + + public function registerAbilities(): void + { + foreach ($this->registry->abilities() as $ability) { + wp_register_ability($ability->name(), $this->argsFor($ability)); + } + } + + /** @return array */ + public function argsFor(Ability $ability): array + { + $args = [ + 'label' => $ability->label(), + 'description' => $ability->description(), + 'category' => self::CATEGORY, + 'input_schema' => $ability->inputSchema(), + 'permission_callback' => static function (mixed $input = null) use ($ability): bool|WP_Error { + if (! Access::currentUserMayUse()) { + return Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.'); + } + + return $ability->permission(is_array($input) ? $input : []); + }, + 'execute_callback' => static function (mixed $input = null) use ($ability): mixed { + try { + return $ability->execute(is_array($input) ? $input : []); + } catch (Throwable $e) { + $message = sprintf('[wp-lit-mcp] %s failed: %s', $ability->name(), $e); + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return Errors::internal(); + } + }, + 'meta' => [ + 'annotations' => $ability->annotations(), + 'show_in_rest' => false, + 'mcp' => ['public' => true, 'type' => 'tool'], + ], + ]; + + $output = $ability->outputSchema(); + if ($output !== null) { + $args['output_schema'] = $output; + } + + return $args; + } +} diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php new file mode 100644 index 0000000..3d59620 --- /dev/null +++ b/src/Abilities/Errors.php @@ -0,0 +1,39 @@ + 403]); + } + + /** @param array $fields */ + public static function invalidInput(string $message, array $fields = []): WP_Error + { + return new WP_Error('lit_validation_failed', $message, ['status' => 400, 'fields' => $fields]); + } + + public static function notFound(string $message): WP_Error + { + return new WP_Error('lit_not_found', $message, ['status' => 404]); + } + + public static function internal(): WP_Error + { + return new WP_Error( + 'lit_internal_error', + 'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.', + ['status' => 500] + ); + } +} diff --git a/src/Integrations/Integration.php b/src/Integrations/Integration.php new file mode 100644 index 0000000..e19a45c --- /dev/null +++ b/src/Integrations/Integration.php @@ -0,0 +1,20 @@ + */ + public function abilities(): array; +} diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php new file mode 100644 index 0000000..4b75be3 --- /dev/null +++ b/src/Integrations/IntegrationRegistry.php @@ -0,0 +1,51 @@ + */ + private array $integrations; + + public function __construct(Integration ...$integrations) + { + $this->integrations = array_values($integrations); + } + + /** @return list */ + public function available(): array + { + return array_values(array_filter( + $this->integrations, + static fn (Integration $integration): bool => $integration->isAvailable() + )); + } + + /** @return list */ + public function abilities(): array + { + $abilities = []; + foreach ($this->available() as $integration) { + array_push($abilities, ...$integration->abilities()); + } + + return $abilities; + } + + /** @return list */ + public function directAbilityNames(): array + { + $names = []; + foreach ($this->abilities() as $ability) { + if ($ability->isDirect()) { + $names[] = $ability->name(); + } + } + + return $names; + } +} diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php new file mode 100644 index 0000000..cf7de1f --- /dev/null +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -0,0 +1,116 @@ +allows('id')->andReturn('core'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn([$ability]); + + return new AbilityRegistrar(new IntegrationRegistry($integration)); + } + + private function ability(): Mockery\MockInterface + { + $ability = Mockery::mock(Ability::class); + $ability->allows('name')->andReturn('lit/test'); + $ability->allows('label')->andReturn('Test'); + $ability->allows('description')->andReturn('Test ability'); + $ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]); + $ability->allows('outputSchema')->andReturn(['type' => 'object']); + $ability->allows('annotations')->andReturn(['readonly' => true]); + $ability->allows('isDirect')->andReturn(true); + + return $ability; + } + + public function testArgsExposeAbilityPubliclyToMcpWithAnnotations(): void + { + $args = $this->registrarFor($this->ability())->argsFor($this->ability()); + + self::assertSame('Test', $args['label']); + self::assertSame(AbilityRegistrar::CATEGORY, $args['category']); + self::assertSame(['type' => 'object'], $args['output_schema']); + self::assertTrue($args['meta']['mcp']['public']); + self::assertSame(['readonly' => true], $args['meta']['annotations']); + self::assertFalse($args['meta']['show_in_rest']); + } + + public function testPermissionDeniedWithoutLitCapability(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = $this->ability(); + $ability->shouldNotReceive('permission'); + + $result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_forbidden', $result->get_error_code()); + } + + public function testPermissionDelegatesToAbilityWhenGatePasses(): void + { + Functions\when('current_user_can')->justReturn(true); + $ability = $this->ability(); + $ability->expects('permission')->with(['x' => 1])->andReturn(true); + + $result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])(['x' => 1]); + + self::assertTrue($result); + } + + public function testNullInputIsPassedAsEmptyArray(): void + { + $ability = $this->ability(); + $ability->expects('execute')->with([])->andReturn(['ok' => true]); + + $result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])(null); + + self::assertSame(['ok' => true], $result); + } + + public function testExceptionBecomesNeutralInternalError(): void + { + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + $ability = $this->ability(); + $ability->expects('execute')->andThrow(new RuntimeException('secret path /var/www/x.php')); + + try { + $result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_internal_error', $result->get_error_code()); + self::assertStringNotContainsString('/var/www', $result->get_error_message()); + self::assertStringContainsString('lit/test failed', (string) file_get_contents($log)); + unlink($log); + } + + public function testRegisterAbilitiesCallsWordPressForEachAbility(): void + { + $ability = $this->ability(); + Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array')); + + $this->registrarFor($ability)->registerAbilities(); + } +} diff --git a/tests/Unit/Integrations/IntegrationRegistryTest.php b/tests/Unit/Integrations/IntegrationRegistryTest.php new file mode 100644 index 0000000..eb015fe --- /dev/null +++ b/tests/Unit/Integrations/IntegrationRegistryTest.php @@ -0,0 +1,50 @@ +ability('lit/a', true); + $indirect = $this->ability('lit/b', false); + $hidden = $this->ability('lit/c', true); + + $registry = new IntegrationRegistry( + $this->integration('core', true, [$direct, $indirect]), + $this->integration('divi', false, [$hidden]) + ); + + self::assertSame(['core'], array_map(static fn ($i) => $i->id(), $registry->available())); + self::assertSame([$direct, $indirect], $registry->abilities()); + self::assertSame(['lit/a'], $registry->directAbilityNames()); + } + + private function ability(string $name, bool $direct): Ability + { + $ability = Mockery::mock(Ability::class); + $ability->allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn($direct); + + return $ability; + } + + /** @param list $abilities */ + private function integration(string $id, bool $available, array $abilities): Integration + { + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn($id); + $integration->allows('isAvailable')->andReturn($available); + $integration->allows('abilities')->andReturn($abilities); + + return $integration; + } +} From b54d55e5e11e1d00a6a138dd4d58a18fea3fef0f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:32:26 +0200 Subject: [PATCH 008/185] feat: guide tool and server instructions Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 23 +++++ src/Guides/GuideRepository.php | 51 ++++++++++ src/Guides/InstructionsBuilder.php | 40 ++++++++ src/Integrations/Core/GuideAbility.php | 94 +++++++++++++++++++ tests/Fixtures/guides/divi-shortform.md | 2 + tests/Fixtures/guides/overview.md | 2 + tests/Unit/Guides/GuideRepositoryTest.php | 55 +++++++++++ tests/Unit/Guides/InstructionsBuilderTest.php | 28 ++++++ .../Integrations/Core/GuideAbilityTest.php | 53 +++++++++++ 9 files changed, 348 insertions(+) create mode 100644 docs/guides/overview.md create mode 100644 src/Guides/GuideRepository.php create mode 100644 src/Guides/InstructionsBuilder.php create mode 100644 src/Integrations/Core/GuideAbility.php create mode 100644 tests/Fixtures/guides/divi-shortform.md create mode 100644 tests/Fixtures/guides/overview.md create mode 100644 tests/Unit/Guides/GuideRepositoryTest.php create mode 100644 tests/Unit/Guides/InstructionsBuilderTest.php create mode 100644 tests/Unit/Integrations/Core/GuideAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md new file mode 100644 index 0000000..6f8cdd6 --- /dev/null +++ b/docs/guides/overview.md @@ -0,0 +1,23 @@ +# LIT MCP – Übersicht + +Dieser Server pflegt eine WordPress-Website. Alle Änderungen laufen mit den Rechten +des verbundenen WordPress-Benutzers. + +## Grundregeln + +1. **Divi-nativ:** Inhalte werden als echte Divi-5-Module erzeugt, damit sie im Visual + Builder weiter bearbeitet werden können. Das Code-Modul nur verwenden, wenn kein + natives Modul passt. +2. **Designsystem nutzen:** Globale Farben, Variablen und Presets der Website vor festen + Werten verwenden. +3. **Status respektieren:** Entwürfe bleiben Entwürfe, veröffentlichte Inhalte bleiben + veröffentlicht. Veröffentlichen nur auf ausdrücklichen Wunsch. +4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools + verlangen den `rev` des letzten Lesevorgangs. + +## Verfügbare Werkzeuge (Etappe 0) + +- `lit-guide` – diese Anleitungen. Weitere Themen: siehe Feld `topics`. +- `lit-site-status` – Überblick über Website, aktive Integrationen und eigene Rechte. + +Weitere Werkzeuge kommen mit den nächsten Ausbaustufen hinzu. diff --git a/src/Guides/GuideRepository.php b/src/Guides/GuideRepository.php new file mode 100644 index 0000000..e9b3f4b --- /dev/null +++ b/src/Guides/GuideRepository.php @@ -0,0 +1,51 @@ + */ + public function topics(): array + { + $files = glob($this->directory . '/*.md'); + if ($files === false) { + return []; + } + + $topics = []; + foreach ($files as $file) { + $topic = basename($file, '.md'); + if (preg_match(self::TOPIC_PATTERN, $topic) === 1) { + $topics[] = $topic; + } + } + sort($topics); + + return $topics; + } + + public function get(string $topic): ?string + { + if (preg_match(self::TOPIC_PATTERN, $topic) !== 1 || ! in_array($topic, $this->topics(), true)) { + return null; + } + + $path = $this->directory . '/' . $topic . '.md'; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents($path); + + return $content === false ? null : $content; + } +} diff --git a/src/Guides/InstructionsBuilder.php b/src/Guides/InstructionsBuilder.php new file mode 100644 index 0000000..0594612 --- /dev/null +++ b/src/Guides/InstructionsBuilder.php @@ -0,0 +1,40 @@ + $integration->id(), + $this->registry->available() + )); + + return implode("\n", [ + 'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).', + 'Aktive Integrationen: ' . $active . '.', + 'Regeln:', + '- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide ' + . 'mit topic.', + '- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, ' + . 'wenn kein natives Modul passt.', + '- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich.', + '- Schreib-Tools verlangen den aktuellen rev-Wert aus dem letzten Lesevorgang.', + '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', + ]); + } +} diff --git a/src/Integrations/Core/GuideAbility.php b/src/Integrations/Core/GuideAbility.php new file mode 100644 index 0000000..c2bfbfc --- /dev/null +++ b/src/Integrations/Core/GuideAbility.php @@ -0,0 +1,94 @@ + 'object', + 'properties' => [ + 'topic' => [ + 'type' => 'string', + 'description' => 'Thema, z. B. "overview" oder "divi-shortform".', + ], + ], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'topic' => ['type' => 'string'], + 'topics' => ['type' => 'array', 'items' => ['type' => 'string']], + 'content' => ['type' => 'string'], + ], + 'required' => ['topic', 'topics', 'content'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return true; + } + + public function execute(array $input): mixed + { + $topic = isset($input['topic']) && is_string($input['topic']) && $input['topic'] !== '' + ? $input['topic'] + : self::DEFAULT_TOPIC; + $topics = $this->guides->topics(); + $content = $this->guides->get($topic); + + if ($content === null) { + return Errors::notFound(sprintf( + 'Unbekanntes Thema. Verfügbar: %s', + implode(', ', $topics) + )); + } + + return ['topic' => $topic, 'topics' => $topics, 'content' => $content]; + } +} diff --git a/tests/Fixtures/guides/divi-shortform.md b/tests/Fixtures/guides/divi-shortform.md new file mode 100644 index 0000000..12de54a --- /dev/null +++ b/tests/Fixtures/guides/divi-shortform.md @@ -0,0 +1,2 @@ +# Divi-Kurzform +Testinhalt Kurzform. diff --git a/tests/Fixtures/guides/overview.md b/tests/Fixtures/guides/overview.md new file mode 100644 index 0000000..6ac5c5d --- /dev/null +++ b/tests/Fixtures/guides/overview.md @@ -0,0 +1,2 @@ +# Übersicht +Testinhalt Übersicht. diff --git a/tests/Unit/Guides/GuideRepositoryTest.php b/tests/Unit/Guides/GuideRepositoryTest.php new file mode 100644 index 0000000..fd9450d --- /dev/null +++ b/tests/Unit/Guides/GuideRepositoryTest.php @@ -0,0 +1,55 @@ +guides = new GuideRepository(dirname(__DIR__, 2) . '/Fixtures/guides'); + } + + public function testListsTopicsSortedWithoutExtension(): void + { + self::assertSame(['divi-shortform', 'overview'], $this->guides->topics()); + } + + public function testReturnsMarkdownOfKnownTopic(): void + { + self::assertStringContainsString('Testinhalt Kurzform', (string) $this->guides->get('divi-shortform')); + } + + /** + * @dataProvider maliciousTopics + */ + public function testRejectsAnythingThatIsNotAPlainTopicName(string $topic): void + { + self::assertNull($this->guides->get($topic)); + } + + /** @return array */ + public static function maliciousTopics(): array + { + return [ + 'traversal' => ['../../wp-config'], + 'with extension' => ['overview.md'], + 'empty' => [''], + 'absolute' => ['/etc/passwd'], + 'unknown' => ['does-not-exist'], + 'uppercase' => ['OVERVIEW'], + ]; + } + + public function testMissingDirectoryYieldsNoTopics(): void + { + self::assertSame([], (new GuideRepository('/nonexistent/dir'))->topics()); + } +} diff --git a/tests/Unit/Guides/InstructionsBuilderTest.php b/tests/Unit/Guides/InstructionsBuilderTest.php new file mode 100644 index 0000000..587ce69 --- /dev/null +++ b/tests/Unit/Guides/InstructionsBuilderTest.php @@ -0,0 +1,28 @@ +allows('id')->andReturn('core'); + $core->allows('isAvailable')->andReturn(true); + + $text = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); + + self::assertStringContainsString('Aktive Integrationen: core', $text); + self::assertStringContainsString('lit-guide', $text); + self::assertStringContainsString('Status', $text); + self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.'); + } +} diff --git a/tests/Unit/Integrations/Core/GuideAbilityTest.php b/tests/Unit/Integrations/Core/GuideAbilityTest.php new file mode 100644 index 0000000..24c9387 --- /dev/null +++ b/tests/Unit/Integrations/Core/GuideAbilityTest.php @@ -0,0 +1,53 @@ +ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides')); + } + + public function testWithoutTopicReturnsOverviewAndTopicList(): void + { + $result = $this->ability->execute([]); + + self::assertSame('overview', $result['topic']); + self::assertSame(['divi-shortform', 'overview'], $result['topics']); + self::assertStringContainsString('Testinhalt Übersicht', $result['content']); + } + + public function testReturnsRequestedTopic(): void + { + $result = $this->ability->execute(['topic' => 'divi-shortform']); + + self::assertStringContainsString('Testinhalt Kurzform', $result['content']); + } + + public function testUnknownTopicIsNotFoundErrorListingTopics(): void + { + $result = $this->ability->execute(['topic' => '../../wp-config']); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_not_found', $result->get_error_code()); + self::assertStringContainsString('divi-shortform', $result->get_error_message()); + } + + public function testIsDirectReadOnlyTool(): void + { + self::assertSame('lit/guide', $this->ability->name()); + self::assertTrue($this->ability->isDirect()); + self::assertTrue($this->ability->annotations()['readonly']); + } +} From fb4787c032becf8c29fbc6c188a2bd6633c73920 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:36:53 +0200 Subject: [PATCH 009/185] feat: site-status test tool and core integration Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Core/CoreIntegration.php | 34 ++++++++ src/Integrations/Core/EnvironmentProbe.php | 36 +++++++++ src/Integrations/Core/SiteStatusAbility.php | 80 +++++++++++++++++++ .../Integrations/Core/CoreIntegrationTest.php | 23 ++++++ .../Core/SiteStatusAbilityTest.php | 36 +++++++++ 5 files changed, 209 insertions(+) create mode 100644 src/Integrations/Core/CoreIntegration.php create mode 100644 src/Integrations/Core/EnvironmentProbe.php create mode 100644 src/Integrations/Core/SiteStatusAbility.php create mode 100644 tests/Unit/Integrations/Core/CoreIntegrationTest.php create mode 100644 tests/Unit/Integrations/Core/SiteStatusAbilityTest.php diff --git a/src/Integrations/Core/CoreIntegration.php b/src/Integrations/Core/CoreIntegration.php new file mode 100644 index 0000000..18883c6 --- /dev/null +++ b/src/Integrations/Core/CoreIntegration.php @@ -0,0 +1,34 @@ +guidesDirectory)), + new SiteStatusAbility(new EnvironmentProbe(), new Access()), + ]; + } +} diff --git a/src/Integrations/Core/EnvironmentProbe.php b/src/Integrations/Core/EnvironmentProbe.php new file mode 100644 index 0000000..7e059b7 --- /dev/null +++ b/src/Integrations/Core/EnvironmentProbe.php @@ -0,0 +1,36 @@ + */ + public function components(): array + { + $theme = wp_get_theme(); + $isDivi = $theme->get_template() === 'Divi'; + $parent = $theme->parent(); + $diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null; + + $tecActive = class_exists('Tribe__Events__Main'); + + return [ + ['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion], + [ + 'id' => 'pods', + 'active' => defined('PODS_VERSION'), + 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null, + ], + [ + 'id' => 'the-events-calendar', + 'active' => $tecActive, + 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, + ], + ]; + } +} diff --git a/src/Integrations/Core/SiteStatusAbility.php b/src/Integrations/Core/SiteStatusAbility.php new file mode 100644 index 0000000..fd43c2a --- /dev/null +++ b/src/Integrations/Core/SiteStatusAbility.php @@ -0,0 +1,80 @@ + 'object', 'properties' => [], 'additionalProperties' => false]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'site' => ['type' => 'object'], + 'wordpress' => ['type' => 'string'], + 'user' => ['type' => 'object'], + 'allowed_roles' => ['type' => 'array', 'items' => ['type' => 'string']], + 'components' => ['type' => 'array'], + ], + 'required' => ['site', 'wordpress', 'user', 'allowed_roles', 'components'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return true; + } + + public function execute(array $input): mixed + { + $user = wp_get_current_user(); + + return [ + 'site' => ['name' => get_bloginfo('name'), 'url' => home_url()], + 'wordpress' => get_bloginfo('version'), + 'user' => ['id' => $user->ID, 'roles' => array_values($user->roles)], + 'allowed_roles' => $this->access->allowedRoles(), + 'components' => $this->probe->components(), + ]; + } +} diff --git a/tests/Unit/Integrations/Core/CoreIntegrationTest.php b/tests/Unit/Integrations/Core/CoreIntegrationTest.php new file mode 100644 index 0000000..a93088d --- /dev/null +++ b/tests/Unit/Integrations/Core/CoreIntegrationTest.php @@ -0,0 +1,23 @@ +id()); + self::assertTrue($core->isAvailable()); + self::assertSame( + ['lit/guide', 'lit/site-status'], + array_map(static fn ($a) => $a->name(), $core->abilities()) + ); + } +} diff --git a/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php new file mode 100644 index 0000000..4382bc8 --- /dev/null +++ b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php @@ -0,0 +1,36 @@ +alias(static fn (string $key) => ['name' => 'Kunde', 'version' => '7.0.3'][$key]); + Functions\when('home_url')->justReturn('https://kunde.de'); + Functions\when('wp_get_current_user')->justReturn(new WP_User(3, ['editor'])); + Functions\when('get_option')->justReturn(['administrator', 'editor']); + + // EnvironmentProbe is deliberately non-final so it can be mocked; Access is final, so use the real one. + $probe = Mockery::mock(EnvironmentProbe::class); + $probe->allows('components')->andReturn([['id' => 'divi', 'active' => true, 'version' => '5.2.1']]); + + $result = (new SiteStatusAbility($probe, new Access()))->execute([]); + + self::assertSame(['name' => 'Kunde', 'url' => 'https://kunde.de'], $result['site']); + self::assertSame('7.0.3', $result['wordpress']); + self::assertSame(['id' => 3, 'roles' => ['editor']], $result['user']); + self::assertSame(['administrator', 'editor'], $result['allowed_roles']); + self::assertSame('divi', $result['components'][0]['id']); + } +} From 3661d143fb786c53059a0ff1b05351c9615e1115 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:46:24 +0200 Subject: [PATCH 010/185] feat: register OAuth MCP server with direct tools Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Plugin.php | 15 ++++ src/Server/ServerRegistrar.php | 100 ++++++++++++++++++++++ tests/Unit/Server/ServerRegistrarTest.php | 80 +++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 src/Server/ServerRegistrar.php create mode 100644 tests/Unit/Server/ServerRegistrarTest.php diff --git a/src/Plugin.php b/src/Plugin.php index db2ec0e..b3b19ca 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,6 +4,12 @@ declare(strict_types=1); namespace LIT\MCP; +use LIT\MCP\Abilities\AbilityRegistrar; +use LIT\MCP\Guides\InstructionsBuilder; +use LIT\MCP\Integrations\Core\CoreIntegration; +use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Server\ServerRegistrar; + /** * Wires WP-LIT-MCP into WordPress. */ @@ -22,6 +28,15 @@ final class Plugin } (new Access())->register(); + + $registry = new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides') + ); + + (new AbilityRegistrar($registry))->register(); + (new ServerRegistrar($registry, new InstructionsBuilder($registry)))->register(); + + \WPMedia\MCP\OAuth\Bootstrap::instance(); } public static function pluginDir(): string diff --git a/src/Server/ServerRegistrar.php b/src/Server/ServerRegistrar.php new file mode 100644 index 0000000..9cbb326 --- /dev/null +++ b/src/Server/ServerRegistrar.php @@ -0,0 +1,100 @@ +oauthEnabled = $oauthEnabled + ?? static fn (): bool => (bool) apply_filters('wpmedia_mcp_oauth_server_enabled', true); + } + + public function register(): void + { + add_action('mcp_adapter_init', [$this, 'createServer'], 5); + } + + /** @return array|null */ + public function serverArguments(): ?array + { + if (! ($this->oauthEnabled)()) { + return null; + } + + return [ + 'id' => self::SERVER_ID, + 'namespace' => self::NAMESPACE, + 'route' => self::ROUTE, + 'name' => 'LIT MCP', + 'description' => $this->instructions->build(), + 'version' => '0.1.0', + 'tools' => array_merge($this->registry->directAbilityNames(), self::META_TOOLS), + ]; + } + + public function createServer(): void + { + if ( + ! doing_action('mcp_adapter_init') + || ! class_exists(McpAdapter::class) + || ! class_exists(OAuthHttpTransport::class) + ) { + return; + } + + $args = $this->serverArguments(); + if ($args === null) { + return; + } + + $result = McpAdapter::instance()->create_server( + $args['id'], + $args['namespace'], + $args['route'], + $args['name'], + $args['description'], + $args['version'], + [OAuthHttpTransport::class], + ErrorLogMcpErrorHandler::class, + null, + $args['tools'] + ); + + if (is_wp_error($result)) { + $message = '[wp-lit-mcp] MCP server registration failed: ' . $result->get_error_message(); + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + } +} diff --git a/tests/Unit/Server/ServerRegistrarTest.php b/tests/Unit/Server/ServerRegistrarTest.php new file mode 100644 index 0000000..bc25cda --- /dev/null +++ b/tests/Unit/Server/ServerRegistrarTest.php @@ -0,0 +1,80 @@ +allows('name')->andReturn('lit/guide'); + $direct->allows('isDirect')->andReturn(true); + $indirect = Mockery::mock(Ability::class); + $indirect->allows('name')->andReturn('lit/rare-tool'); + $indirect->allows('isDirect')->andReturn(false); + + $core = Mockery::mock(Integration::class); + $core->allows('id')->andReturn('core'); + $core->allows('isAvailable')->andReturn(true); + $core->allows('abilities')->andReturn([$direct, $indirect]); + + return new IntegrationRegistry($core); + } + + private function instructions(): InstructionsBuilder + { + // InstructionsBuilder is final (not mockable) and pure – use the real one. + return new InstructionsBuilder($this->registry()); + } + + public function testServerUsesOauthRouteDirectToolsPlusMetaToolsAndInstructions(): void + { + $args = (new ServerRegistrar($this->registry(), $this->instructions(), static fn () => true))->serverArguments(); + + self::assertSame('mcp-oauth-server', $args['id']); + self::assertSame('mcp', $args['namespace']); + self::assertSame('mcp-oauth-server', $args['route']); + self::assertStringContainsString('Aktive Integrationen: core', $args['description']); + self::assertSame( + ['lit/guide', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'], + $args['tools'] + ); + self::assertNotContains('lit/rare-tool', $args['tools']); + } + + public function testNoServerWhenOauthIsDisabled(): void + { + $registrar = new ServerRegistrar($this->registry(), $this->instructions(), static fn () => false); + + self::assertNull($registrar->serverArguments()); + } + + public function testCreateServerOutsideAdapterInitDoesNothing(): void + { + Functions\when('doing_action')->justReturn(false); + $oauthChecked = false; + $registrar = new ServerRegistrar( + $this->registry(), + $this->instructions(), + static function () use (&$oauthChecked): bool { + $oauthChecked = true; + return true; + } + ); + + $registrar->createServer(); + + self::assertFalse($oauthChecked, 'createServer must bail out before touching the adapter'); + } +} From 3299034297ac1708816eb6c46350dc09d46e7643 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:50:51 +0200 Subject: [PATCH 011/185] fix: prevent mcp-oauth's duplicate server registration instead of tolerating it Proactively unhook wp-media/mcp-oauth's own mcp_adapter_init callback before creating our server, rather than letting it collide and rely on WP_DEBUG=true being harmless. The collision previously surfaced as a fatal-looking _doing_it_wrong notice under WP-CLI (breaking every wp command while the plugin is active) and could leak debug notices into HTTP responses on customer sites that run WP_DEBUG. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Server/ServerRegistrar.php | 45 +++++++++++++++++++++-- tests/Unit/Server/ServerRegistrarTest.php | 40 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/Server/ServerRegistrar.php b/src/Server/ServerRegistrar.php index 9cbb326..55e9122 100644 --- a/src/Server/ServerRegistrar.php +++ b/src/Server/ServerRegistrar.php @@ -9,14 +9,19 @@ use LIT\MCP\Integrations\IntegrationRegistry; use WP\MCP\Core\McpAdapter; use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler; use WPMedia\MCP\OAuth\Transport\OAuthHttpTransport; +use WPMedia\MCP\OAuth\Transport\ServerRegistrar as OAuthServerRegistrar; /** * Registers the OAuth-protected MCP server with our own tool list. * * wp-media/mcp-oauth hardcodes server id/route (token audience) and only three - * meta-tools. We register the same server id first (priority 5 < mcp-oauth's 10); - * mcp-oauth's later attempt fails with "duplicate server id" (logged via - * _doing_it_wrong in debug mode only) – see spec §4.2. + * meta-tools, and registers its own server unconditionally on the same + * `mcp_adapter_init` action (priority 10). We register the same server id + * first (priority 5) and, right before creating it, unhook mcp-oauth's own + * `ServerRegistrar::register_server` callback so it never runs – see + * `unhookOAuthDefaultServer()`. This avoids the duplicate-server-id WP_Error + * (and the `_doing_it_wrong()` notice it used to trigger) entirely instead of + * merely tolerating it – see spec §4.2. */ final class ServerRegistrar { @@ -79,6 +84,8 @@ final class ServerRegistrar return; } + $this->unhookOAuthDefaultServer(); + $result = McpAdapter::instance()->create_server( $args['id'], $args['namespace'], @@ -97,4 +104,36 @@ final class ServerRegistrar error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } + + /** + * Removes wp-media/mcp-oauth's own `mcp_adapter_init` callback so it never attempts to + * register its (now redundant) copy of the `mcp-oauth-server` server. + * + * Reads the raw `WP_Hook` for `mcp_adapter_init` from `$GLOBALS['wp_filter']` – there is no + * public API to look up a hooked callback by the class of its bound object, only by the exact + * callable, which we don't have here. Removing a same-action, later-priority callback while an + * earlier-priority one for the same action is running is explicitly supported by + * `WP_Hook::remove_filter()` (it calls `resort_active_iterations()` for exactly this case). + * No-op if the hook has not fired yet, has no callbacks, or the mcp-oauth package is absent. + */ + public function unhookOAuthDefaultServer(): void + { + if (! class_exists(OAuthServerRegistrar::class)) { + return; + } + + $hook = $GLOBALS['wp_filter']['mcp_adapter_init'] ?? null; + if (! is_object($hook) || ! isset($hook->callbacks) || ! is_array($hook->callbacks)) { + return; + } + + foreach ($hook->callbacks as $priority => $callbacks) { + foreach ($callbacks as $callback) { + $function = $callback['function'] ?? null; + if (is_array($function) && ($function[0] ?? null) instanceof OAuthServerRegistrar) { + remove_action('mcp_adapter_init', $function, $priority); + } + } + } + } } diff --git a/tests/Unit/Server/ServerRegistrarTest.php b/tests/Unit/Server/ServerRegistrarTest.php index bc25cda..bd916dd 100644 --- a/tests/Unit/Server/ServerRegistrarTest.php +++ b/tests/Unit/Server/ServerRegistrarTest.php @@ -12,9 +12,16 @@ use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Server\ServerRegistrar; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use WPMedia\MCP\OAuth\Transport\ServerRegistrar as OAuthServerRegistrar; final class ServerRegistrarTest extends TestCase { + protected function tearDown(): void + { + unset($GLOBALS['wp_filter']); + parent::tearDown(); + } + private function registry(): IntegrationRegistry { $direct = Mockery::mock(Ability::class); @@ -77,4 +84,37 @@ final class ServerRegistrarTest extends TestCase self::assertFalse($oauthChecked, 'createServer must bail out before touching the adapter'); } + + public function testUnhookOAuthDefaultServerRemovesOnlyTheMcpOauthCallback(): void + { + $oauthRegistrar = Mockery::mock(OAuthServerRegistrar::class); + $oauthCallback = [$oauthRegistrar, 'register_server']; + $unrelatedCallback = static function (): void { + }; + + $hook = new class { + /** @var array> */ + public array $callbacks = []; + }; + $hook->callbacks = [ + 5 => ['unrelated-id' => ['function' => $unrelatedCallback, 'accepted_args' => 0]], + 10 => ['oauth-id' => ['function' => $oauthCallback, 'accepted_args' => 0]], + ]; + $GLOBALS['wp_filter']['mcp_adapter_init'] = $hook; + + Functions\expect('remove_action') + ->once() + ->with('mcp_adapter_init', $oauthCallback, 10); + + (new ServerRegistrar($this->registry(), $this->instructions()))->unhookOAuthDefaultServer(); + } + + public function testUnhookOAuthDefaultServerIsNoOpWhenHookIsMissing(): void + { + unset($GLOBALS['wp_filter']['mcp_adapter_init']); + + Functions\expect('remove_action')->never(); + + (new ServerRegistrar($this->registry(), $this->instructions()))->unhookOAuthDefaultServer(); + } } From f2580a7c642df9e34b1a6b90c396b670c7b0faff Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:56:50 +0200 Subject: [PATCH 012/185] fix: honor mcp-oauth's enabled/observability filters, disable second unprotected MCP endpoint - Default oauthEnabled to WPMedia\MCP\OAuth\Context::is_enabled() instead of duplicating its filter logic, so the deprecated rocket_mcp_oauth_server_enabled filter and the typed wpmedia_mcp_oauth_server_enabled filter are honored exactly as mcp-oauth itself defines them. - register() now disables mcp-adapter's default server (mcp_adapter_create_default_server => false) so no second, unauthenticated MCP endpoint (plain HttpTransport, is_user_logged_in()) is exposed alongside our OAuth-protected one; mcp-oauth's own ServerRegistrar registers the shared meta-abilities itself once the default server is disabled. - Pass WPMedia\MCP\OAuth\Context::observability_handler() instead of null so mcp-oauth's own observability filter keeps working for our server too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Server/ServerRegistrar.php | 15 ++++++++++-- tests/Unit/Server/ServerRegistrarTest.php | 29 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Server/ServerRegistrar.php b/src/Server/ServerRegistrar.php index 55e9122..abec288 100644 --- a/src/Server/ServerRegistrar.php +++ b/src/Server/ServerRegistrar.php @@ -8,12 +8,16 @@ use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\IntegrationRegistry; use WP\MCP\Core\McpAdapter; use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler; +use WPMedia\MCP\OAuth\Context as OAuthContext; use WPMedia\MCP\OAuth\Transport\OAuthHttpTransport; use WPMedia\MCP\OAuth\Transport\ServerRegistrar as OAuthServerRegistrar; /** * Registers the OAuth-protected MCP server with our own tool list. * + * `register()` disables mcp-adapter's default server (`mcp_adapter_create_default_server`) + * so no second, unprotected MCP endpoint is exposed alongside ours. + * * wp-media/mcp-oauth hardcodes server id/route (token audience) and only three * meta-tools, and registers its own server unconditionally on the same * `mcp_adapter_init` action (priority 10). We register the same server id @@ -43,11 +47,18 @@ final class ServerRegistrar ?callable $oauthEnabled = null ) { $this->oauthEnabled = $oauthEnabled - ?? static fn (): bool => (bool) apply_filters('wpmedia_mcp_oauth_server_enabled', true); + ?? static fn (): bool => (new OAuthContext())->is_enabled(); } public function register(): void { + // mcp-adapter's default server is a second, unprotected (plain HttpTransport, + // is_user_logged_in()) endpoint exposing execute-ability – and thus our own + // abilities. We register our own OAuth-protected server instead; mcp-oauth's + // ServerRegistrar registers the shared meta-abilities itself once this filter + // disables the default server (see its ensure_shared_abilities_registered()). + add_filter('mcp_adapter_create_default_server', '__return_false'); + add_action('mcp_adapter_init', [$this, 'createServer'], 5); } @@ -95,7 +106,7 @@ final class ServerRegistrar $args['version'], [OAuthHttpTransport::class], ErrorLogMcpErrorHandler::class, - null, + (new OAuthContext())->observability_handler(), $args['tools'] ); diff --git a/tests/Unit/Server/ServerRegistrarTest.php b/tests/Unit/Server/ServerRegistrarTest.php index bd916dd..215f5b2 100644 --- a/tests/Unit/Server/ServerRegistrarTest.php +++ b/tests/Unit/Server/ServerRegistrarTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Server; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; use LIT\MCP\Guides\InstructionsBuilder; @@ -85,6 +86,34 @@ final class ServerRegistrarTest extends TestCase self::assertFalse($oauthChecked, 'createServer must bail out before touching the adapter'); } + public function testRegisterDisablesAdapterDefaultServerAndHooksCreateServer(): void + { + $registrar = new ServerRegistrar($this->registry(), $this->instructions()); + + Functions\expect('add_filter') + ->once() + ->with('mcp_adapter_create_default_server', '__return_false'); + Functions\expect('add_action') + ->once() + ->with('mcp_adapter_init', [$registrar, 'createServer'], 5); + + $registrar->register(); + } + + public function testDefaultOauthEnabledDelegatesToMcpOauthContext(): void + { + // LIT\MCP\Server\ServerRegistrar::__construct() defaults to + // (new \WPMedia\MCP\OAuth\Context())->is_enabled() – exercise that real + // Context, only stubbing the filter it reads, to prove we track its notion + // of "enabled" (including the deprecated rocket_mcp_oauth_server_enabled + // filter Context::is_enabled() also seeds from) instead of duplicating it. + Filters\expectApplied('wpmedia_mcp_oauth_server_enabled')->once()->andReturn(false); + + $registrar = new ServerRegistrar($this->registry(), $this->instructions()); + + self::assertNull($registrar->serverArguments()); + } + public function testUnhookOAuthDefaultServerRemovesOnlyTheMcpOauthCallback(): void { $oauthRegistrar = Mockery::mock(OAuthServerRegistrar::class); From 210b1bad2fd0ce06059d79ee87a64f55d70aa4ab Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 16:06:10 +0200 Subject: [PATCH 013/185] test: end-to-end MCP smoke test against DDEV Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- tests/Integration/mcp-smoke.sh | 102 +++++++++++++++++++++++++++++++ tests/Integration/mint-token.php | 39 ++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 tests/Integration/mcp-smoke.sh create mode 100644 tests/Integration/mint-token.php diff --git a/tests/Integration/mcp-smoke.sh b/tests/Integration/mcp-smoke.sh new file mode 100644 index 0000000..92fdc23 --- /dev/null +++ b/tests/Integration/mcp-smoke.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# End-to-end checks of the OAuth-protected MCP endpoint. +# Runs INSIDE the DDEV web container (wp-cli and curl available there): +# cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh +set -euo pipefail + +PLUGIN=wp-content/plugins/WP-LIT-MCP +ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server +cd /var/www/html + +fail() { echo "FAIL: $*"; exit 1; } +pass() { echo "ok - $*"; } + +# --- Cleanup: remove every "lit-mcp-integration-test" application password +# this script mints (for every user it mints one for) and the throwaway +# subscriber it creates. Registered up front so it runs on any exit path +# (success, fail(), or an unexpected error under `set -e`). +ADMIN_LOGIN="" + +cleanup_app_passwords_for() { # $1 = user_login + local login="$1" + [ -n "$login" ] || return 0 + local escaped=${login//\'/\\\'} + wp eval " +\$u = get_user_by('login', '${escaped}'); +if (\$u) { + foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) { + if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') { + WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']); + } + } +} +" >/dev/null 2>&1 || true +} + +cleanup() { + cleanup_app_passwords_for "$ADMIN_LOGIN" + cleanup_app_passwords_for lit-smoke-sub + wp user delete lit-smoke-sub --yes >/dev/null 2>&1 || true +} +trap cleanup EXIT + +mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+body + local session_header=() + [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") + curl -s -i -X POST "$ENDPOINT" \ + -H "Authorization: Bearer $1" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + "${session_header[@]}" -d "$2" +} + +# 1. Discovery documents +curl -s http://localhost/.well-known/oauth-protected-resource | grep -q '"resource"' \ + || fail "protected-resource metadata missing" +pass "RFC 9728 protected-resource metadata served" +curl -s http://localhost/.well-known/oauth-authorization-server | grep -q 'client_id_metadata_document_supported' \ + || fail "authorization-server metadata missing CIMD flag" +pass "RFC 8414 authorization-server metadata advertises CIMD" + +# 2. Unauthenticated request -> 401 with WWW-Authenticate +UNAUTH=$(curl -s -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}') +echo "$UNAUTH" | head -1 | grep -q ' 401' || fail "expected 401 without token" +echo "$UNAUTH" | grep -qi '^www-authenticate: Bearer' || fail "missing WWW-Authenticate header" +pass "401 + WWW-Authenticate without token" + +# 3. Administrator: initialize, tools/list, tools/call +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$ADMIN_LOGIN") +INIT=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') +SESSION=$(echo "$INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r') +echo "$INIT" | grep -q 'LIT MCP pflegt' || fail "instructions missing in initialize result" +pass "initialize returns LIT instructions (session: ${SESSION:-none})" + +LIST=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' "$SESSION") +for tool in lit-guide lit-site-status mcp-adapter-execute-ability; do + echo "$LIST" | grep -q "\"$tool\"" || fail "tools/list lacks $tool" +done +pass "tools/list contains direct tools and meta-tools" + +CALL=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SESSION") +echo "$CALL" | grep -q '"isError":false' || fail "lit-site-status failed: $CALL" +echo "$CALL" | grep -q 'the-events-calendar' || fail "site-status lacks components" +pass "tools/call lit-site-status works" + +# 4. Token must not authenticate other REST routes (mcp-oauth issue #62) +ME=$(curl -s -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/users/me -H "Authorization: Bearer $TOKEN") +[ "$ME" = "401" ] || fail "MCP token authenticates /wp/v2/users/me (HTTP $ME) – scope filter needed" +pass "MCP token is rejected on /wp/v2/users/me" + +# 5. Role gate: subscriber has a valid token but must not use tools +wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/dev/null +SUB_TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" lit-smoke-sub) +SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') +SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r') +SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION") +echo "$SUB_CALL" | grep -q '"isError":true\|"error"' || fail "subscriber could call lit-site-status: $SUB_CALL" +SUB_META=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"mcp-adapter-execute-ability","arguments":{"ability_name":"lit/site-status","parameters":{}}}}' "$SUB_SESSION") +echo "$SUB_META" | grep -q '"isError":true\|"error"' || fail "subscriber could use execute-ability: $SUB_META" +pass "role gate blocks subscriber (direct tool and meta-tool)" + +echo "ALL CHECKS PASSED" diff --git a/tests/Integration/mint-token.php b/tests/Integration/mint-token.php new file mode 100644 index 0000000..6d7907d --- /dev/null +++ b/tests/Integration/mint-token.php @@ -0,0 +1,39 @@ + + * + * Note: no `declare(strict_types=1);` here — PHP forbids that declaration + * inside eval()'d code, and `wp eval-file` runs this file through eval(). + * Confirmed with a minimal repro during Task 7 (see task-7-report.md). + */ + +use WPMedia\MCP\OAuth\Auth\JWT; +use WPMedia\MCP\OAuth\Auth\SecretManager; + +$login = $args[0] ?? ''; +$user = get_user_by('login', $login); +if (! $user) { + fwrite(STDERR, "unknown user {$login}\n"); + exit(1); +} + +[, $item] = WP_Application_Passwords::create_new_application_password( + $user->ID, + ['name' => 'lit-mcp-integration-test', 'app_id' => 'lit-mcp-integration-test'] +); + +$now = time(); +echo JWT::encode([ + 'iss' => home_url(), + 'aud' => get_rest_url(null, 'mcp/mcp-oauth-server'), + 'sub' => (string) $user->ID, + 'app_pass_id' => $item['uuid'], + 'client_id' => 'lit-mcp-integration-test', + 'scope' => 'mcp', + 'iat' => $now, + 'exp' => $now + 600, +], SecretManager::get_secret()); From ef3f73c5f27afbd88105dfb34159a363484976f4 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 18:55:41 +0200 Subject: [PATCH 014/185] fix: split MCP discovery check to bypass local nginx dotfile block Local DDEV nginx denies all dotfile paths (including /.well-known/), blocking check 1 before it reaches WordPress -- an environment issue, not a WP-LIT-MCP/mcp-oauth defect. Check 1 now hits mcp-oauth's own discovery rewrite target (index.php?mcp_oauth_discovery=...) with a Host header derived from `wp option get home` as a HARD check, and separately probes the real /.well-known/ path as a non-failing ENVIRONMENT check that warns if the web server blocks it. Also adds --max-time 20 to every curl call so the script can never hang. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- tests/Integration/mcp-smoke.sh | 41 +++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/tests/Integration/mcp-smoke.sh b/tests/Integration/mcp-smoke.sh index 92fdc23..6e3c106 100644 --- a/tests/Integration/mcp-smoke.sh +++ b/tests/Integration/mcp-smoke.sh @@ -43,7 +43,7 @@ trap cleanup EXIT mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+body local session_header=() [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") - curl -s -i -X POST "$ENDPOINT" \ + curl -s --max-time 20 -i -X POST "$ENDPOINT" \ -H "Authorization: Bearer $1" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ @@ -51,15 +51,46 @@ mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+bod } # 1. Discovery documents -curl -s http://localhost/.well-known/oauth-protected-resource | grep -q '"resource"' \ +# +# The local DDEV nginx config denies all dotfile paths (`location ~ /\. { deny +# all; }`, in the parent project's .ddev/nginx_full/nginx-site.conf, outside +# this plugin), which blocks the real `/.well-known/...` paths before the +# request ever reaches WordPress. That's an environment/deployment concern, +# not a WP-LIT-MCP or mcp-oauth defect, so it must not fail this test. +# +# The HARD checks below instead hit mcp-oauth's own rewrite target directly +# (index.php?mcp_oauth_discovery=...) via a request whose Host matches the +# site's home URL (WordPress issues a 301 to the canonical host otherwise), +# proving the discovery documents themselves are correct. The ENVIRONMENT +# check further down separately probes the real `/.well-known/` path and only +# warns, never fails, so a misconfigured web server doesn't block this suite +# while still surfacing the risk (real MCP clients like claude.ai use the +# real path, not the rewrite target). +HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') + +curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ + 'http://localhost/?mcp_oauth_discovery=protected-resource' \ + | grep -q '"resource"' \ || fail "protected-resource metadata missing" pass "RFC 9728 protected-resource metadata served" -curl -s http://localhost/.well-known/oauth-authorization-server | grep -q 'client_id_metadata_document_supported' \ + +curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ + 'http://localhost/?mcp_oauth_discovery=authorization-server' \ + | grep -q 'client_id_metadata_document_supported' \ || fail "authorization-server metadata missing CIMD flag" pass "RFC 8414 authorization-server metadata advertises CIMD" +# ENVIRONMENT check (never fails): the real .well-known path, same Host/proto. +WELLKNOWN=$(curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ + 'http://localhost/.well-known/oauth-protected-resource') +if echo "$WELLKNOWN" | grep -q '"resource"'; then + pass "/.well-known/ reachable" +else + echo "WARN - /.well-known/ blocked by web server config (claude.ai discovery will fail; allow .well-known in nginx)" +fi + # 2. Unauthenticated request -> 401 with WWW-Authenticate -UNAUTH=$(curl -s -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}') +UNAUTH=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}') echo "$UNAUTH" | head -1 | grep -q ' 401' || fail "expected 401 without token" echo "$UNAUTH" | grep -qi '^www-authenticate: Bearer' || fail "missing WWW-Authenticate header" pass "401 + WWW-Authenticate without token" @@ -84,7 +115,7 @@ echo "$CALL" | grep -q 'the-events-calendar' || fail "site-status lacks componen pass "tools/call lit-site-status works" # 4. Token must not authenticate other REST routes (mcp-oauth issue #62) -ME=$(curl -s -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/users/me -H "Authorization: Bearer $TOKEN") +ME=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/users/me -H "Authorization: Bearer $TOKEN") [ "$ME" = "401" ] || fail "MCP token authenticates /wp/v2/users/me (HTTP $ME) – scope filter needed" pass "MCP token is rejected on /wp/v2/users/me" From 3f81005a83eddc93536a5292ba8f0c7206e2c370 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 19:03:07 +0200 Subject: [PATCH 015/185] fix: tighten mcp-smoke.sh denial checks and escaping - Role-gate denial checks no longer match a bare "error" substring that could appear inside a successful response; they now require "isError":true or a real JSON-RPC error object ("error":{). - cleanup_app_passwords_for now escapes backslashes before single quotes when interpolating the user login into the wp eval PHP snippet. - The CIMD discovery check now requires client_id_metadata_document_supported to be true, not just present. - Mcp-Session-Id extraction no longer aborts under pipefail when the header is absent; a missing header now yields an empty SESSION, as already handled downstream. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- tests/Integration/mcp-smoke.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Integration/mcp-smoke.sh b/tests/Integration/mcp-smoke.sh index 6e3c106..b0cfaca 100644 --- a/tests/Integration/mcp-smoke.sh +++ b/tests/Integration/mcp-smoke.sh @@ -20,7 +20,8 @@ ADMIN_LOGIN="" cleanup_app_passwords_for() { # $1 = user_login local login="$1" [ -n "$login" ] || return 0 - local escaped=${login//\'/\\\'} + local escaped=${login//\\/\\\\} + escaped=${escaped//\'/\\\'} wp eval " \$u = get_user_by('login', '${escaped}'); if (\$u) { @@ -76,7 +77,7 @@ pass "RFC 9728 protected-resource metadata served" curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ 'http://localhost/?mcp_oauth_discovery=authorization-server' \ - | grep -q 'client_id_metadata_document_supported' \ + | grep -Eq '"client_id_metadata_document_supported":[[:space:]]*true' \ || fail "authorization-server metadata missing CIMD flag" pass "RFC 8414 authorization-server metadata advertises CIMD" @@ -99,7 +100,7 @@ pass "401 + WWW-Authenticate without token" ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$ADMIN_LOGIN") INIT=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') -SESSION=$(echo "$INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r') +SESSION=$(echo "$INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) echo "$INIT" | grep -q 'LIT MCP pflegt' || fail "instructions missing in initialize result" pass "initialize returns LIT instructions (session: ${SESSION:-none})" @@ -123,11 +124,11 @@ pass "MCP token is rejected on /wp/v2/users/me" wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/dev/null SUB_TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" lit-smoke-sub) SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') -SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r') +SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION") -echo "$SUB_CALL" | grep -q '"isError":true\|"error"' || fail "subscriber could call lit-site-status: $SUB_CALL" +echo "$SUB_CALL" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could call lit-site-status: $SUB_CALL" SUB_META=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"mcp-adapter-execute-ability","arguments":{"ability_name":"lit/site-status","parameters":{}}}}' "$SUB_SESSION") -echo "$SUB_META" | grep -q '"isError":true\|"error"' || fail "subscriber could use execute-ability: $SUB_META" +echo "$SUB_META" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could use execute-ability: $SUB_META" pass "role gate blocks subscriber (direct tool and meta-tool)" echo "ALL CHECKS PASSED" From 2c03286ba081ba431c307721d2916bbf325d2095 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 19:04:58 +0200 Subject: [PATCH 016/185] test: probe generic event creation for The Events Calendar Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- tests/Integration/tec-probe.php | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/Integration/tec-probe.php diff --git a/tests/Integration/tec-probe.php b/tests/Integration/tec-probe.php new file mode 100644 index 0000000..63111e7 --- /dev/null +++ b/tests/Integration/tec-probe.php @@ -0,0 +1,45 @@ + 'tribe_events', + 'post_status' => 'publish', + 'post_title' => 'LIT Probe Event (wegwerfen)', +]); +update_post_meta($id, '_EventStartDate', $start); +update_post_meta($id, '_EventEndDate', $end); + +$derived = []; +foreach (['_EventStartDateUTC', '_EventEndDateUTC', '_EventDuration', '_EventTimezone'] as $key) { + $derived[$key] = get_post_meta($id, $key, true); +} + +$found = tribe_get_events([ + 'start_date' => gmdate('Y-m-d', strtotime('+29 days')), + 'end_date' => gmdate('Y-m-d', strtotime('+31 days')), + 'fields' => 'ids', +]); + +echo wp_json_encode([ + 'event_id' => $id, + 'found_by_tribe_get_events' => in_array($id, (array) $found, true), + 'derived_meta' => $derived, +], JSON_PRETTY_PRINT) . "\n"; + +wp_delete_post($id, true); From 0e3332ad3e430f50d24b38e7289155bde7eb8d94 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 19:23:09 +0200 Subject: [PATCH 017/185] fix: close etappe-0 whole-branch review findings - Autoloader: add automattic/jetpack-autoloader and load vendor/autoload_packages.php; require wordpress/mcp-adapter directly (^0.5). - ServerRegistrar: make the adapter create_server() call and the OAuthServerRegistrar class_exists() check injectable so createServer()'s unhook-before-create ordering and the "mcp-oauth absent" branch are unit tested; add a smoke-test check that booting REST triggers no _doing_it_wrong(). - mcp-smoke.sh: assert mcp-adapter's default server route 404s, and that the subscriber role-gate denial actually carries our/the adapter's denial text (not just any isError/error). - Guard tests/Integration/mint-token.php and tec-probe.php with `defined('WP_CLI') && WP_CLI || exit;`; add .gitattributes export-ignore for tests/docs-superpowers/.superpowers/phpunit/phpcs config. - Security: add Server/RequestContext, tracking via rest_request_before_callbacks whether the current REST dispatch is our OAuth-protected /mcp/mcp-oauth-server route; AbilityRegistrar's permission_callback now denies (after the role gate) when abilities are invoked outside that route. Wired in Plugin::boot(). - Minors: single Plugin::VERSION constant used by ServerRegistrar; IntegrationRegistry caches its computed abilities list; tec-probe.php checks wp_insert_post's result and cleans up its probe post in a try/finally. composer test: 47/47 green. composer lint: clean. Full mcp-smoke.sh run ends ALL CHECKS PASSED (plus the expected /.well-known WARN). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .gitattributes | 5 ++ composer.json | 3 + composer.lock | 66 ++++++++++++++++++- src/Abilities/AbilityRegistrar.php | 5 ++ src/Integrations/IntegrationRegistry.php | 15 +++-- src/Plugin.php | 4 ++ src/Server/RequestContext.php | 62 +++++++++++++++++ src/Server/ServerRegistrar.php | 45 ++++++++----- tests/Integration/mcp-smoke.sh | 38 +++++++++++ tests/Integration/mint-token.php | 2 + tests/Integration/tec-probe.php | 49 +++++++++----- tests/Stubs/WpRestRequest.php | 17 +++++ tests/Unit/Abilities/AbilityRegistrarTest.php | 28 ++++++++ tests/Unit/Server/RequestContextTest.php | 61 +++++++++++++++++ tests/Unit/Server/ServerRegistrarTest.php | 65 ++++++++++++++++++ tests/bootstrap.php | 1 + wp-lit-mcp.php | 2 +- 17 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 .gitattributes create mode 100644 src/Server/RequestContext.php create mode 100644 tests/Stubs/WpRestRequest.php create mode 100644 tests/Unit/Server/RequestContextTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a55a796 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/docs/superpowers export-ignore +/.superpowers export-ignore +/phpunit.xml.dist export-ignore +/phpcs.xml.dist export-ignore diff --git a/composer.json b/composer.json index f7ed2a4..e7c6ee8 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,8 @@ "license": "GPL-2.0-or-later", "require": { "php": ">=8.1", + "automattic/jetpack-autoloader": "^3.0", + "wordpress/mcp-adapter": "^0.5", "wp-media/mcp-oauth": "^1.1" }, "require-dev": { @@ -30,6 +32,7 @@ "sort-packages": true, "platform": { "php": "8.1" }, "allow-plugins": { + "automattic/jetpack-autoloader": true, "dealerdirect/phpcodesniffer-composer-installer": true } } diff --git a/composer.lock b/composer.lock index b8fef02..c953e95 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,72 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "538b1dbb81faf068bc0ff5f6a99efde9", + "content-hash": "f61708b5ca7588f7b1c5b47774383472", "packages": [ + { + "name": "automattic/jetpack-autoloader", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/Automattic/jetpack-autoloader.git", + "reference": "e7e49a4e2f16cb2dfd3e58c478499a60d7d51839" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/e7e49a4e2f16cb2dfd3e58c478499a60d7d51839", + "reference": "e7e49a4e2f16cb2dfd3e58c478499a60d7d51839", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1 || ^2.0", + "php": ">=7.0" + }, + "require-dev": { + "automattic/jetpack-changelogger": "^4.2.8", + "composer/composer": "^1.1 || ^2.0", + "yoast/phpunit-polyfills": "^1.1.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Automattic\\Jetpack\\Autoloader\\CustomAutoloaderPlugin", + "autotagger": true, + "mirror-repo": "Automattic/jetpack-autoloader", + "branch-alias": { + "dev-trunk": "3.1.x-dev" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-autoloader/compare/v${old}...v${new}" + }, + "version-constants": { + "::VERSION": "src/AutoloadGenerator.php" + } + }, + "autoload": { + "psr-4": { + "Automattic\\Jetpack\\Autoloader\\": "src" + }, + "classmap": [ + "src/AutoloadGenerator.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "description": "Creates a custom autoloader for a plugin or theme.", + "keywords": [ + "autoload", + "autoloader", + "composer", + "jetpack", + "plugin", + "wordpress" + ], + "support": { + "source": "https://github.com/Automattic/jetpack-autoloader/tree/v3.1.3" + }, + "time": "2024-11-04T09:23:56+00:00" + }, { "name": "wordpress/mcp-adapter", "version": "v0.5.0", diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php index 031074e..4e0ceba 100644 --- a/src/Abilities/AbilityRegistrar.php +++ b/src/Abilities/AbilityRegistrar.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Abilities; use LIT\MCP\Access; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Server\RequestContext; use Throwable; use WP_Error; @@ -55,6 +56,10 @@ final class AbilityRegistrar return Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.'); } + if (! RequestContext::isOAuthServerRequest()) { + return Errors::forbidden('LIT MCP ist nur über den OAuth-geschützten MCP-Server nutzbar.'); + } + return $ability->permission(is_array($input) ? $input : []); }, 'execute_callback' => static function (mixed $input = null) use ($ability): mixed { diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index 4b75be3..6364c0f 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -11,6 +11,9 @@ final class IntegrationRegistry /** @var list */ private array $integrations; + /** @var list|null */ + private ?array $abilities = null; + public function __construct(Integration ...$integrations) { $this->integrations = array_values($integrations); @@ -28,12 +31,16 @@ final class IntegrationRegistry /** @return list */ public function abilities(): array { - $abilities = []; - foreach ($this->available() as $integration) { - array_push($abilities, ...$integration->abilities()); + if ($this->abilities === null) { + $abilities = []; + foreach ($this->available() as $integration) { + array_push($abilities, ...$integration->abilities()); + } + + $this->abilities = $abilities; } - return $abilities; + return $this->abilities; } /** @return list */ diff --git a/src/Plugin.php b/src/Plugin.php index b3b19ca..bfb740c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -8,6 +8,7 @@ use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\ServerRegistrar; /** @@ -15,6 +16,8 @@ use LIT\MCP\Server\ServerRegistrar; */ final class Plugin { + public const VERSION = '0.1.0'; + private static string $pluginFile = ''; public static function boot(string $pluginFile): void @@ -28,6 +31,7 @@ final class Plugin } (new Access())->register(); + (new RequestContext())->register(); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides') diff --git a/src/Server/RequestContext.php b/src/Server/RequestContext.php new file mode 100644 index 0000000..15c3e6c --- /dev/null +++ b/src/Server/RequestContext.php @@ -0,0 +1,62 @@ + $handler + * @return mixed + */ + public static function recordRoute(mixed $response, array $handler, WP_REST_Request $request): mixed + { + if ($request->get_route() === self::route()) { + self::$isOAuthServerRequest = true; + } + + return $response; + } + + public static function isOAuthServerRequest(): bool + { + return self::$isOAuthServerRequest; + } + + /** + * Test-only: resets (or forces) the recorded state between unit tests. + */ + public static function reset(bool $isOAuthServerRequest = false): void + { + self::$isOAuthServerRequest = $isOAuthServerRequest; + } + + private static function route(): string + { + return '/' . ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE; + } +} diff --git a/src/Server/ServerRegistrar.php b/src/Server/ServerRegistrar.php index abec288..e950559 100644 --- a/src/Server/ServerRegistrar.php +++ b/src/Server/ServerRegistrar.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Server; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Plugin; use WP\MCP\Core\McpAdapter; use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler; use WPMedia\MCP\OAuth\Context as OAuthContext; @@ -41,13 +42,38 @@ final class ServerRegistrar /** @var callable(): bool */ private $oauthEnabled; + /** @var callable(array): mixed */ + private $createAdapterServer; + + /** @var callable(string): bool */ + private $classExists; + public function __construct( private IntegrationRegistry $registry, private InstructionsBuilder $instructions, - ?callable $oauthEnabled = null + ?callable $oauthEnabled = null, + ?callable $createAdapterServer = null, + ?callable $classExists = null ) { $this->oauthEnabled = $oauthEnabled ?? static fn (): bool => (new OAuthContext())->is_enabled(); + $this->classExists = $classExists + ?? static fn (string $class): bool => class_exists($class); + $this->createAdapterServer = $createAdapterServer + ?? static function (array $args): mixed { + return McpAdapter::instance()->create_server( + $args['id'], + $args['namespace'], + $args['route'], + $args['name'], + $args['description'], + $args['version'], + [OAuthHttpTransport::class], + ErrorLogMcpErrorHandler::class, + (new OAuthContext())->observability_handler(), + $args['tools'] + ); + }; } public function register(): void @@ -75,7 +101,7 @@ final class ServerRegistrar 'route' => self::ROUTE, 'name' => 'LIT MCP', 'description' => $this->instructions->build(), - 'version' => '0.1.0', + 'version' => Plugin::VERSION, 'tools' => array_merge($this->registry->directAbilityNames(), self::META_TOOLS), ]; } @@ -97,18 +123,7 @@ final class ServerRegistrar $this->unhookOAuthDefaultServer(); - $result = McpAdapter::instance()->create_server( - $args['id'], - $args['namespace'], - $args['route'], - $args['name'], - $args['description'], - $args['version'], - [OAuthHttpTransport::class], - ErrorLogMcpErrorHandler::class, - (new OAuthContext())->observability_handler(), - $args['tools'] - ); + $result = ($this->createAdapterServer)($args); if (is_wp_error($result)) { $message = '[wp-lit-mcp] MCP server registration failed: ' . $result->get_error_message(); @@ -129,7 +144,7 @@ final class ServerRegistrar */ public function unhookOAuthDefaultServer(): void { - if (! class_exists(OAuthServerRegistrar::class)) { + if (! ($this->classExists)(OAuthServerRegistrar::class)) { return; } diff --git a/tests/Integration/mcp-smoke.sh b/tests/Integration/mcp-smoke.sh index b0cfaca..3b1c4af 100644 --- a/tests/Integration/mcp-smoke.sh +++ b/tests/Integration/mcp-smoke.sh @@ -51,6 +51,18 @@ mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+bod "${session_header[@]}" -d "$2" } +# 0. Booting REST triggers no _doing_it_wrong(). A regression in the +# unhook-before-create ordering (ServerRegistrar::createServer()) would +# otherwise surface as a "duplicate server id" _doing_it_wrong() notice when +# mcp-adapter's own mcp_adapter_init action fires during rest_api_init. +DOING_IT_WRONG=$(wp eval " +add_action('doing_it_wrong_run', static function () { echo 'DOING_IT_WRONG'; }); +do_action('rest_api_init'); +" 2>&1) +echo "$DOING_IT_WRONG" | grep -q 'DOING_IT_WRONG' \ + && fail "_doing_it_wrong() triggered while booting REST: $DOING_IT_WRONG" +pass "booting REST triggers no _doing_it_wrong()" + # 1. Discovery documents # # The local DDEV nginx config denies all dotfile paths (`location ~ /\. { deny @@ -115,6 +127,22 @@ echo "$CALL" | grep -q '"isError":false' || fail "lit-site-status failed: $CALL" echo "$CALL" | grep -q 'the-events-calendar' || fail "site-status lacks components" pass "tools/call lit-site-status works" +# 3b. mcp-adapter's default (unprotected) server must not exist: the route +# it would register, mcp-adapter-default-server, must 404. Uses a temporary +# application password (Basic auth) instead of the MCP bearer token, since +# this checks a plain REST route, not the MCP endpoint. Named like the mint- +# token.php passwords so the cleanup trap above also removes it. +APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain) +DEFAULT_SERVER_STATUS=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' \ + -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ + -X POST 'http://localhost/wp-json/mcp/mcp-adapter-default-server' \ + -u "${ADMIN_LOGIN}:${APP_PASSWORD}" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}') +[ "$DEFAULT_SERVER_STATUS" = "404" ] \ + || fail "mcp-adapter's default server is still reachable (HTTP $DEFAULT_SERVER_STATUS)" +pass "mcp-adapter's default server (mcp-adapter-default-server) is gone (404)" + # 4. Token must not authenticate other REST routes (mcp-oauth issue #62) ME=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/users/me -H "Authorization: Bearer $TOKEN") [ "$ME" = "401" ] || fail "MCP token authenticates /wp/v2/users/me (HTTP $ME) – scope filter needed" @@ -127,8 +155,18 @@ SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","para SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION") echo "$SUB_CALL" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could call lit-site-status: $SUB_CALL" +# The denial text must actually be our role-gate message, not just any error +# (a crash or validation failure also sets isError/error and must not pass). +echo "$SUB_CALL" | grep -q 'nicht freigegeben' \ + || fail "lit-site-status denial lacks role-gate message: $SUB_CALL" SUB_META=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"mcp-adapter-execute-ability","arguments":{"ability_name":"lit/site-status","parameters":{}}}}' "$SUB_SESSION") echo "$SUB_META" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could use execute-ability: $SUB_META" +# execute-ability denies at the adapter's own capability gate (mcp_adapter_execute_ability_capability, +# set to lit_mcp_use by Access) before our ability's permission_callback ever runs, so the adapter's +# own "insufficient_capability" text ("...lacks required capability...") is what appears here, not +# our "nicht freigegeben" message. +echo "$SUB_META" | grep -Eq 'lacks required capability|nicht freigegeben' \ + || fail "execute-ability denial lacks role-gate message: $SUB_META" pass "role gate blocks subscriber (direct tool and meta-tool)" echo "ALL CHECKS PASSED" diff --git a/tests/Integration/mint-token.php b/tests/Integration/mint-token.php index 6d7907d..f80a174 100644 --- a/tests/Integration/mint-token.php +++ b/tests/Integration/mint-token.php @@ -11,6 +11,8 @@ * Confirmed with a minimal repro during Task 7 (see task-7-report.md). */ +defined('WP_CLI') && WP_CLI || exit; + use WPMedia\MCP\OAuth\Auth\JWT; use WPMedia\MCP\OAuth\Auth\SecretManager; diff --git a/tests/Integration/tec-probe.php b/tests/Integration/tec-probe.php index 63111e7..da8120c 100644 --- a/tests/Integration/tec-probe.php +++ b/tests/Integration/tec-probe.php @@ -9,6 +9,8 @@ * No strict_types: PHP forbids declare(strict_types) inside eval(), which wp eval-file uses. */ +defined('WP_CLI') && WP_CLI || exit; + if (! class_exists('Tribe__Events__Main')) { fwrite(STDERR, "The Events Calendar is not active\n"); exit(1); @@ -21,25 +23,38 @@ $id = wp_insert_post([ 'post_type' => 'tribe_events', 'post_status' => 'publish', 'post_title' => 'LIT Probe Event (wegwerfen)', -]); -update_post_meta($id, '_EventStartDate', $start); -update_post_meta($id, '_EventEndDate', $end); +], true); -$derived = []; -foreach (['_EventStartDateUTC', '_EventEndDateUTC', '_EventDuration', '_EventTimezone'] as $key) { - $derived[$key] = get_post_meta($id, $key, true); +if (is_wp_error($id)) { + fwrite(STDERR, 'wp_insert_post failed: ' . $id->get_error_message() . "\n"); + exit(1); } -$found = tribe_get_events([ - 'start_date' => gmdate('Y-m-d', strtotime('+29 days')), - 'end_date' => gmdate('Y-m-d', strtotime('+31 days')), - 'fields' => 'ids', -]); +if (! $id) { + fwrite(STDERR, "wp_insert_post returned no post id\n"); + exit(1); +} -echo wp_json_encode([ - 'event_id' => $id, - 'found_by_tribe_get_events' => in_array($id, (array) $found, true), - 'derived_meta' => $derived, -], JSON_PRETTY_PRINT) . "\n"; +try { + update_post_meta($id, '_EventStartDate', $start); + update_post_meta($id, '_EventEndDate', $end); -wp_delete_post($id, true); + $derived = []; + foreach (['_EventStartDateUTC', '_EventEndDateUTC', '_EventDuration', '_EventTimezone'] as $key) { + $derived[$key] = get_post_meta($id, $key, true); + } + + $found = tribe_get_events([ + 'start_date' => gmdate('Y-m-d', strtotime('+29 days')), + 'end_date' => gmdate('Y-m-d', strtotime('+31 days')), + 'fields' => 'ids', + ]); + + echo wp_json_encode([ + 'event_id' => $id, + 'found_by_tribe_get_events' => in_array($id, (array) $found, true), + 'derived_meta' => $derived, + ], JSON_PRETTY_PRINT) . "\n"; +} finally { + wp_delete_post($id, true); +} diff --git a/tests/Stubs/WpRestRequest.php b/tests/Stubs/WpRestRequest.php new file mode 100644 index 0000000..ccca454 --- /dev/null +++ b/tests/Stubs/WpRestRequest.php @@ -0,0 +1,17 @@ +route; + } + } +} diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index cf7de1f..3e22e6c 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -9,6 +9,7 @@ use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Server\RequestContext; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use RuntimeException; @@ -16,6 +17,20 @@ use WP_Error; final class AbilityRegistrarTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + // Default to "inside the OAuth server request" so tests unrelated to this + // gate keep exercising the ability's own permission() as before. + RequestContext::reset(true); + } + + protected function tearDown(): void + { + RequestContext::reset(); + parent::tearDown(); + } + private function registrarFor(Ability $ability): AbilityRegistrar { $integration = Mockery::mock(Integration::class); @@ -64,6 +79,19 @@ final class AbilityRegistrarTest extends TestCase self::assertSame('lit_forbidden', $result->get_error_code()); } + public function testPermissionDeniedOutsideOAuthServerRequest(): void + { + Functions\when('current_user_can')->justReturn(true); + RequestContext::reset(false); + $ability = $this->ability(); + $ability->shouldNotReceive('permission'); + + $result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_forbidden', $result->get_error_code()); + } + public function testPermissionDelegatesToAbilityWhenGatePasses(): void { Functions\when('current_user_can')->justReturn(true); diff --git a/tests/Unit/Server/RequestContextTest.php b/tests/Unit/Server/RequestContextTest.php new file mode 100644 index 0000000..4630109 --- /dev/null +++ b/tests/Unit/Server/RequestContextTest.php @@ -0,0 +1,61 @@ +once() + ->with('rest_request_before_callbacks', [RequestContext::class, 'recordRoute'], 10, 3); + + (new RequestContext())->register(); + } +} diff --git a/tests/Unit/Server/ServerRegistrarTest.php b/tests/Unit/Server/ServerRegistrarTest.php index 215f5b2..5e2fba0 100644 --- a/tests/Unit/Server/ServerRegistrarTest.php +++ b/tests/Unit/Server/ServerRegistrarTest.php @@ -146,4 +146,69 @@ final class ServerRegistrarTest extends TestCase (new ServerRegistrar($this->registry(), $this->instructions()))->unhookOAuthDefaultServer(); } + + public function testUnhookOAuthDefaultServerIsNoOpWhenOAuthPackageIsAbsent(): void + { + $oauthRegistrar = Mockery::mock(OAuthServerRegistrar::class); + $oauthCallback = [$oauthRegistrar, 'register_server']; + $hook = new class { + /** @var array> */ + public array $callbacks = []; + }; + $hook->callbacks = [10 => ['oauth-id' => ['function' => $oauthCallback, 'accepted_args' => 0]]]; + $GLOBALS['wp_filter']['mcp_adapter_init'] = $hook; + + Functions\expect('remove_action')->never(); + + // Simulate wp-media/mcp-oauth not being installed via the injectable + // classExists probe (class_exists() is a PHP internal and cannot be + // stubbed directly through Brain\Monkey/Patchwork). + $registrar = new ServerRegistrar( + $this->registry(), + $this->instructions(), + null, + null, + static fn (): bool => false + ); + + $registrar->unhookOAuthDefaultServer(); + } + + public function testCreateServerUnhooksOAuthDefaultServerBeforeCreatingAdapterServer(): void + { + Functions\when('doing_action')->justReturn(true); + Functions\when('is_wp_error')->justReturn(false); + + $oauthRegistrar = Mockery::mock(OAuthServerRegistrar::class); + $oauthCallback = [$oauthRegistrar, 'register_server']; + $hook = new class { + /** @var array> */ + public array $callbacks = []; + }; + $hook->callbacks = [10 => ['oauth-id' => ['function' => $oauthCallback, 'accepted_args' => 0]]]; + $GLOBALS['wp_filter']['mcp_adapter_init'] = $hook; + + /** @var list $order */ + $order = []; + Functions\expect('remove_action') + ->once() + ->with('mcp_adapter_init', $oauthCallback, 10) + ->andReturnUsing(static function () use (&$order): void { + $order[] = 'unhook'; + }); + + $registrar = new ServerRegistrar( + $this->registry(), + $this->instructions(), + static fn (): bool => true, + static function (array $args) use (&$order): bool { + $order[] = 'create'; + return true; + } + ); + + $registrar->createServer(); + + self::assertSame(['unhook', 'create'], $order, 'unhook must run before the adapter creates the server'); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5162f52..e77c426 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -9,3 +9,4 @@ if (! defined('ABSPATH')) { require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/Stubs/WpError.php'; require_once __DIR__ . '/Stubs/WpUser.php'; +require_once __DIR__ . '/Stubs/WpRestRequest.php'; diff --git a/wp-lit-mcp.php b/wp-lit-mcp.php index dd0edbd..c089e3b 100644 --- a/wp-lit-mcp.php +++ b/wp-lit-mcp.php @@ -16,7 +16,7 @@ declare(strict_types=1); defined('ABSPATH') || exit; -$lit_mcp_autoload = __DIR__ . '/vendor/autoload.php'; +$lit_mcp_autoload = __DIR__ . '/vendor/autoload_packages.php'; if (! is_readable($lit_mcp_autoload)) { add_action('admin_notices', static function (): void { From 3f30e36a730ab4a737f333fb76e7f9e804b1fc29 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 20:06:06 +0200 Subject: [PATCH 018/185] docs: add delivery & update stage to spec Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index 028685e..2eb4471 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -462,6 +462,7 @@ Referenz: https://modelcontextprotocol.io/specification | **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides | Divi-Seiten mit Claude bauen und pflegen | | **3 – Library & Theme Builder** | Library-Tools, Node-Copy, Make-Global, Find-Similar, Theme-Builder-Tools, Dynamic Content | CPT-Vorlagen bauen, globale Module einführen | | **4 – Designsystem** | Farben, Variablen, Presets, Theme-Optionen inkl. SEO, Snapshots, Prompts | Corporate Design in einem Rutsch übernehmen | +| **U – Auslieferung & Updates** (nach Etappe 1) | Build-Skript (`composer install --no-dev -o`, ZIP inkl. `vendor/` ~3,3 MB), Release auf Gitea (`git.loosen-it.de`) mit ZIP-Asset und `update.json`, `YahnisElsts/plugin-update-checker` im Self-hosted-JSON-Modus; Updates werden nur angezeigt (manueller Klick), kein Auto-Update; offen: Gitea Actions vs. lokales `bin/release.sh`, privates Repo → Token pro Seite | Kundenseiten per Klick aktualisieren | Kann-Kriterien nach Etappe 4 oder bei freier Kapazität. From 85bd6ca365f29c3f8dd25256cc51ff63116f15c3 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 20:13:58 +0200 Subject: [PATCH 019/185] docs: mark delivery/update stage as open in spec Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index 2eb4471..ee95a69 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -462,7 +462,7 @@ Referenz: https://modelcontextprotocol.io/specification | **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides | Divi-Seiten mit Claude bauen und pflegen | | **3 – Library & Theme Builder** | Library-Tools, Node-Copy, Make-Global, Find-Similar, Theme-Builder-Tools, Dynamic Content | CPT-Vorlagen bauen, globale Module einführen | | **4 – Designsystem** | Farben, Variablen, Presets, Theme-Optionen inkl. SEO, Snapshots, Prompts | Corporate Design in einem Rutsch übernehmen | -| **U – Auslieferung & Updates** (nach Etappe 1) | Build-Skript (`composer install --no-dev -o`, ZIP inkl. `vendor/` ~3,3 MB), Release auf Gitea (`git.loosen-it.de`) mit ZIP-Asset und `update.json`, `YahnisElsts/plugin-update-checker` im Self-hosted-JSON-Modus; Updates werden nur angezeigt (manueller Klick), kein Auto-Update; offen: Gitea Actions vs. lokales `bin/release.sh`, privates Repo → Token pro Seite | Kundenseiten per Klick aktualisieren | +| **U – Auslieferung & Updates** (nach Etappe 1) | **OFFEN – bewusst noch nicht spezifiziert.** Bekannt: Build mit `composer install --no-dev` (~3,3 MB vendor), Updates nur anzeigen (manueller Klick), `plugin-update-checker`; Hosting (Gitea / GitHub / eigene Subdomain mit `update.json`) und Signaturprüfung werden später entschieden | – | Kann-Kriterien nach Etappe 4 oder bei freier Kapazität. From 5434cb6767adf9accf942e9a47fd92140bdc175c Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 21:21:18 +0200 Subject: [PATCH 020/185] docs: implementation plans for stage 1 (1a content/menus/settings, 1b media/admin) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../2026-09-25-etappe-1a-wordpress-inhalte.md | 10905 ++++++++++++++++ .../2026-09-25-etappe-1b-medien-admin.md | 6082 +++++++++ 2 files changed, 16987 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md create mode 100644 docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md diff --git a/docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md b/docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md new file mode 100644 index 0000000..2ffef8a --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md @@ -0,0 +1,10905 @@ +# Etappe 1a – WordPress-Basis: Inhalte, Menüs, Einstellungen – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Claude kann über den OAuth-geschützten MCP-Server Seiten, Beiträge und Pods-Einträge generisch finden, lesen, anlegen und bearbeiten, ihren Status explizit steuern, Entwurfskopien anlegen und zurücktauschen, Revisionen nutzen, Menüs aufbauen und die freigegebenen WordPress-Einstellungen setzen – jede Änderung mit einheitlichem `ToolResult` und Protokolleintrag. + +**Architecture:** Neue Integration `WordPressIntegration` (immer verfügbar) liefert die Abilities; eine `ContentSchema` führt Feldquellen zusammen (Pods → registrierte Meta → Meta-Stichprobe), `FieldValidator`/`TermAssigner`/`PostWriter` validieren alles vor dem ersten Schreibzugriff (alles oder nichts). Querschnitt: `Errors` (JSON-Fehler mit `hint`), `ToolResult` (Spec §8.1), `Cursor`, `Rev`, `ChangeLog` (Tabelle `{prefix}lit_mcp_log`, täglicher Cron), `RequestContext` (Flag-Reset nach dem Request, Client-Name für das Protokoll). Etappe 1b (separater Plan) ergänzt Medien, Einstellungsseite, Expositions-Policy, Hinweise und Aufräumen. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 Abilities API, `wordpress/mcp-adapter` 0.5.0, `wp-media/mcp-oauth` 1.1.2, Pods 3.3.9 (optional), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (PSR-12 + WordPress-Security), DDEV. + +**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §4.3–4.5, §6.1, §6.2, §6.4, §6.5, §8.1–8.3, §9, §10, §11.3, §12 Etappe 1) + +**Folgeplan:** `docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md` (Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Aufräumen, Hinweise). 1b setzt diesen Plan voraus. + +## Global Constraints + +- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Das installierte, inaktive Pods darf in DDEV für Integrationstests aktiviert werden (danach wieder deaktivieren); keine neuen Plugins installieren. +- PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3); Plugin-Header `Tested up to: 7.0`. +- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` müssen nach jedem Task sauber sein. +- Unit-Tests: PHPUnit 9.6 + Brain Monkey + Mockery über `LIT\MCP\Tests\Unit\TestCase`; Stubs für WP-Klassen in `tests/Stubs/` (in `tests/bootstrap.php` eingebunden). Funktionen, die `TestCase::setUp()` per `Functions\stubs()` vorbelegt (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`), nie mit `Functions\expect()` prüfen – Brain Monkey verbietet Stub und Erwartung auf derselben Funktion. +- Integrationstests laufen im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`; WP-CLI über `timeout 120 ddev wp …`. +- Ability-Namen `lit/-` → Tool-Namen `lit--`. Annotations exakt: Lese-Tools `readonly: true`; `destructive: true` nur für Löschen, Ersetzen, Austausch (Papierkorb, Kopie-Austausch, Revision wiederherstellen, Menübaum ersetzen); `idempotent` wie je Task angegeben. +- Input- und Output-Schemas als JSON Schema 2020-12, nur mit Schlüsselwörtern, die `rest_validate_value_from_schema()` prüft (`type` inkl. Typ-Listen, `properties`, `required`, `items`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `minItems`, `maxItems`, `additionalProperties`). WordPress prüft Eingaben vor `execute()` und Ausgaben danach – eine Ausgabe, die nicht zum `outputSchema` passt, wird zum Fehler. +- Fachliche Fehler ausschließlich über `LIT\MCP\Abilities\Errors` (Code `lit_`, Nachricht = JSON `{code, message, hint, …}`), nie Stacktraces. +- Schreib-Tools ändern nie den Veröffentlichungsstatus; neue Einträge immer als Entwurf; Beiträge nur in den Papierkorb, nie endgültig löschen (einzige Ausnahme: Menüeinträge `nav_menu_item`, die WordPress selbst ohne Papierkorb löscht – Task 14). +- Tools mit `destructive: true` verlangen `confirm: true`, sonst `lit_confirm_required` ohne jede Änderung. +- Jede erfolgreiche Änderung schreibt einen Protokolleintrag (`ChangeLog::record`) und liefert `ToolResult` nach Spec §8.1; `revision_id` ist der Wiederherstellungspunkt **vor** der Änderung (oder `null`), `rev` der Hash des `post_content` danach (oder `null` bei Nicht-Beiträgen). +- Listen mit Cursor-Paginierung: `limit` 1–100 (Standard 20), `cursor` rein, `next_cursor` (oder `null`) raus. +- Keine Divi-Tools in dieser Etappe; Divi-Inhalte werden erkannt (`_et_pb_use_builder = on` oder `']); + + $full = (new PostPresenter($schema))->full($post); + + self::assertTrue($full['divi']); + self::assertNull($full['content']); + self::assertNull($full['featured_image']); + self::assertStringContainsString('lit-divi-outline', $full['hints'][0]); + self::assertInstanceOf(stdClass::class, $full['terms']); + self::assertInstanceOf(stdClass::class, $full['fields']); + } + + public function testDiviIsAlsoDetectedFromBlockMarkup(): void + { + Functions\when('get_post_meta')->justReturn(''); + + self::assertTrue(PostPresenter::usesDivi(new WP_Post(['post_content' => 'x y']))); + self::assertFalse(PostPresenter::usesDivi(new WP_Post(['post_content' => '']))); + } +} +``` + +`tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php`: +```php +allows('summary')->andReturnUsing(static fn (WP_Post $post): array => ['id' => $post->ID]); + + return new ContentQueryAbility(Mockery::mock(ContentSchema::class), $presenter); + } + + public function testBuildsTheQueryAndPaginates(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\expect('get_posts')->once()->with(Mockery::on(static function (array $args): bool { + return $args['post_type'] === 'lit_person' + && $args['post_status'] === ['draft'] + && $args['s'] === 'Anna' + && $args['posts_per_page'] === 3 + && $args['offset'] === 4 + && $args['orderby'] === 'title' + && $args['order'] === 'ASC' + && $args['tax_query'] === [ + 'relation' => 'OR', + ['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]], + ['taxonomy' => 'category', 'field' => 'slug', 'terms' => ['news']], + ] + && $args['meta_query'] === [['key' => 'level', 'compare' => '=', 'value' => 'senior']] + && $args['date_query'] === [['after' => '2026-01-01', 'inclusive' => true]]; + }))->andReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2]), new WP_Post(['ID' => 3])]); + + $result = $this->ability()->execute([ + 'post_type' => 'lit_person', + 'status' => ['draft'], + 'search' => 'Anna', + 'taxonomy' => ['taxonomy' => 'category', 'terms' => [4, 'news']], + 'field' => ['key' => 'level', 'value' => 'senior'], + 'date_after' => '2026-01-01', + 'orderby' => 'title', + 'order' => 'asc', + 'limit' => 2, + 'cursor' => Cursor::encode(4), + ]); + + self::assertSame([['id' => 1], ['id' => 2]], $result['items']); + self::assertSame(6, Cursor::decode($result['next_cursor'])); + } + + public function testExistsFilterHasNoValueAndLastPageHasNoCursor(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\expect('get_posts')->once()->with(Mockery::on( + static fn (array $args): bool => $args['meta_query'] === [['key' => 'role', 'compare' => 'EXISTS']] + && $args['post_status'] === ['publish', 'draft', 'pending', 'private', 'future'] + ))->andReturn([new WP_Post(['ID' => 1])]); + + $result = $this->ability()->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]); + + self::assertNull($result['next_cursor']); + } + + public function testPostsTheUserCannotEditAreDropped(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2); + Functions\when('get_posts')->justReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2])]); + + $result = $this->ability()->execute(['post_type' => 'page']); + + self::assertSame([['id' => 1]], $result['items']); + } + + public function testInvalidCursorIsRejected(): void + { + $result = $this->ability()->execute(['post_type' => 'page', 'cursor' => '???']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testPermissionNeedsAManagedTypeAndEditRights(): void + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(false); + $ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class)); + + self::assertSame('lit_not_found', $ability->permission(['post_type' => 'secret'])->get_error_code()); + } +} +``` + +`tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php`: +```php + 5, 'post_type' => 'page']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $presenter = Mockery::mock(PostPresenter::class); + $presenter->expects('full')->with($post)->andReturn(['id' => 5]); + $ability = new ContentGetAbility($schema, $presenter); + + self::assertTrue($ability->permission(['post_id' => 5])); + self::assertSame(['id' => 5], $ability->execute(['post_id' => 5])); + } + + public function testMissingPostIsNotFound(): void + { + Functions\when('get_post')->justReturn(null); + $schema = Mockery::mock(ContentSchema::class); + $ability = new ContentGetAbility($schema, Mockery::mock(PostPresenter::class)); + + self::assertSame('lit_not_found', $ability->permission(['post_id' => 99])->get_error_code()); + } + + public function testOutputSchemaAllowsNullContentForDivi(): void + { + $ability = new ContentGetAbility(Mockery::mock(ContentSchema::class), Mockery::mock(PostPresenter::class)); + + self::assertSame(['string', 'null'], $ability->outputSchema()['properties']['content']['type']); + self::assertTrue($ability->isDirect()); + } +} +``` + +In `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/content-types', + 'lit/content-query', + 'lit/content-get', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'PostPresenterTest|ContentQueryAbilityTest|ContentGetAbilityTest|WordPressIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Content/PostPresenter.php`: +```php + */ + public function summary(WP_Post $post): array + { + return [ + 'id' => (int) $post->ID, + 'type' => (string) $post->post_type, + 'status' => (string) $post->post_status, + 'title' => (string) $post->post_title, + 'slug' => (string) $post->post_name, + 'parent' => (int) $post->post_parent, + 'date' => (string) $post->post_date, + 'modified' => (string) $post->post_modified, + 'url' => (string) get_permalink($post), + 'edit_url' => (string) get_edit_post_link($post->ID, 'raw'), + 'divi' => self::usesDivi($post), + ]; + } + + /** @return array */ + public function full(WP_Post $post): array + { + $divi = self::usesDivi($post); + + return array_merge($this->summary($post), [ + 'excerpt' => (string) $post->post_excerpt, + 'content' => $divi ? null : (string) $post->post_content, + 'menu_order' => (int) $post->menu_order, + 'author' => [ + 'id' => (int) $post->post_author, + 'name' => (string) get_the_author_meta('display_name', (int) $post->post_author), + ], + 'template' => (string) get_page_template_slug($post), + 'preview_url' => (string) get_preview_post_link($post), + 'terms' => JsonMap::of($this->terms($post)), + 'fields' => JsonMap::of($this->fields($post)), + 'featured_image' => $this->featuredImage($post), + 'rev' => Rev::of((string) $post->post_content), + 'hints' => $divi ? [self::DIVI_HINT] : [], + ]); + } + + public static function usesDivi(WP_Post $post): bool + { + return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on' + || str_contains((string) $post->post_content, '', + 'post_excerpt' => 'Kurz', + 'post_parent' => 2, + 'menu_order' => 3, + ]); + } + + public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void + { + Functions\when('get_current_user_id')->justReturn(4); + Functions\when('maybe_unserialize')->returnArg(); + Functions\expect('wp_insert_post')->once()->with($this->draftMatcher(), true)->andReturn(77); + Functions\when('get_post_meta')->justReturn([ + '_edit_lock' => ['123:1'], + '_et_pb_use_builder' => ['on'], + 'role' => ['Leitung'], + ]); + Functions\expect('add_post_meta')->once()->with(77, '_et_pb_use_builder', 'on'); + Functions\expect('add_post_meta')->once()->with(77, 'role', 'Leitung'); + Functions\when('get_object_taxonomies')->justReturn(['category']); + Functions\when('wp_get_object_terms')->justReturn([4, 9]); + Functions\expect('wp_set_object_terms')->once()->with(77, [4, 9], 'category', false); + Functions\expect('update_post_meta')->once()->with(77, '_lit_mcp_copy_of', 5); + + self::assertSame(77, (new PostCloner())->duplicate($this->original())); + } + + public function testDuplicateFailureThrows(): void + { + Functions\when('get_current_user_id')->justReturn(4); + Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'kaputt')); + + $this->expectException(RuntimeException::class); + + (new PostCloner())->duplicate($this->original()); + } + + public function testReplaceOverwritesContentMetaAndTermsButKeepsTheOriginalsIdentity(): void + { + $copy = new WP_Post([ + 'ID' => 77, + 'post_type' => 'page', + 'post_title' => 'Team (Kopie)', + 'post_content' => 'neu', + 'post_excerpt' => 'Neu kurz', + ]); + Functions\when('maybe_unserialize')->returnArg(); + Functions\expect('wp_update_post')->once()->with([ + 'ID' => 5, + 'post_title' => 'Team', + 'post_content' => 'neu', + 'post_excerpt' => 'Neu kurz', + ], true)->andReturn(5); + Functions\when('get_post_meta')->alias(static fn (int $id) => $id === 5 + ? ['_edit_lock' => ['1'], 'role' => ['Alt'], 'city' => ['Trier']] + : ['_lit_mcp_copy_of' => ['5'], 'role' => ['Neu']]); + Functions\expect('delete_post_meta')->once()->with(5, 'role'); + Functions\expect('delete_post_meta')->once()->with(5, 'city'); + Functions\expect('add_post_meta')->once()->with(5, 'role', 'Neu'); + Functions\when('get_object_taxonomies')->justReturn(['category']); + Functions\when('wp_get_object_terms')->justReturn([12]); + Functions\expect('wp_set_object_terms')->once()->with(5, [12], 'category', false); + + (new PostCloner())->replace($this->original(), $copy); + } + + private function draftMatcher(): \Mockery\Matcher\Closure + { + return \Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft' + && $data['post_type'] === 'page' + && $data['post_title'] === 'Team (Kopie)' + && $data['post_content'] === '' + && $data['post_parent'] === 2 + && $data['menu_order'] === 3 + && $data['post_author'] === 4); + } +} +``` + +`tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php`: +```php + 5, 'post_type' => 'page', 'post_title' => 'Team']); + Functions\when('get_post')->alias(static fn (int $id) => $id === 5 + ? $original + : new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)'])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=77&preview=true'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=77&action=edit'); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('duplicate')->with($original)->andReturn(77); + Actions\expectDone('lit_mcp_after_save')->once()->with(77, 'lit/post-duplicate', ['post_id' => 5]); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/post-duplicate', 'page', '77', 'Kopie von "Team" (ID 5) angelegt')->andReturn(8); + + $result = (new PostDuplicateAbility($schema, $cloner, $log))->execute(['post_id' => 5]); + + self::assertSame([['id' => '77', 'type' => 'page', 'action' => 'created', 'label' => 'Team (Kopie)']], $result['changed']); + self::assertSame(5, $result['original_id']); + self::assertSame(8, $result['log_id']); + } +} +``` + +`tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php`: +```php +original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish']); + $this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']); + Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_meta')->justReturn('5'); + Functions\when('get_permalink')->justReturn('https://kunde.de/team/'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); + } + + private function ability(PostCloner $cloner, ChangeLog $log, bool $trash = true): PostReplaceWithCopyAbility + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new PostReplaceWithCopyAbility($schema, $cloner, $log, static fn (): bool => $trash); + } + + public function testRequiresConfirmation(): void + { + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute(['original_id' => 5, 'copy_id' => 77]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + } + + public function testCopyMustStemFromTheOriginal(): void + { + Functions\when('get_post_meta')->justReturn('12'); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class)) + ->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testDisabledTrashIsRefusedBeforeAnyChange(): void + { + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class), false) + ->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testRevisionThenReplaceThenTrashTheCopy(): void + { + $order = []; + Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { + $order[] = 'revision'; + return 900; + }); + Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void { + $order[] = 'replace'; + }); + Functions\expect('wp_trash_post')->once()->with(77)->andReturnUsing(static function () use (&$order): WP_Post { + $order[] = 'trash'; + return new WP_Post(['ID' => 77]); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900)->andReturn(11); + + $result = $this->ability($cloner, $log)->execute(['original_id' => 5, 'copy_id' => 77, 'confirm' => true]); + + self::assertSame(['revision', 'replace', 'trash'], $order); + self::assertSame(900, $result['revision_id']); + self::assertSame(['replaced', 'trashed'], array_column($result['changed'], 'action')); + } +} +``` + +In `WordPressIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/content-types', + 'lit/content-query', + 'lit/content-get', + 'lit/content-create', + 'lit/content-update', + 'lit/post-publish', + 'lit/post-unpublish', + 'lit/post-schedule', + 'lit/post-trash', + 'lit/post-duplicate', + 'lit/post-replace-with-copy', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'PostCloner|PostDuplicate|PostReplaceWithCopy|WordPressIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Status/PostCloner.php`: +```php + (string) $original->post_type, + 'post_status' => 'draft', + 'post_title' => sprintf('%s (Kopie)', $original->post_title), + 'post_content' => (string) $original->post_content, + 'post_excerpt' => (string) $original->post_excerpt, + 'post_parent' => (int) $original->post_parent, + 'menu_order' => (int) $original->menu_order, + 'comment_status' => (string) $original->comment_status, + 'ping_status' => (string) $original->ping_status, + 'post_password' => (string) $original->post_password, + 'post_author' => get_current_user_id(), + ]), true); + + if ($copyId instanceof WP_Error || (int) $copyId === 0) { + $reason = $copyId instanceof WP_Error ? $copyId->get_error_message() : 'unknown'; + throw new RuntimeException('Copy could not be created: ' . $reason); + } + + $copyId = (int) $copyId; + $this->copyMeta((int) $original->ID, $copyId); + $this->copyTerms($original, $copyId); + update_post_meta($copyId, self::COPY_OF_META, (int) $original->ID); + + return $copyId; + } + + /** @throws RuntimeException */ + public function replace(WP_Post $original, WP_Post $copy): void + { + $result = wp_update_post(wp_slash([ + 'ID' => (int) $original->ID, + 'post_title' => (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title), + 'post_content' => (string) $copy->post_content, + 'post_excerpt' => (string) $copy->post_excerpt, + ]), true); + if ($result instanceof WP_Error) { + throw new RuntimeException('Original could not be updated: ' . $result->get_error_message()); + } + + foreach (array_keys((array) get_post_meta((int) $original->ID)) as $key) { + if (! in_array((string) $key, self::SKIP_META, true)) { + delete_post_meta((int) $original->ID, (string) $key); + } + } + + $this->copyMeta((int) $copy->ID, (int) $original->ID); + $this->copyTerms($copy, (int) $original->ID); + } + + private function copyMeta(int $from, int $to): void + { + foreach ((array) get_post_meta($from) as $key => $values) { + if (in_array((string) $key, self::SKIP_META, true)) { + continue; + } + + foreach ((array) $values as $value) { + add_post_meta($to, (string) $key, wp_slash(maybe_unserialize($value))); + } + } + } + + private function copyTerms(WP_Post $from, int $to): void + { + foreach (get_object_taxonomies((string) $from->post_type) as $taxonomy) { + $ids = wp_get_object_terms((int) $from->ID, $taxonomy, ['fields' => 'ids']); + if (is_array($ids)) { + wp_set_object_terms($to, array_map('intval', $ids), $taxonomy, false); + } + } + } +} +``` + +`src/Integrations/WordPress/Status/PostDuplicateAbility.php`: +```php + 'object', + 'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['original_id' => ['type' => 'integer']], ['original_id']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $post = $this->load($input); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $original = $this->load($input); + if ($original instanceof WP_Error) { + return $original; + } + + $copyId = $this->cloner->duplicate($original); + do_action('lit_mcp_after_save', $copyId, $this->name(), $input); + + $copy = get_post($copyId); + $copyTitle = $copy instanceof WP_Post ? (string) $copy->post_title : ''; + $postType = (string) $original->post_type; + $summary = sprintf('Kopie von "%s" (ID %d) angelegt', $original->post_title, $original->ID); + $logId = $this->log->record($this->name(), $postType, (string) $copyId, $summary); + + return ToolResult::ok(sprintf('%s: Entwurf ID %d.', $summary, $copyId)) + ->changed((string) $copyId, $postType, 'created', $copyTitle) + ->logId($logId) + ->urls((string) get_preview_post_link($copyId), (string) get_edit_post_link($copyId, 'raw')) + ->with('original_id', (int) $original->ID) + ->toArray(); + } + + /** @param array $input */ + private function load(array $input): WP_Post|WP_Error + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + if ($post instanceof WP_Error) { + return $post; + } + + $type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts'); + + return $type instanceof WP_Error ? $type : $post; + } +} +``` + +`src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php`: +```php +trashEnabled = $trashEnabled + ?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS; + } + + public function name(): string + { + return 'lit/post-replace-with-copy'; + } + + public function label(): string + { + return 'Kopie ins Original übernehmen'; + } + + public function description(): string + { + return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit-post-duplicate erstellten Kopie ' + . 'ins Original. ID, URL, Status und Menüeinträge des Originals bleiben; vorher entsteht eine ' + . 'Revision des Originals, die Kopie kommt danach in den Papierkorb. Erst nach Rückfrage mit ' + . 'confirm: true aufrufen.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'original_id' => ['type' => 'integer', 'minimum' => 1], + 'copy_id' => ['type' => 'integer', 'minimum' => 1], + 'confirm' => ['type' => 'boolean'], + ], + 'required' => ['original_id', 'copy_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $pair = $this->load($input); + + return $pair instanceof WP_Error ? $pair : true; + } + + public function execute(array $input): mixed + { + $pair = $this->load($input); + if ($pair instanceof WP_Error) { + return $pair; + } + + [$original, $copy] = $pair; + $originalId = (int) $original->ID; + $copyId = (int) $copy->ID; + + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired(sprintf( + 'Inhalt von "%s" (ID %d) durch die Kopie ID %d ersetzen und die Kopie in den Papierkorb legen?', + $original->post_title, + $originalId, + $copyId + )); + } + + $copyOf = (int) get_post_meta($copyId, PostCloner::COPY_OF_META, true); + if ($copy->post_type !== $original->post_type || $copyOf !== $originalId) { + return Errors::invalidInput( + 'Die Kopie gehört nicht zu diesem Original.', + ['copy_id' => sprintf('keine mit lit-post-duplicate erstellte Kopie von %d', $originalId)], + 'Kopie mit lit-post-duplicate aus diesem Original erstellen.' + ); + } + + if (! ($this->trashEnabled)()) { + return Errors::unsupported( + 'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. ' + . 'Nichts wurde geändert.', + 'Den Austausch in WordPress manuell durchführen.' + ); + } + + $revisionId = RevisionPoint::capture($originalId); + $this->cloner->replace($original, $copy); + wp_trash_post($copyId); + do_action('lit_mcp_after_save', $originalId, $this->name(), $input); + + $title = (string) preg_replace('/ \(Kopie\)$/u', '', (string) $copy->post_title); + $postType = (string) $original->post_type; + $summary = sprintf('Kopie ID %d in "%s" (ID %d) übernommen', $copyId, $title, $originalId); + $logId = $this->log->record($this->name(), $postType, (string) $originalId, $summary, $revisionId); + + $statusNote = sprintf('; Status bleibt %s, Kopie liegt im Papierkorb.', $original->post_status); + + return ToolResult::ok($summary . $statusNote) + ->changed((string) $originalId, $postType, 'replaced', $title) + ->changed((string) $copyId, $postType, 'trashed', (string) $copy->post_title) + ->revisionId($revisionId) + ->logId($logId) + ->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw')) + ->toArray(); + } + + /** + * @param array $input + * @return array{0: WP_Post, 1: WP_Post}|WP_Error + */ + private function load(array $input): array|WP_Error + { + $original = PostGuard::editable($input['original_id'] ?? null, $this->schema, 'original_id'); + if ($original instanceof WP_Error) { + return $original; + } + + $copy = PostGuard::editable($input['copy_id'] ?? null, $this->schema, 'copy_id'); + if ($copy instanceof WP_Error) { + return $copy; + } + + if (! current_user_can('delete_post', (int) $copy->ID)) { + return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.'); + } + + return [$original, $copy]; + } +} +``` + +`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen +```php +use LIT\MCP\Integrations\WordPress\Status\PostCloner; +use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility; +use LIT\MCP\Integrations\WordPress\Status\PostReplaceWithCopyAbility; +``` +und in `abilities()` vor dem `return` `$cloner = new PostCloner();` ergänzen sowie nach `new PostTrashAbility(...)` einfügen: +```php + new PostDuplicateAbility($this->schema, $cloner, $this->log), + new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log), +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS (lange Zeilen für phpcs umbrechen). + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/WordPress tests/Unit/Integrations/WordPress +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: draft copies and replace-with-copy + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 12: Revisionen – `lit/revisions-list`, `lit/revision-restore` + +**Files:** +- Create: `src/Integrations/WordPress/Revisions/RevisionsListAbility.php`, `src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php` +- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` +- Test: `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`, `tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php` + +**Interfaces:** +- Consumes: `PostGuard`, `RevisionPoint`, `Cursor`, `Rev`, `ChangeLog`, `ToolResult`, `Errors`. +- Produces: + - `RevisionsListAbility`: `__construct(ContentSchema $schema)`; `lit/revisions-list`, Input `{post_id, limit?, cursor?}`, Output `{post_id, items: list<{id, date, author{id,name}, title, autosave, excerpt}>, next_cursor}`; indirekt; readonly. + - `RevisionRestoreAbility`: `__construct(ContentSchema $schema, ChangeLog $log)`; `lit/revision-restore`, Input `{revision_id, confirm}`; indirekt; `destructive: true`, idempotent; sichert den aktuellen Stand vorher als Revision (`revision_id` im Ergebnis = dieser Wiederherstellungspunkt); Status unverändert. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`: +```php +justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page'])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_the_author_meta')->justReturn('Jan'); + Functions\when('wp_is_post_autosave')->alias(static fn ($post): bool => $post->ID === 811); + Functions\when('wp_strip_all_tags')->alias(static fn (string $text): string => strip_tags($text)); + Functions\expect('wp_get_post_revisions')->once()->with(5, ['posts_per_page' => 3, 'offset' => 0])->andReturn([ + 812 => new WP_Post(['ID' => 812, 'post_title' => 'Team', 'post_content' => '

Neu

', 'post_author' => '2']), + 811 => new WP_Post(['ID' => 811, 'post_title' => 'Team', 'post_content' => '

Auto

', 'post_author' => '2']), + 810 => new WP_Post(['ID' => 810, 'post_title' => 'Team', 'post_content' => '

Alt

', 'post_author' => '2']), + ]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + $result = (new RevisionsListAbility($schema))->execute(['post_id' => 5, 'limit' => 2]); + + self::assertSame(5, $result['post_id']); + self::assertSame([812, 811], array_column($result['items'], 'id')); + self::assertSame('Neu', $result['items'][0]['excerpt']); + self::assertTrue($result['items'][1]['autosave']); + self::assertSame(['id' => 2, 'name' => 'Jan'], $result['items'][0]['author']); + self::assertSame(2, Cursor::decode($result['next_cursor'])); + } +} +``` + +`tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php`: +```php +alias(static fn (int $id) => match ($id) { + 810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5, 'post_date' => '2026-09-20 10:00:00']), + 5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish', 'post_content' => 'alt']), + default => null, + }); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(ChangeLog $log): RevisionRestoreAbility + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new RevisionRestoreAbility($schema, $log); + } + + public function testNonRevisionIdIsNotFound(): void + { + self::assertSame('lit_not_found', $this->ability(Mockery::mock(ChangeLog::class))->permission(['revision_id' => 5])->get_error_code()); + } + + public function testRequiresConfirmation(): void + { + Functions\expect('wp_restore_post_revision')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['revision_id' => 810]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + } + + public function testSavesTheCurrentStateThenRestores(): void + { + $order = []; + Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { + $order[] = 'backup'; + return 813; + }); + Functions\when('wp_get_post_revisions')->justReturn([813 => new WP_Post(['ID' => 813])]); + Functions\expect('wp_restore_post_revision')->once()->with(810)->andReturnUsing(static function () use (&$order): int { + $order[] = 'restore'; + return 5; + }); + Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/revision-restore', 'page', '5', Mockery::type('string'), 813)->andReturn(12); + + $result = $this->ability($log)->execute(['revision_id' => 810, 'confirm' => true]); + + self::assertSame(['backup', 'restore'], $order); + self::assertSame(813, $result['revision_id']); + self::assertSame('restored', $result['changed'][0]['action']); + self::assertStringContainsString('2026-09-20 10:00:00', $result['summary']); + } +} +``` + +In `WordPressIntegrationTest` die Konstante um `'lit/revisions-list', 'lit/revision-restore'` (nach `'lit/post-replace-with-copy'`) ergänzen: +```php + private const EXPECTED = [ + 'lit/content-types', + 'lit/content-query', + 'lit/content-get', + 'lit/content-create', + 'lit/content-update', + 'lit/post-publish', + 'lit/post-unpublish', + 'lit/post-schedule', + 'lit/post-trash', + 'lit/post-duplicate', + 'lit/post-replace-with-copy', + 'lit/revisions-list', + 'lit/revision-restore', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'Revision|WordPressIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Revisions/RevisionsListAbility.php`: +```php + 'object', + 'properties' => array_merge( + ['post_id' => ['type' => 'integer', 'minimum' => 1]], + Cursor::inputProperties() + ), + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'date' => ['type' => 'string'], + 'author' => ['type' => 'object'], + 'title' => ['type' => 'string'], + 'autosave' => ['type' => 'boolean'], + 'excerpt' => ['type' => 'string'], + ], + 'required' => ['id', 'date', 'author', 'title', 'autosave', 'excerpt'], + ], + ], + 'next_cursor' => ['type' => ['string', 'null']], + ], + 'required' => ['post_id', 'items', 'next_cursor'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + if ($post instanceof WP_Error) { + return $post; + } + + $offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + + $limit = Cursor::limit($input['limit'] ?? null); + $revisions = wp_get_post_revisions((int) $post->ID, ['posts_per_page' => $limit + 1, 'offset' => $offset]); + $page = Cursor::page(array_values(is_array($revisions) ? $revisions : []), $limit, $offset); + + return [ + 'post_id' => (int) $post->ID, + 'items' => array_map(fn (WP_Post $revision): array => $this->item($revision), $page['items']), + 'next_cursor' => $page['next_cursor'], + ]; + } + + /** @return array */ + private function item(WP_Post $revision): array + { + $text = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags((string) $revision->post_content))); + + return [ + 'id' => (int) $revision->ID, + 'date' => (string) $revision->post_date, + 'author' => [ + 'id' => (int) $revision->post_author, + 'name' => (string) get_the_author_meta('display_name', (int) $revision->post_author), + ], + 'title' => (string) $revision->post_title, + 'autosave' => (bool) wp_is_post_autosave($revision), + 'excerpt' => mb_substr($text, 0, 160), + ]; + } +} +``` + +`src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php`: +```php + 'object', + 'properties' => [ + 'revision_id' => ['type' => 'integer', 'minimum' => 1], + 'confirm' => ['type' => 'boolean'], + ], + 'required' => ['revision_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => true, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $pair = $this->load($input); + + return $pair instanceof WP_Error ? $pair : true; + } + + public function execute(array $input): mixed + { + $pair = $this->load($input); + if ($pair instanceof WP_Error) { + return $pair; + } + + [$revision, $post] = $pair; + $postId = (int) $post->ID; + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired(sprintf( + '"%s" (ID %d) auf den Stand vom %s zurücksetzen?', + $post->post_title, + $postId, + $revision->post_date + )); + } + + $backupId = RevisionPoint::capture($postId); + $restored = wp_restore_post_revision((int) $revision->ID); + if (! $restored) { + return Errors::unsupported( + 'WordPress konnte die Revision nicht wiederherstellen.', + 'Revision in WordPress unter „Revisionen“ prüfen.' + ); + } + + do_action('lit_mcp_after_save', $postId, $this->name(), $input); + $updated = get_post($postId); + $updated = $updated instanceof WP_Post ? $updated : $post; + $summary = sprintf('"%s" auf Stand vom %s zurückgesetzt', $updated->post_title, $revision->post_date); + $postType = (string) $post->post_type; + $logId = $this->log->record($this->name(), $postType, (string) $postId, $summary, $backupId); + + return ToolResult::ok($summary . sprintf(' (Status unverändert: %s).', $updated->post_status)) + ->changed((string) $postId, $postType, 'restored', (string) $updated->post_title) + ->rev(Rev::of((string) $updated->post_content)) + ->revisionId($backupId) + ->logId($logId) + ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) + ->toArray(); + } + + /** + * @param array $input + * @return array{0: WP_Post, 1: WP_Post}|WP_Error + */ + private function load(array $input): array|WP_Error + { + $revisionId = PostGuard::id($input['revision_id'] ?? null); + $revision = $revisionId === null ? null : get_post($revisionId); + if (! $revision instanceof WP_Post || $revision->post_type !== 'revision') { + return Errors::notFound('Revision nicht gefunden.', 'Revisionen über lit-revisions-list abrufen.'); + } + + $post = PostGuard::editable((int) $revision->post_parent, $this->schema); + + return $post instanceof WP_Error ? $post : [$revision, $post]; + } +} +``` + +`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen +```php +use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; +use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; +``` +und in `abilities()` nach `new PostReplaceWithCopyAbility(...)` einfügen: +```php + new RevisionsListAbility($this->schema), + new RevisionRestoreAbility($this->schema, $this->log), +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/WordPress tests/Unit/Integrations/WordPress +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: list and restore revisions + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 13: Menüs lesen, anlegen, Positionen – `lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-assign-location` + +Recht für alle Menü-Tools: `edit_theme_options` (Spec §4.5). + +**Files:** +- Create: `src/Integrations/WordPress/Menus/MenuTree.php`, `src/Integrations/WordPress/Menus/MenuLocations.php`, `src/Integrations/WordPress/Menus/MenuListAbility.php`, `src/Integrations/WordPress/Menus/MenuGetAbility.php`, `src/Integrations/WordPress/Menus/MenuCreateAbility.php`, `src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php` +- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` +- Test: `tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php` + +**Interfaces:** +- Consumes: `ChangeLog`, `ToolResult`, `Errors`. +- Produces: + - `MenuTree` (final, statisch): `build(list $items): list` mit `Node = {id: int, title: string, type: 'post'|'taxonomy'|'custom', object: string, object_id: int, url: string, children: list}` (Einträge mit fehlendem Elternteil landen auf oberster Ebene); `node(object $item): array` (ohne `children`); `nodeSchema(): array`. + - `MenuLocations` (final, statisch): `registered(): array`, `assigned(): array`, `of(int $menuId): list`, `assign(string $location, int $menuId): void` (`0` = Zuordnung entfernen). + - `MenuListAbility()` – `lit/menu-list`, Output `{menus: list<{id, name, slug, count, locations}>, locations: list<{location, description, menu_id|null}>}`; indirekt; readonly. + - `MenuGetAbility()` – `lit/menu-get`, Input `{menu_id}`, Output `{id, name, locations, items: list}`; indirekt; readonly. + - `MenuCreateAbility(ChangeLog $log)` – `lit/menu-create`, Input `{name, location?}`; Ergebnis-Extra `menu_id`; indirekt; nicht idempotent. + - `MenuAssignLocationAbility(ChangeLog $log)` – `lit/menu-assign-location`, Input `{location, menu_id}` (`0` entfernt); Ergebnis-Extra `previous_menu_id` (int|null); indirekt; idempotent. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/WordPress/Menus/MenuTreeTest.php`: +```php + $id, + 'menu_item_parent' => (string) $parent, + 'title' => $title, + 'post_title' => $type === 'custom' ? $title : '', + 'type' => $type, + 'object' => $type === 'custom' ? 'custom' : 'page', + 'object_id' => (string) ($type === 'custom' ? $id : $id + 100), + 'url' => $url, + 'description' => '', + 'attr_title' => '', + 'target' => '', + 'classes' => [''], + 'xfn' => '', + ]; + } + + public function testBuildsANestedTreeInMenuOrder(): void + { + $tree = MenuTree::build([ + self::item(10, 0, 'Start'), + self::item(11, 0, 'Über uns'), + self::item(12, 11, 'Team'), + self::item(13, 0, 'Kontakt', 'custom', '/kontakt'), + ]); + + self::assertSame(['Start', 'Über uns', 'Kontakt'], array_column($tree, 'title')); + self::assertSame('Team', $tree[1]['children'][0]['title']); + self::assertSame([], $tree[1]['children'][0]['children']); + self::assertSame('custom', $tree[2]['type']); + self::assertSame('post', $tree[0]['type']); + self::assertSame(110, $tree[0]['object_id']); + } + + public function testOrphansAndSelfParentsLandOnTheTopLevel(): void + { + $tree = MenuTree::build([self::item(20, 99, 'Waise'), self::item(21, 21, 'Zirkel')]); + + self::assertSame(['Waise', 'Zirkel'], array_column($tree, 'title')); + } +} +``` + +`tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php`: +```php +justReturn([ + (object) ['term_id' => 3, 'name' => 'Hauptmenü', 'slug' => 'hauptmenue', 'count' => 4], + ]); + Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü', 'footer-menu' => 'Footer']); + Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]); + + $result = (new MenuListAbility())->execute([]); + + self::assertSame(['primary-menu'], $result['menus'][0]['locations']); + self::assertSame( + [ + ['location' => 'primary-menu', 'description' => 'Hauptmenü', 'menu_id' => 3], + ['location' => 'footer-menu', 'description' => 'Footer', 'menu_id' => null], + ], + $result['locations'] + ); + } + + public function testGetReturnsTheTreeOrNotFound(): void + { + Functions\when('wp_get_nav_menu_object')->alias( + static fn (int $id) => $id === 3 ? (object) ['term_id' => 3, 'name' => 'Hauptmenü'] : false + ); + Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Start')]); + Functions\when('get_nav_menu_locations')->justReturn([]); + $ability = new MenuGetAbility(); + + $result = $ability->execute(['menu_id' => 3]); + + self::assertSame('Hauptmenü', $result['name']); + self::assertSame('Start', $result['items'][0]['title']); + self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9])->get_error_code()); + } + + public function testReadToolsNeedThemeOptionRights(): void + { + Functions\when('current_user_can')->justReturn(false); + + self::assertSame('lit_forbidden', (new MenuListAbility())->permission([])->get_error_code()); + self::assertSame('lit_forbidden', (new MenuGetAbility())->permission(['menu_id' => 3])->get_error_code()); + } +} +``` + +`tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php`: +```php +justReturn(['primary-menu' => 'Hauptmenü']); + Functions\expect('wp_create_nav_menu')->never(); + + $result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Neu', 'location' => 'nope']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('primary-menu', $result->get_error_message()); + } + + public function testExistingNameIsAValidationError(): void + { + Functions\when('wp_create_nav_menu')->justReturn(new WP_Error('menu_exists', 'Der Menüname existiert bereits.')); + + $result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Hauptmenü']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testCreatesAndAssignsTheLocation(): void + { + Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']); + Functions\expect('wp_create_nav_menu')->once()->with('Neu')->andReturn(8); + Functions\when('get_nav_menu_locations')->justReturn(['footer-menu' => 2]); + Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['footer-menu' => 2, 'primary-menu' => 8]); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/menu-create', 'nav_menu', '8', 'Menü "Neu" angelegt (Position primary-menu)')->andReturn(5); + + $result = (new MenuCreateAbility($log))->execute(['name' => 'Neu', 'location' => 'primary-menu']); + + self::assertSame(8, $result['menu_id']); + self::assertSame('created', $result['changed'][0]['action']); + } +} +``` + +`tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php`: +```php +justReturn(['primary-menu' => 'Hauptmenü']); + Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]); + Functions\when('wp_get_nav_menu_object')->alias(static fn (int $id) => $id === 8 ? (object) ['term_id' => 8, 'name' => 'Neu'] : false); + } + + public function testAssignsAndReportsThePreviousMenu(): void + { + Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['primary-menu' => 8]); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andReturn(6); + + $result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 8]); + + self::assertSame(3, $result['previous_menu_id']); + self::assertSame('assigned', $result['changed'][0]['action']); + } + + public function testZeroRemovesTheAssignment(): void + { + Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', []); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andReturn(7); + + $result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 0]); + + self::assertSame('unassigned', $result['changed'][0]['action']); + } + + public function testUnknownMenuIsNotFound(): void + { + Functions\expect('set_theme_mod')->never(); + + $result = (new MenuAssignLocationAbility(Mockery::mock(ChangeLog::class))) + ->execute(['location' => 'primary-menu', 'menu_id' => 99]); + + self::assertSame('lit_not_found', $result->get_error_code()); + } +} +``` + +In `WordPressIntegrationTest` die Konstante ersetzen (Reihenfolge wie in `abilities()`): +```php + private const EXPECTED = [ + 'lit/content-types', + 'lit/content-query', + 'lit/content-get', + 'lit/content-create', + 'lit/content-update', + 'lit/post-publish', + 'lit/post-unpublish', + 'lit/post-schedule', + 'lit/post-trash', + 'lit/post-duplicate', + 'lit/post-replace-with-copy', + 'lit/revisions-list', + 'lit/revision-restore', + 'lit/menu-list', + 'lit/menu-get', + 'lit/menu-create', + 'lit/menu-assign-location', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'Menu|WordPressIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Menus/MenuTree.php`: +```php + $items Sorted by menu_order (wp_get_nav_menu_items default). + * @return list> + */ + public static function build(array $items): array + { + $nodes = []; + foreach ($items as $item) { + $nodes[(int) $item->ID] = self::node($item); + } + + $children = []; + foreach ($items as $item) { + $id = (int) $item->ID; + $parent = (int) $item->menu_item_parent; + $children[$parent !== $id && isset($nodes[$parent]) ? $parent : 0][] = $id; + } + + return self::attach(0, $nodes, $children); + } + + /** @return array{id: int, title: string, type: string, object: string, object_id: int, url: string} */ + public static function node(object $item): array + { + return [ + 'id' => (int) $item->ID, + 'title' => (string) $item->title, + 'type' => match ((string) $item->type) { + 'post_type' => 'post', + 'taxonomy' => 'taxonomy', + default => 'custom', + }, + 'object' => (string) $item->object, + 'object_id' => (int) $item->object_id, + 'url' => (string) $item->url, + ]; + } + + /** @return array JSON schema of one tree node (children not validated deeper). */ + public static function nodeSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'object' => ['type' => 'string'], + 'object_id' => ['type' => 'integer'], + 'url' => ['type' => 'string'], + 'children' => ['type' => 'array'], + ], + 'required' => ['id', 'title', 'type', 'children'], + ]; + } + + /** + * @param array> $nodes + * @param array> $children + * @return list> + */ + private static function attach(int $parent, array $nodes, array $children): array + { + $result = []; + foreach ($children[$parent] ?? [] as $id) { + $result[] = array_merge($nodes[$id], ['children' => self::attach($id, $nodes, $children)]); + } + + return $result; + } +} +``` + +`src/Integrations/WordPress/Menus/MenuLocations.php`: +```php + Location slug => description. */ + public static function registered(): array + { + return array_map('strval', (array) get_registered_nav_menus()); + } + + /** @return array Location slug => menu ID. */ + public static function assigned(): array + { + $locations = get_nav_menu_locations(); + + return is_array($locations) ? array_map('intval', $locations) : []; + } + + /** @return list */ + public static function of(int $menuId): array + { + return array_map('strval', array_keys(array_filter( + self::assigned(), + static fn (int $id): bool => $id === $menuId + ))); + } + + public static function assign(string $location, int $menuId): void + { + $locations = self::assigned(); + if ($menuId > 0) { + $locations[$location] = $menuId; + } else { + unset($locations[$location]); + } + + set_theme_mod('nav_menu_locations', $locations); + } +} +``` + +`src/Integrations/WordPress/Menus/MenuListAbility.php`: +```php + 'object', 'properties' => [], 'additionalProperties' => false]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'menus' => ['type' => 'array', 'items' => ['type' => 'object']], + 'locations' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['menus', 'locations'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_theme_options') + ? true + : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); + } + + public function execute(array $input): mixed + { + $menus = []; + foreach ((array) wp_get_nav_menus() as $menu) { + $menus[] = [ + 'id' => (int) $menu->term_id, + 'name' => (string) $menu->name, + 'slug' => (string) $menu->slug, + 'count' => (int) $menu->count, + 'locations' => MenuLocations::of((int) $menu->term_id), + ]; + } + + $assigned = MenuLocations::assigned(); + $locations = []; + foreach (MenuLocations::registered() as $location => $description) { + $menuId = $assigned[$location] ?? 0; + $locations[] = [ + 'location' => (string) $location, + 'description' => $description, + 'menu_id' => $menuId > 0 ? $menuId : null, + ]; + } + + return ['menus' => $menus, 'locations' => $locations]; + } +} +``` + +`src/Integrations/WordPress/Menus/MenuGetAbility.php`: +```php + 'object', + 'properties' => ['menu_id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['menu_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'locations' => ['type' => 'array', 'items' => ['type' => 'string']], + 'items' => ['type' => 'array', 'items' => MenuTree::nodeSchema()], + ], + 'required' => ['id', 'name', 'locations', 'items'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_theme_options') + ? true + : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); + } + + public function execute(array $input): mixed + { + $menuId = (int) ($input['menu_id'] ?? 0); + $menu = wp_get_nav_menu_object($menuId); + if (! is_object($menu)) { + return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); + } + + $items = wp_get_nav_menu_items($menuId); + + return [ + 'id' => (int) $menu->term_id, + 'name' => (string) $menu->name, + 'locations' => MenuLocations::of((int) $menu->term_id), + 'items' => MenuTree::build(is_array($items) ? $items : []), + ]; + } +} +``` + +`src/Integrations/WordPress/Menus/MenuCreateAbility.php`: +```php + 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 1], + 'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit-menu-list.'], + ], + 'required' => ['name'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['menu_id' => ['type' => 'integer']], ['menu_id']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_theme_options') + ? true + : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); + } + + public function execute(array $input): mixed + { + $name = trim((string) ($input['name'] ?? '')); + $location = isset($input['location']) ? (string) $input['location'] : ''; + if ($location !== '' && ! array_key_exists($location, MenuLocations::registered())) { + return Errors::invalidInput( + 'Unbekannte Menü-Position.', + ['location' => 'verfügbar: ' . implode(', ', array_keys(MenuLocations::registered()))] + ); + } + + $menuId = wp_create_nav_menu($name); + if ($menuId instanceof WP_Error) { + return Errors::invalidInput( + 'Menü konnte nicht angelegt werden: ' . $menuId->get_error_message(), + ['name' => 'bereits vergeben oder ungültig'], + 'Vorhandene Menüs über lit-menu-list abrufen.' + ); + } + + $menuId = (int) $menuId; + if ($location !== '') { + MenuLocations::assign($location, $menuId); + } + + $summary = sprintf('Menü "%s" angelegt', $name) + . ($location !== '' ? sprintf(' (Position %s)', $location) : ''); + $logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary); + + return ToolResult::ok($summary . sprintf(', ID %d.', $menuId)) + ->changed((string) $menuId, 'nav_menu', 'created', $name) + ->logId($logId) + ->with('menu_id', $menuId) + ->toArray(); + } +} +``` + +`src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php`: +```php + 'object', + 'properties' => [ + 'location' => ['type' => 'string', 'minLength' => 1], + 'menu_id' => ['type' => 'integer', 'minimum' => 0], + ], + 'required' => ['location', 'menu_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['previous_menu_id' => ['type' => ['integer', 'null']]], ['previous_menu_id']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_theme_options') + ? true + : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); + } + + public function execute(array $input): mixed + { + $location = (string) ($input['location'] ?? ''); + $menuId = (int) ($input['menu_id'] ?? 0); + $registered = MenuLocations::registered(); + if (! array_key_exists($location, $registered)) { + return Errors::invalidInput( + 'Unbekannte Menü-Position.', + ['location' => 'verfügbar: ' . implode(', ', array_keys($registered))] + ); + } + + if ($menuId > 0 && ! is_object(wp_get_nav_menu_object($menuId))) { + return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); + } + + $previous = MenuLocations::assigned()[$location] ?? 0; + MenuLocations::assign($location, $menuId); + + $action = $menuId > 0 ? 'assigned' : 'unassigned'; + $summary = $menuId > 0 + ? sprintf('Menü %d der Position %s zugewiesen', $menuId, $location) + : sprintf('Zuweisung der Position %s entfernt', $location); + $logId = $this->log->record($this->name(), 'menu_location', $location, $summary); + + return ToolResult::ok($summary . '.') + ->changed($location, 'menu_location', $action, $registered[$location]) + ->logId($logId) + ->with('previous_menu_id', $previous > 0 ? $previous : null) + ->toArray(); + } +} +``` + +`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen +```php +use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility; +use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility; +use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility; +use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility; +``` +und in `abilities()` nach `new RevisionRestoreAbility(...)` einfügen: +```php + new MenuListAbility(), + new MenuGetAbility(), + new MenuCreateAbility($this->log), + new MenuAssignLocationAbility($this->log), +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/WordPress tests/Unit/Integrations/WordPress +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: menu list, get, create and location tools + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 14: Menüeinträge setzen – `MenuOperations`, `MenuWriter`, `lit/menu-set-items` + +Spec §6.4: Einträge deklarativ als Gesamtbaum **oder** als Einzeloperationen (hinzufügen/verschieben/entfernen). Beide Wege laufen durch denselben Schreibpfad: Operationen werden auf den aktuellen Baum angewendet und ergeben einen Zielbaum. Jede Entfernung braucht `confirm: true`; die Antwort enthält immer `previous_items` (Baum vorher), damit Claude einen Fehler zurückbauen kann. Menüeinträge (`nav_menu_item`) löscht WordPress selbst ohne Papierkorb (`wp_delete_post($id, true)`, wie der Menü-Editor) – das ist die einzige Ausnahme von „nie endgültig löschen“, sie betrifft nur Menüstruktur, keine Inhalte. + +**Files:** +- Create: `src/Integrations/WordPress/Menus/MenuOperations.php`, `src/Integrations/WordPress/Menus/MenuWriter.php`, `src/Integrations/WordPress/Menus/MenuSetItemsAbility.php` +- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` +- Test: `tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php`, `tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php` + +**Interfaces:** +- Consumes: `MenuTree::build()` (Task 13), `ChangeLog`, `ToolResult`, `Errors`, Test-Helfer `MenuTreeTest::item()`. +- Produces: + - Zielbaum-Knoten (Eingabe): bestehender Eintrag `{id, title?, url? (nur custom), children?}`; neuer Eintrag `{type: 'post'|'taxonomy'|'custom', object_id? (post/taxonomy), url? (custom), title? (custom: Pflicht), children?}`. + - `MenuOperations` (final): `apply(list $tree, list $operations): list|WP_Error` – Operationen `{op: 'add', item: {…}, parent_id?, position?}`, `{op: 'move', id, parent_id?, position?}`, `{op: 'remove', id}` (Kinder rücken an die Stelle des entfernten Eintrags); `position` = 0-basierter Index unter den Geschwistern, ohne = ans Ende. + - `MenuWriter` (nicht final): `plan(list $tree, array $current): array{errors: array, removals: list, warnings: list}` (schreibt nichts); `apply(int $menuId, list $tree, array $current): array{created: int, updated: int, removed: int}` (wirft `RuntimeException`). + - `MenuSetItemsAbility`: `__construct(MenuWriter $writer, MenuOperations $operations, ChangeLog $log)`; `lit/menu-set-items`, Input `{menu_id, items? | operations?, confirm?}`; Ergebnis-Extras `items` (Baum nachher), `previous_items` (Baum vorher); indirekt; `destructive: true`, nicht idempotent. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php`: +```php +> Start(1) > Team(2); Kontakt(3) */ + private function tree(): array + { + return [ + ['id' => 1, 'title' => 'Start', 'children' => [['id' => 2, 'title' => 'Team', 'children' => []]]], + ['id' => 3, 'title' => 'Kontakt', 'children' => []], + ]; + } + + public function testAddInsertsANewNodeAtThePosition(): void + { + $result = (new MenuOperations())->apply($this->tree(), [ + ['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog'], 'position' => 1], + ]); + + self::assertSame(1, $result[0]['id']); + self::assertSame(['type' => 'custom', 'url' => '/blog', 'title' => 'Blog', 'children' => []], $result[1]); + self::assertSame(3, $result[2]['id']); + } + + public function testMoveAndRemoveKeepChildren(): void + { + $result = (new MenuOperations())->apply($this->tree(), [ + ['op' => 'move', 'id' => 3, 'parent_id' => 1, 'position' => 0], + ['op' => 'remove', 'id' => 1], + ]); + + self::assertSame([3, 2], array_column($result, 'id')); + self::assertSame([], $result[0]['children']); + } + + public function testInvalidOperationsAreReportedWithTheirIndex(): void + { + $operations = new MenuOperations(); + + $unknown = $operations->apply($this->tree(), [['op' => 'move', 'id' => 99]]); + $cycle = $operations->apply($this->tree(), [['op' => 'move', 'id' => 1, 'parent_id' => 2]]); + $badOp = $operations->apply($this->tree(), [['op' => 'rename', 'id' => 1]]); + + self::assertArrayHasKey('operations.0', $unknown->get_error_data()['fields']); + self::assertStringContainsString('unter sich selbst', $cycle->get_error_data()['fields']['operations.0']); + self::assertStringContainsString('add, move oder remove', $badOp->get_error_data()['fields']['operations.0']); + } +} +``` + +`tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php`: +```php + */ + private function current(): array + { + return [ + 10 => MenuTreeTest::item(10, 0, 'Home', 'custom', '/'), + 11 => MenuTreeTest::item(11, 0, 'Über uns'), + ]; + } + + public function testPlanFindsErrorsRemovalsAndWarnings(): void + { + Functions\when('wp_parse_url')->alias('parse_url'); + Functions\when('get_post')->alias(static fn (int $id) => $id === 7 + ? new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Neu']) + : null); + + $plan = (new MenuWriter())->plan([ + ['id' => 11, 'children' => [['id' => 11]]], + ['id' => 55], + ['type' => 'post', 'object_id' => 7], + ['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'], + ['type' => 'custom', 'url' => '/kontakt'], + ], $this->current()); + + self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']); + self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']); + self::assertArrayNotHasKey('items.2', $plan['errors']); + self::assertStringContainsString('url', $plan['errors']['items.3']); + self::assertStringContainsString('title', $plan['errors']['items.4']); + self::assertSame([['id' => 10, 'title' => 'Home']], $plan['removals']); + self::assertStringContainsString('"Neu" ist nicht veröffentlicht', $plan['warnings'][0]); + } + + public function testApplyWritesInTreeOrderKeepsExistingDataAndRemovesTheRest(): void + { + Functions\when('esc_url_raw')->returnArg(); + Functions\expect('wp_update_nav_menu_item')->once()->with(3, 11, Mockery::on(static fn (array $args): bool => + $args['menu-item-title'] === 'Wir' + && $args['menu-item-type'] === 'post_type' + && $args['menu-item-object-id'] === 111 + && $args['menu-item-parent-id'] === 0 + && $args['menu-item-position'] === 1 + && $args['menu-item-status'] === 'publish'))->andReturn(11); + Functions\expect('wp_update_nav_menu_item')->once()->with(3, 0, Mockery::on(static fn (array $args): bool => + $args['menu-item-type'] === 'custom' + && $args['menu-item-url'] === '/kontakt' + && $args['menu-item-title'] === 'Kontakt' + && $args['menu-item-parent-id'] === 11 + && $args['menu-item-position'] === 2))->andReturn(12); + Functions\expect('wp_delete_post')->once()->with(10, true); + + $stats = (new MenuWriter())->apply(3, [ + ['id' => 11, 'title' => 'Wir', 'children' => [['type' => 'custom', 'url' => '/kontakt', 'title' => 'Kontakt']]], + ], $this->current()); + + self::assertSame(['created' => 1, 'updated' => 1, 'removed' => 1], $stats); + } +} +``` + +`tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php`: +```php +justReturn((object) ['term_id' => 3, 'name' => 'Hauptmenü']); + Functions\when('wp_get_nav_menu_items')->justReturn([MenuTreeTest::item(10, 0, 'Home', 'custom', '/')]); + } + + public function testItemsAndOperationsAreMutuallyExclusive(): void + { + $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class)); + + self::assertSame('lit_validation_failed', $ability->execute(['menu_id' => 3])->get_error_code()); + self::assertSame( + 'lit_validation_failed', + $ability->execute(['menu_id' => 3, 'items' => [], 'operations' => [['op' => 'remove', 'id' => 10]]])->get_error_code() + ); + } + + public function testRemovalsNeedConfirmationAndNothingIsWritten(): void + { + Functions\expect('wp_update_nav_menu_item')->never(); + Functions\expect('wp_delete_post')->never(); + $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class)); + + $result = $ability->execute(['menu_id' => 3, 'items' => []]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertSame([['id' => 10, 'title' => 'Home']], $result->get_error_data()['removals']); + } + + public function testAppliesAndReturnsTheTreeBeforeAndAfter(): void + { + $writer = Mockery::mock(MenuWriter::class); + $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); + $writer->expects('apply')->andReturn(['created' => 1, 'updated' => 1, 'removed' => 0]); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/menu-set-items', 'nav_menu', '3', Mockery::type('string'))->andReturn(21); + + $result = (new MenuSetItemsAbility($writer, new MenuOperations(), $log))->execute([ + 'menu_id' => 3, + 'operations' => [['op' => 'add', 'item' => ['type' => 'custom', 'url' => '/blog', 'title' => 'Blog']]], + ]); + + self::assertSame('Home', $result['previous_items'][0]['title']); + self::assertSame(21, $result['log_id']); + self::assertStringContainsString('1 neu, 1 geändert, 0 entfernt', $result['summary']); + } +} +``` + +In `WordPressIntegrationTest` die Konstante um `'lit/menu-set-items'` nach `'lit/menu-create'` ergänzen: +```php + private const EXPECTED = [ + 'lit/content-types', + 'lit/content-query', + 'lit/content-get', + 'lit/content-create', + 'lit/content-update', + 'lit/post-publish', + 'lit/post-unpublish', + 'lit/post-schedule', + 'lit/post-trash', + 'lit/post-duplicate', + 'lit/post-replace-with-copy', + 'lit/revisions-list', + 'lit/revision-restore', + 'lit/menu-list', + 'lit/menu-get', + 'lit/menu-create', + 'lit/menu-set-items', + 'lit/menu-assign-location', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MenuOperations|MenuWriter|MenuSetItems|WordPressIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Menus/MenuOperations.php`: +```php +> $tree Current tree (MenuTree::build()). + * @param list $operations + * @return list>|WP_Error + */ + public function apply(array $tree, array $operations): array|WP_Error + { + $nodes = []; + $children = [self::ROOT => []]; + $this->flatten($tree, self::ROOT, $nodes, $children); + + foreach ($operations as $index => $operation) { + $error = $this->applyOne($operation, (int) $index, $nodes, $children); + if ($error !== null) { + return Errors::invalidInput( + 'Menü-Operation ungültig; nichts wurde geändert.', + ['operations.' . $index => $error] + ); + } + } + + return $this->nest(self::ROOT, $nodes, $children); + } + + /** + * @param list> $tree + * @param array> $nodes + * @param array> $children + */ + private function flatten(array $tree, string $parent, array &$nodes, array &$children): void + { + foreach ($tree as $item) { + $key = 'id:' . (int) $item['id']; + $nodes[$key] = ['id' => (int) $item['id']]; + $children[$parent][] = $key; + $children[$key] = []; + $this->flatten((array) ($item['children'] ?? []), $key, $nodes, $children); + } + } + + /** + * @param array> $nodes + * @param array> $children + */ + private function applyOne(mixed $operation, int $index, array &$nodes, array &$children): ?string + { + if (! is_array($operation)) { + return 'Objekt erwartet.'; + } + + $parentId = (int) ($operation['parent_id'] ?? 0); + $parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT; + if (! isset($children[$parentKey])) { + return 'parent_id ist kein Eintrag dieses Menüs.'; + } + + $position = isset($operation['position']) ? (int) $operation['position'] : null; + $key = 'id:' . (int) ($operation['id'] ?? 0); + + switch ($operation['op'] ?? '') { + case 'add': + if (! is_array($operation['item'] ?? null)) { + return 'item fehlt.'; + } + $newKey = 'new:' . $index; + $nodes[$newKey] = array_diff_key($operation['item'], ['id' => true, 'children' => true]); + $children[$newKey] = []; + $this->insert($children[$parentKey], $newKey, $position); + return null; + case 'move': + if (! isset($nodes[$key])) { + return 'id ist kein Eintrag dieses Menüs.'; + } + if ($key === $parentKey || $this->isDescendant($parentKey, $key, $children)) { + return 'Ein Eintrag kann nicht unter sich selbst verschoben werden.'; + } + $this->detach($key, $children); + $this->insert($children[$parentKey], $key, $position); + return null; + case 'remove': + if (! isset($nodes[$key])) { + return 'id ist kein Eintrag dieses Menüs.'; + } + [$owner, $at] = $this->detach($key, $children); + array_splice($children[$owner], $at, 0, $children[$key]); + unset($nodes[$key], $children[$key]); + return null; + default: + return 'op muss add, move oder remove sein.'; + } + } + + /** @param list $list */ + private function insert(array &$list, string $key, ?int $position): void + { + if ($position === null || $position >= count($list)) { + $list[] = $key; + return; + } + + array_splice($list, max(0, $position), 0, [$key]); + } + + /** + * @param array> $children + * @return array{0: string, 1: int} Former parent key and index. + */ + private function detach(string $key, array &$children): array + { + foreach ($children as $parent => $list) { + $index = array_search($key, $list, true); + if ($index !== false) { + array_splice($children[$parent], (int) $index, 1); + return [(string) $parent, (int) $index]; + } + } + + return [self::ROOT, count($children[self::ROOT])]; + } + + /** @param array> $children */ + private function isDescendant(string $candidate, string $ancestor, array $children): bool + { + foreach ($children[$ancestor] ?? [] as $child) { + if ($child === $candidate || $this->isDescendant($candidate, $child, $children)) { + return true; + } + } + + return false; + } + + /** + * @param array> $nodes + * @param array> $children + * @return list> + */ + private function nest(string $parent, array $nodes, array $children): array + { + $result = []; + foreach ($children[$parent] ?? [] as $key) { + $result[] = array_merge($nodes[$key], ['children' => $this->nest($key, $nodes, $children)]); + } + + return $result; + } +} +``` + +`src/Integrations/WordPress/Menus/MenuWriter.php`: +```php + $tree + * @param array $current Current items by ID. + * @return array{errors: array, removals: list, warnings: list} + */ + public function plan(array $tree, array $current): array + { + $errors = []; + $warnings = []; + $seen = []; + $this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings); + + $removals = []; + foreach ($current as $id => $item) { + if (! isset($seen[$id])) { + $removals[] = ['id' => (int) $id, 'title' => (string) $item->title]; + } + } + + return ['errors' => $errors, 'removals' => $removals, 'warnings' => $warnings]; + } + + /** + * @param list> $tree Validated by plan(). + * @param array $current + * @return array{created: int, updated: int, removed: int} + * @throws RuntimeException + */ + public function apply(int $menuId, array $tree, array $current): array + { + $stats = ['created' => 0, 'updated' => 0, 'removed' => 0]; + $position = 0; + $seen = []; + $this->writeNodes($menuId, $tree, 0, $current, $position, $seen, $stats); + + foreach (array_keys($current) as $id) { + if (! isset($seen[$id])) { + // Menu items have no trash in WordPress; the core menu editor deletes them the same way. + wp_delete_post((int) $id, true); + $stats['removed']++; + } + } + + return $stats; + } + + /** + * @param array $current + * @param array $seen + * @param array $errors + * @param list $warnings + */ + private function validateNodes( + mixed $nodes, + string $path, + array $current, + array &$seen, + array &$errors, + array &$warnings + ): void { + if (! is_array($nodes) || ! array_is_list($nodes)) { + $errors[$path] = 'Liste erwartet.'; + return; + } + + foreach ($nodes as $index => $node) { + $nodePath = $path . '.' . $index; + if (! is_array($node)) { + $errors[$nodePath] = 'Objekt erwartet.'; + continue; + } + + $error = array_key_exists('id', $node) + ? $this->checkExisting($node, $current, $seen) + : $this->checkNew($node, $warnings); + if ($error !== null) { + $errors[$nodePath] = $error; + } + + if (array_key_exists('children', $node)) { + $this->validateNodes($node['children'], $nodePath . '.children', $current, $seen, $errors, $warnings); + } + } + } + + /** + * @param array $node + * @param array $current + * @param array $seen + */ + private function checkExisting(array $node, array $current, array &$seen): ?string + { + $id = $node['id']; + if (! is_int($id) || ! isset($current[$id])) { + return 'id ist kein Eintrag dieses Menüs (für neue Einträge id weglassen).'; + } + + if (isset($seen[$id])) { + return sprintf('Eintrag %d kommt mehrfach vor.', $id); + } + $seen[$id] = true; + + if (array_key_exists('title', $node) && ! is_string($node['title'])) { + return 'title muss Text sein.'; + } + + if (array_key_exists('url', $node)) { + if ((string) $current[$id]->type !== 'custom') { + return 'url ist nur bei individuellen Links änderbar.'; + } + if (! $this->validUrl($node['url'])) { + return 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).'; + } + } + + return null; + } + + /** + * @param array $node + * @param list $warnings + */ + private function checkNew(array $node, array &$warnings): ?string + { + $objectId = $node['object_id'] ?? null; + + switch ($node['type'] ?? null) { + case 'post': + $post = is_int($objectId) ? get_post($objectId) : null; + if (! $post instanceof WP_Post || in_array($post->post_type, self::NON_LINKABLE, true)) { + return 'object_id muss ein vorhandener Eintrag sein.'; + } + if ($post->post_status !== 'publish') { + $warnings[] = sprintf( + '"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.', + $post->post_title + ); + } + return null; + case 'taxonomy': + return is_int($objectId) && get_term($objectId) instanceof WP_Term + ? null + : 'object_id muss ein vorhandener Begriff sein.'; + case 'custom': + if (! $this->validUrl($node['url'] ?? null)) { + return 'Individueller Link braucht eine gültige url (/pfad, #anker, http(s), mailto, tel).'; + } + return is_string($node['title'] ?? null) && trim($node['title']) !== '' + ? null + : 'Individueller Link braucht einen title.'; + default: + return 'type muss post, taxonomy oder custom sein.'; + } + } + + private function validUrl(mixed $url): bool + { + if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) { + return false; + } + + if (str_starts_with($url, '/') || str_starts_with($url, '#')) { + return true; + } + + $scheme = wp_parse_url($url, PHP_URL_SCHEME); + + return is_string($scheme) && in_array(strtolower($scheme), self::URL_SCHEMES, true); + } + + /** + * @param list> $nodes + * @param array $current + * @param array $seen + * @param array{created: int, updated: int, removed: int} $stats + */ + private function writeNodes( + int $menuId, + array $nodes, + int $parentId, + array $current, + int &$position, + array &$seen, + array &$stats + ): void { + foreach ($nodes as $node) { + $position++; + $existing = isset($node['id']) ? $current[(int) $node['id']] : null; + $args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node); + if (isset($node['title'])) { + $args['menu-item-title'] = (string) $node['title']; + } + if (isset($node['url'])) { + $args['menu-item-url'] = esc_url_raw((string) $node['url']); + } + $args['menu-item-parent-id'] = $parentId; + $args['menu-item-position'] = $position; + $args['menu-item-status'] = 'publish'; + + $itemId = wp_update_nav_menu_item($menuId, $existing !== null ? (int) $existing->ID : 0, $args); + if (is_wp_error($itemId)) { + throw new RuntimeException('Menu item could not be saved: ' . $itemId->get_error_message()); + } + + if ($existing !== null) { + $seen[(int) $existing->ID] = true; + $stats['updated']++; + } else { + $stats['created']++; + } + + $children = (array) ($node['children'] ?? []); + $this->writeNodes($menuId, $children, (int) $itemId, $current, $position, $seen, $stats); + } + } + + /** @return array */ + private function argsFromItem(object $item): array + { + $isCustom = (string) $item->type === 'custom'; + + return [ + 'menu-item-object-id' => (int) $item->object_id, + 'menu-item-object' => (string) $item->object, + 'menu-item-type' => (string) $item->type, + 'menu-item-title' => (string) $item->post_title, + 'menu-item-url' => $isCustom ? (string) $item->url : '', + 'menu-item-description' => (string) $item->description, + 'menu-item-attr-title' => (string) $item->attr_title, + 'menu-item-target' => (string) $item->target, + 'menu-item-classes' => implode(' ', array_filter((array) $item->classes)), + 'menu-item-xfn' => (string) $item->xfn, + ]; + } + + /** + * @param array $node + * @return array + */ + private function argsFromNode(array $node): array + { + $objectId = (int) ($node['object_id'] ?? 0); + + return match ($node['type']) { + 'post' => [ + 'menu-item-type' => 'post_type', + 'menu-item-object' => (string) get_post_type($objectId), + 'menu-item-object-id' => $objectId, + ], + 'taxonomy' => [ + 'menu-item-type' => 'taxonomy', + 'menu-item-object' => (string) get_term($objectId)->taxonomy, + 'menu-item-object-id' => $objectId, + ], + default => ['menu-item-type' => 'custom', 'menu-item-url' => ''], + }; + } +} +``` + +`src/Integrations/WordPress/Menus/MenuSetItemsAbility.php`: +```php + 'object', + 'properties' => [ + 'menu_id' => ['type' => 'integer', 'minimum' => 1], + 'items' => ['type' => 'array', 'items' => ['type' => 'object']], + 'operations' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'op' => ['type' => 'string', 'enum' => ['add', 'move', 'remove']], + 'id' => ['type' => 'integer', 'minimum' => 1], + 'item' => ['type' => 'object'], + 'parent_id' => ['type' => 'integer', 'minimum' => 0], + 'position' => ['type' => 'integer', 'minimum' => 0], + ], + 'required' => ['op'], + 'additionalProperties' => false, + ], + ], + 'confirm' => ['type' => 'boolean'], + ], + 'required' => ['menu_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + $tree = ['type' => 'array', 'items' => MenuTree::nodeSchema()]; + + return ToolResult::schema(['items' => $tree, 'previous_items' => $tree], ['items', 'previous_items']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_theme_options') + ? true + : Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).'); + } + + public function execute(array $input): mixed + { + $menuId = (int) ($input['menu_id'] ?? 0); + $menu = wp_get_nav_menu_object($menuId); + if (! is_object($menu)) { + return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); + } + + if (array_key_exists('items', $input) === array_key_exists('operations', $input)) { + return Errors::invalidInput( + 'Entweder items (Gesamtbaum) oder operations angeben – genau eines von beiden.', + ['items' => 'oder operations'] + ); + } + + $currentItems = wp_get_nav_menu_items($menuId); + $current = []; + foreach (is_array($currentItems) ? $currentItems : [] as $item) { + $current[(int) $item->ID] = $item; + } + $previous = MenuTree::build(array_values($current)); + + $tree = array_key_exists('items', $input) + ? (array) $input['items'] + : $this->operations->apply($previous, (array) $input['operations']); + if ($tree instanceof WP_Error) { + return $tree; + } + + $plan = $this->writer->plan($tree, $current); + if ($plan['errors'] !== []) { + return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']); + } + + if ($plan['removals'] !== [] && ($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired( + sprintf( + 'Folgende Menüeinträge würden entfernt: %s.', + implode(', ', array_column($plan['removals'], 'title')) + ), + ['removals' => $plan['removals']] + ); + } + + $stats = $this->writer->apply($menuId, $tree, $current); + $after = wp_get_nav_menu_items($menuId); + $summary = sprintf( + 'Menü "%s": %d neu, %d geändert, %d entfernt', + $menu->name, + $stats['created'], + $stats['updated'], + $stats['removed'] + ); + $logId = $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary); + + return ToolResult::ok($summary . '.') + ->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name) + ->warnings($plan['warnings']) + ->logId($logId) + ->with('items', MenuTree::build(is_array($after) ? $after : [])) + ->with('previous_items', $previous) + ->toArray(); + } +} +``` + +`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen +```php +use LIT\MCP\Integrations\WordPress\Menus\MenuOperations; +use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility; +use LIT\MCP\Integrations\WordPress\Menus\MenuWriter; +``` +und in `abilities()` nach `new MenuCreateAbility($this->log),` einfügen: +```php + new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log), +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS (lange Zeilen umbrechen). + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/WordPress tests/Unit/Integrations/WordPress +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: declarative and operation-based menu item editing + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 15: WordPress-Einstellungen – `SettingsWhitelist`, `lit/wp-settings-get`, `lit/wp-settings-update` + +Whitelist exakt nach Spec §6.5; Recht `manage_options`. Nach einer Änderung der Permalink-Struktur werden die Rewrite-Regeln neu erzeugt – über `$wp_rewrite->set_permalink_structure()` (setzt Option und Rewrite-Objekt gemeinsam), danach `flush_rewrite_rules()`; ein bloßes `update_option()` + Flush würde die Regeln mit der alten Struktur erzeugen. + +**Files:** +- Create: `src/Integrations/WordPress/Settings/SettingsWhitelist.php`, `src/Integrations/WordPress/Settings/WpSettingsGetAbility.php`, `src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php` +- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` +- Test: `tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php`, `tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php` + +**Interfaces:** +- Consumes: `ChangeLog`, `ToolResult`, `JsonMap`, `Errors`. +- Produces: + - `SettingsWhitelist` (final): `SETTINGS` (Schlüssel → `[Gruppe, Typ, Beschreibung]`), `BLOCKED`, `GROUPS`; `current(string $key): mixed` (`WPLANG` leer → `en_US`; Zahlenfelder als int); `validate(string $key, mixed $value, array $pending): ?string` (`null` = gültig; `$pending` = alle Werte dieses Aufrufs, für Abhängigkeiten `show_on_front`/`page_on_front`/`page_for_posts`); `normalize(string $key, mixed $value): mixed` (`WPLANG` `en_US` → `''`). + - `WpSettingsGetAbility(SettingsWhitelist $whitelist)` – `lit/wp-settings-get`, Input `{group?}`, Output `{settings: list<{key, group, type, value, description}>}`; indirekt; readonly. + - `WpSettingsUpdateAbility(SettingsWhitelist $whitelist, ChangeLog $log)` – `lit/wp-settings-update`, Input `{settings: {key: value}}`; alles oder nichts; Ergebnis-Extra `previous` (vorherige Werte); indirekt; idempotent. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php`: +```php +validate('admin_email', 'x@y.de', [])); + self::assertStringStartsWith('Nicht freigegeben', (string) $whitelist->validate('template', 'twentyten', [])); + } + + public function testStaticFrontPageNeedsAPublishedDistinctPage(): void + { + Functions\when('get_option')->justReturn(0); + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']), + 6 => new WP_Post(['ID' => 6, 'post_type' => 'page', 'post_status' => 'draft']), + default => null, + }); + $whitelist = new SettingsWhitelist(); + + self::assertNotNull($whitelist->validate('show_on_front', 'page', [])); + self::assertNull($whitelist->validate('show_on_front', 'page', ['page_on_front' => 5])); + self::assertNull($whitelist->validate('page_on_front', 5, [])); + self::assertNotNull($whitelist->validate('page_on_front', 6, [])); + self::assertNotNull($whitelist->validate('page_on_front', 5, ['page_for_posts' => 5])); + self::assertNull($whitelist->validate('page_for_posts', 0, [])); + } + + public function testFormatsLanguagesAndRanges(): void + { + Functions\when('get_available_languages')->justReturn(['de_DE']); + Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id === 33); + $whitelist = new SettingsWhitelist(); + + self::assertNull($whitelist->validate('WPLANG', 'de_DE', [])); + self::assertNull($whitelist->validate('WPLANG', 'en_US', [])); + self::assertStringContainsString('de_DE', (string) $whitelist->validate('WPLANG', 'fr_FR', [])); + self::assertNull($whitelist->validate('timezone_string', 'Europe/Berlin', [])); + self::assertNotNull($whitelist->validate('timezone_string', 'Mars/Base', [])); + self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', [])); + self::assertNull($whitelist->validate('permalink_structure', '', [])); + self::assertNotNull($whitelist->validate('permalink_structure', 'postname', [])); + self::assertNotNull($whitelist->validate('posts_per_page', 0, [])); + self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', [])); + self::assertNull($whitelist->validate('site_icon', 33, [])); + self::assertNotNull($whitelist->validate('site_icon', 34, [])); + self::assertSame('', $whitelist->normalize('WPLANG', 'en_US')); + } + + public function testCurrentNormalizesLanguageAndNumbers(): void + { + Functions\when('get_option')->alias(static fn (string $key) => ['WPLANG' => '', 'page_on_front' => '5'][$key]); + $whitelist = new SettingsWhitelist(); + + self::assertSame('en_US', $whitelist->current('WPLANG')); + self::assertSame(5, $whitelist->current('page_on_front')); + } +} +``` + +`tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php`: +```php +justReturn('Kunde'); + + $result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']); + + self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key')); + self::assertSame('discussion', $result['settings'][0]['group']); + } + + public function testUpdateIsAllOrNothing(): void + { + Functions\expect('update_option')->never(); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class))) + ->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']); + } + + public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void + { + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'blogdescription' => 'Gleich', + 'permalink_structure' => '', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu'); + $rewrite = Mockery::mock(); + $rewrite->expects('set_permalink_structure')->with('/%postname%/'); + $GLOBALS['wp_rewrite'] = $rewrite; + Functions\expect('flush_rewrite_rules')->once(); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/wp-settings-update', 'option', 'blogname,permalink_structure', Mockery::type('string'))->andReturn(30); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'blogdescription' => 'Gleich', + 'permalink_structure' => '/%postname%/', + ]]); + + self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id')); + self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']); + self::assertSame(30, $result['log_id']); + } + + public function testSettingsToolsNeedManageOptions(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options'); + + self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code()); + self::assertSame( + 'lit_forbidden', + (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))->permission([])->get_error_code() + ); + } +} +``` + +In `WordPressIntegrationTest` die Konstante um die beiden Einstellungs-Tools am Ende ergänzen: +```php + private const EXPECTED = [ + 'lit/content-types', + 'lit/content-query', + 'lit/content-get', + 'lit/content-create', + 'lit/content-update', + 'lit/post-publish', + 'lit/post-unpublish', + 'lit/post-schedule', + 'lit/post-trash', + 'lit/post-duplicate', + 'lit/post-replace-with-copy', + 'lit/revisions-list', + 'lit/revision-restore', + 'lit/menu-list', + 'lit/menu-get', + 'lit/menu-create', + 'lit/menu-set-items', + 'lit/menu-assign-location', + 'lit/wp-settings-get', + 'lit/wp-settings-update', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'Settings|WordPressIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Settings/SettingsWhitelist.php`: +```php + [group, type, description] */ + public const SETTINGS = [ + 'blogname' => ['general', 'string', 'Titel der Website'], + 'blogdescription' => ['general', 'string', 'Untertitel der Website'], + 'WPLANG' => ['general', 'string', 'Sprache (Locale, z. B. de_DE; en_US = Englisch); nur installierte Sprachen'], + 'timezone_string' => ['general', 'string', 'Zeitzone, z. B. Europe/Berlin'], + 'date_format' => ['general', 'string', 'Datumsformat (PHP), z. B. j. F Y'], + 'time_format' => ['general', 'string', 'Zeitformat (PHP), z. B. H:i'], + 'show_on_front' => ['reading', 'string', 'Startseite: posts (neueste Beiträge) oder page (statische Seite)'], + 'page_on_front' => ['reading', 'integer', 'ID der statischen Startseite (0 = keine)'], + 'page_for_posts' => ['reading', 'integer', 'ID der Beitragsseite (0 = keine)'], + 'posts_per_page' => ['reading', 'integer', 'Beiträge pro Seite (1–100)'], + 'default_comment_status' => ['discussion', 'string', 'Kommentare für neue Beiträge: open oder closed'], + 'default_ping_status' => ['discussion', 'string', 'Pingbacks für neue Beiträge: open oder closed'], + 'permalink_structure' => ['permalinks', 'string', 'Permalink-Struktur, z. B. /%postname%/ (leer = einfach)'], + 'site_icon' => ['site_icon', 'integer', 'Anhang-ID des Website-Icons (Bild, empfohlen 512×512; 0 = keins)'], + ]; + + public const BLOCKED = ['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home']; + public const GROUPS = ['general', 'reading', 'discussion', 'permalinks', 'site_icon']; + + private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon']; + + public function current(string $key): mixed + { + $value = get_option($key); + if ($key === 'WPLANG') { + return $value === '' || $value === false ? 'en_US' : (string) $value; + } + + return in_array($key, self::INTEGER_KEYS, true) ? (int) $value : (string) $value; + } + + /** + * @param array $pending All values of the current call. + * @return string|null Error message, null when valid. + */ + public function validate(string $key, mixed $value, array $pending): ?string + { + if (in_array($key, self::BLOCKED, true)) { + return 'Gesperrt: aus Sicherheitsgründen nicht über LIT MCP änderbar.'; + } + + if (! isset(self::SETTINGS[$key])) { + return 'Nicht freigegeben; erlaubt: ' . implode(', ', array_keys(self::SETTINGS)) . '.'; + } + + return match ($key) { + 'blogname', 'blogdescription' => is_string($value) && mb_strlen($value) <= 200 + ? null + : 'Text bis 200 Zeichen erwartet.', + 'WPLANG' => $this->checkLanguage($value), + 'timezone_string' => is_string($value) && in_array($value, timezone_identifiers_list(), true) + ? null + : 'Zeitzonen-Kennung wie Europe/Berlin erwartet.', + 'date_format', 'time_format' => is_string($value) && trim($value) !== '' && mb_strlen($value) <= 50 + ? null + : 'Nicht leeres Format bis 50 Zeichen erwartet.', + 'show_on_front' => $this->checkShowOnFront($value, $pending), + 'page_on_front', 'page_for_posts' => $this->checkPage($key, $value, $pending), + 'posts_per_page' => is_int($value) && $value >= 1 && $value <= 100 + ? null + : 'Ganzzahl von 1 bis 100 erwartet.', + 'default_comment_status', 'default_ping_status' => in_array($value, ['open', 'closed'], true) + ? null + : 'open oder closed erwartet.', + 'permalink_structure' => $this->checkPermalink($value), + 'site_icon' => $this->checkSiteIcon($value), + }; + } + + public function normalize(string $key, mixed $value): mixed + { + return $key === 'WPLANG' && $value === 'en_US' ? '' : $value; + } + + private function checkLanguage(mixed $value): ?string + { + if ($value === 'en_US' || $value === '') { + return null; + } + + $installed = get_available_languages(); + + return is_string($value) && in_array($value, $installed, true) + ? null + : sprintf('Sprache nicht installiert; verfügbar: en_US, %s.', implode(', ', $installed)); + } + + /** @param array $pending */ + private function checkShowOnFront(mixed $value, array $pending): ?string + { + if ($value === 'posts') { + return null; + } + + if ($value !== 'page') { + return 'posts oder page erwartet.'; + } + + $front = (int) ($pending['page_on_front'] ?? get_option('page_on_front')); + + return $front > 0 ? null : 'Für page zuerst page_on_front setzen (im selben Aufruf möglich).'; + } + + /** @param array $pending */ + private function checkPage(string $key, mixed $value, array $pending): ?string + { + if (! is_int($value) || $value < 0) { + return 'Seiten-ID (0 = keine) erwartet.'; + } + + if ($value === 0) { + return null; + } + + $page = get_post($value); + if (! $page instanceof WP_Post || $page->post_type !== 'page' || $page->post_status !== 'publish') { + return sprintf('%d ist keine veröffentlichte Seite.', $value); + } + + $other = $key === 'page_on_front' ? 'page_for_posts' : 'page_on_front'; + $otherValue = (int) ($pending[$other] ?? get_option($other)); + + return $otherValue === $value ? 'Startseite und Beitragsseite müssen verschiedene Seiten sein.' : null; + } + + private function checkPermalink(mixed $value): ?string + { + if ($value === '') { + return null; + } + + return is_string($value) + && str_starts_with($value, '/') + && str_contains($value, '%') + && mb_strlen($value) <= 200 + && preg_match('/\s/', $value) !== 1 + ? null + : 'Leer oder eine Struktur wie /%postname%/ erwartet.'; + } + + private function checkSiteIcon(mixed $value): ?string + { + if (! is_int($value) || $value < 0) { + return 'Anhang-ID (0 = keins) erwartet.'; + } + + return $value === 0 || wp_attachment_is_image($value) + ? null + : sprintf('%d ist kein Bild der Mediathek.', $value); + } +} +``` + +`src/Integrations/WordPress/Settings/WpSettingsGetAbility.php`: +```php + 'object', + 'properties' => ['group' => ['type' => 'string', 'enum' => SettingsWhitelist::GROUPS]], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'settings' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'key' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'value' => ['type' => ['string', 'integer']], + 'description' => ['type' => 'string'], + ], + 'required' => ['key', 'group', 'type', 'value', 'description'], + ], + ], + ], + 'required' => ['settings'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('manage_options') + ? true + : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); + } + + public function execute(array $input): mixed + { + $group = isset($input['group']) ? (string) $input['group'] : ''; + $settings = []; + foreach (SettingsWhitelist::SETTINGS as $key => [$settingGroup, $type, $description]) { + if ($group !== '' && $settingGroup !== $group) { + continue; + } + + $settings[] = [ + 'key' => $key, + 'group' => $settingGroup, + 'type' => $type, + 'value' => $this->whitelist->current($key), + 'description' => $description, + ]; + } + + return ['settings' => $settings]; + } +} +``` + +`src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php`: +```php + 'object', + 'properties' => [ + 'settings' => [ + 'type' => 'object', + 'minProperties' => 1, + 'description' => 'Schlüssel → neuer Wert, z. B. {"blogname": "Praxis Muster"}.', + ], + ], + 'required' => ['settings'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['previous' => ['type' => 'object']], ['previous']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('manage_options') + ? true + : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); + } + + public function execute(array $input): mixed + { + $settings = is_array($input['settings'] ?? null) ? $input['settings'] : []; + if ($settings === []) { + return Errors::invalidInput('Keine Einstellungen angegeben.', ['settings' => 'mindestens ein Schlüssel']); + } + + $errors = []; + foreach ($settings as $key => $value) { + $error = $this->whitelist->validate((string) $key, $value, $settings); + if ($error !== null) { + $errors[(string) $key] = $error; + } + } + + if ($errors !== []) { + return Errors::invalidInput( + 'Einstellungen ungültig; nichts wurde gespeichert.', + $errors, + 'Werte gemäß fields korrigieren; aktuelle Werte über lit-wp-settings-get.' + ); + } + + $previous = []; + $changed = []; + foreach ($settings as $key => $value) { + $key = (string) $key; + $previous[$key] = $this->whitelist->current($key); + if ($previous[$key] === $value) { + continue; + } + + if ($key === 'permalink_structure') { + $this->setPermalinkStructure((string) $value); + } else { + update_option($key, $this->whitelist->normalize($key, $value)); + } + $changed[] = $key; + } + + if ($changed === []) { + return ToolResult::ok('Keine Änderung – alle Werte waren bereits gesetzt.') + ->with('previous', JsonMap::of($previous)) + ->toArray(); + } + + $summary = 'Einstellungen geändert: ' . implode(', ', $changed); + $logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary); + + $result = ToolResult::ok($summary . '.'); + foreach ($changed as $key) { + $result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]); + } + + return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray(); + } + + private function setPermalinkStructure(string $structure): void + { + global $wp_rewrite; + $wp_rewrite->set_permalink_structure($structure); + flush_rewrite_rules(); + } +} +``` + +`src/Integrations/WordPress/WordPressIntegration.php`: Imports ergänzen +```php +use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist; +use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility; +use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility; +``` +und `abilities()` durch die vollständige Endfassung ersetzen: +```php + public function abilities(): array + { + $presenter = new PostPresenter($this->schema); + $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner()); + $cloner = new PostCloner(); + $whitelist = new SettingsWhitelist(); + + return [ + new ContentTypesAbility($this->schema), + new ContentQueryAbility($this->schema, $presenter), + new ContentGetAbility($this->schema, $presenter), + new ContentCreateAbility($this->schema, $writer, $this->log), + new ContentUpdateAbility($this->schema, $writer, $this->log), + new PostPublishAbility($this->schema, $this->log), + new PostUnpublishAbility($this->schema, $this->log), + new PostScheduleAbility($this->schema, $this->log), + new PostTrashAbility($this->schema, $this->log), + new PostDuplicateAbility($this->schema, $cloner, $this->log), + new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log), + new RevisionsListAbility($this->schema), + new RevisionRestoreAbility($this->schema, $this->log), + new MenuListAbility(), + new MenuGetAbility(), + new MenuCreateAbility($this->log), + new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log), + new MenuAssignLocationAbility($this->log), + new WpSettingsGetAbility($whitelist), + new WpSettingsUpdateAbility($whitelist, $this->log), + ]; + } +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/WordPress tests/Unit/Integrations/WordPress +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: whitelisted WordPress settings tools + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 16: Anleitungen und Ende-zu-Ende-Test über HTTP + +Die Anleitungen `content-types` und `workflows` (Spec §9.2) beschreiben die neuen Tools für Claude; der Ende-zu-Ende-Test ruft jede Ability dieses Plans über den echten OAuth-Endpunkt auf. Da WordPress jede Ausgabe gegen das `outputSchema` prüft, ist das zugleich der automatische Output-Schema-Test aus Spec §11.4. + +**Files:** +- Create: `docs/guides/content-types.md`, `docs/guides/workflows.md`, `tests/Integration/lib.sh`, `tests/Integration/wp-basis-smoke.sh` +- Modify: `docs/guides/overview.md` (Abschnitt „Verfügbare Werkzeuge“ ersetzen) + +**Interfaces:** +- Consumes: alle Abilities der Tasks 6–15; `tests/Integration/mint-token.php`, `tests/Integration/pods-fixture.php`. +- Produces: `tests/Integration/lib.sh` mit `fail`, `pass`, `cleanup_app_passwords_for `, `connect ` (setzt `TOKEN`, `SESSION`, `INIT_RESPONSE`), `rpc `, `tool ` (gibt `structuredContent` oder `{"__error": ""}` aus), `ability ` (über `mcp-adapter-execute-ability`; gibt das Ergebnis oder `{"__error": …}` aus), `error_code ` (liefert z. B. `rev_mismatch`) – Etappe 1b nutzt dieselben Helfer. + +- [ ] **Step 1: Anleitungen** + +`docs/guides/content-types.md`: +```markdown +# Inhalte, Felder und Status + +## Inhaltstypen erkennen + +- `lit-content-types` ohne Parameter listet alle Inhaltstypen (Seiten, Beiträge, eigene Typen) mit + Taxonomien, Anzahl und ob Divi dafür aktiv ist. +- `lit-content-types` mit `post_type` liefert zusätzlich alle Felder: Schlüssel, Typ, Pflicht, Auswahlwerte + (`choices`), Beziehungsziel (`related`) und Schreibstatus: + - `writable` – sicher beschreibbar (Pods-Feld oder registriertes Meta-Feld), + - `readonly` – nicht beschreibbar, + - `unknown` – vorhandener Meta-Schlüssel ohne bekannten Typ; Schreiben geht, der Wert wird unverändert + gespeichert (Warnung in der Antwort). + +## Lesen und Suchen + +- `lit-content-query`: Filter `status`, `search`, `taxonomy` (`{taxonomy, terms: [IDs oder Slugs]}`), + `field` (`{key, value, compare}`), `date_after`/`date_before` (JJJJ-MM-TT), `parent`; Blättern mit + `next_cursor` → `cursor`. +- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. + Bei Divi-Seiten fehlt `content`; `hints` verweist auf `lit-divi-outline`. + +## Anlegen und Ändern + +- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler + bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). +- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` + mitgeben – hat jemand parallel gespeichert, kommt `rev_mismatch`: neu lesen, Änderung wiederholen. +- Begriffe: `terms: {"category": [4, "Neuer Begriff"]}` ersetzt die Zuordnung dieser Taxonomie (IDs oder + Namen; unbekannte Namen werden angelegt, falls erlaubt). +- Felder leeren: Wert `null`. Pflichtfelder können nicht geleert werden. +- Beziehungsfelder erwarten Eintrags-IDs, Datei-/Bildfelder Anhang-IDs aus der Mediathek. + +## Status + +- Veröffentlichen, Zurückziehen und Planen sind eigene Werkzeuge (`lit-post-publish`, `lit-post-unpublish`, + `lit-post-schedule` mit `date` = „JJJJ-MM-TT HH:MM“ in der Zeitzone der Website). Nur auf ausdrücklichen + Wunsch des Nutzers verwenden. +- `lit-post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage. + +## Sicher überarbeiten + +1. `lit-post-duplicate` → Entwurfskopie. +2. Kopie mit `lit-content-update` (oder den Divi-Tools) bearbeiten und dem Nutzer die Vorschau zeigen. +3. `lit-post-replace-with-copy` mit `confirm: true` übernimmt die Kopie ins Original (ID, URL, Status und + Menüeinträge bleiben; vorher entsteht eine Revision). + +Jede Änderung liefert `revision_id` (Stand vorher) und `log_id`. Rückgängig: `lit-revisions-list` → +`lit-revision-restore` mit `confirm: true`. Revisionen umfassen Titel, Inhalt und Auszug – Felder und +Begriffe nicht. +``` + +`docs/guides/workflows.md`: +```markdown +# Abläufe + +## Neue Website aufsetzen + +1. `lit-site-status` und `lit-content-types` lesen. +2. Grundeinstellungen: `lit-wp-settings-get`, dann `lit-wp-settings-update` (Titel, Untertitel, Sprache, + Zeitzone, Datumsformat, Permalinks `/%postname%/`). `previous` in der Antwort enthält die alten Werte. +3. Seiten als Entwürfe anlegen (`lit-content-create`), Hierarchie über `parent`. +4. Startseite: Seite veröffentlichen (nach Rückfrage), dann + `{"settings": {"page_on_front": , "show_on_front": "page"}}`. +5. Menü: `lit-menu-create` (optional mit `location` aus `lit-menu-list`), Einträge mit `lit-menu-set-items`. +6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. + +## Menüs pflegen + +- `lit-menu-get` liefert den Baum. Für größere Umbauten den Baum verändern und vollständig als `items` an + `lit-menu-set-items` senden (bestehende Einträge mit `id`, neue ohne `id`). +- Für kleine Änderungen `operations`: `{op: "add", item, parent_id?, position?}`, + `{op: "move", id, parent_id?, position?}`, `{op: "remove", id}`. +- Fallen Einträge weg, verlangt das Tool `confirm: true`; `previous_items` erlaubt das Zurückbauen. + +## Einträge eines eigenen Inhaltstyps (Pods) + +1. `lit-content-types` mit `post_type` → Pflichtfelder, Auswahlwerte, Beziehungsziele. +2. `lit-content-create` mit allen Pflichtfeldern in `fields`. +3. Beziehungen über IDs (vorher mit `lit-content-query` suchen). +``` + +`docs/guides/overview.md`: den Abschnitt ab `## Verfügbare Werkzeuge (Etappe 0)` bis zum Dateiende ersetzen durch: +```markdown +## Werkzeuge + +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`. + +Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`): +Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, +`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs +(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), +Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`). + +Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`. + +## Rückmeldungen + +- Erfolg: `ok`, `summary`, `changed`, `warnings`, `revision_id` (Stand vorher), `log_id`, URLs. +- Fehler: JSON mit `code`, `message`, `hint` (und ggf. `fields`). Dem `hint` folgen. +- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen. +``` + +- [ ] **Step 2: Gemeinsame Shell-Helfer** + +`tests/Integration/lib.sh`: +```bash +#!/usr/bin/env bash +# Shared helpers for the MCP integration scripts. Source it from a script that +# runs INSIDE the DDEV web container with /var/www/html as working directory. +# Requires jq and curl (both present in the DDEV web image). + +PLUGIN=wp-content/plugins/WP-LIT-MCP +ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server +TOKEN="" +SESSION="" +INIT_RESPONSE="" +REQUEST_ID=100 + +fail() { echo "FAIL: $*"; exit 1; } +pass() { echo "ok - $*"; } + +# Removes every "lit-mcp-integration-test" application password of a user. +cleanup_app_passwords_for() { # $1 = user_login + local login="$1" + [ -n "$login" ] || return 0 + local escaped=${login//\\/\\\\} + escaped=${escaped//\'/\\\'} + wp eval " +\$u = get_user_by('login', '${escaped}'); +if (\$u) { + foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) { + if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') { + WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']); + } + } +} +" >/dev/null 2>&1 || true +} + +# Mints a token for the user and opens an MCP session. Sets TOKEN, SESSION and +# INIT_RESPONSE (raw initialize response incl. headers). +connect() { # $1 = user_login + TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$1") + local init + init=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') + SESSION=$(echo "$init" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) + INIT_RESPONSE="$init" + echo "$init" | grep -q '"protocolVersion"' || fail "initialize failed: $init" +} + +# Raw JSON-RPC call; prints the response body. +rpc() { # $1 = method, $2 = params JSON + REQUEST_ID=$((REQUEST_ID + 1)) + local body + body=$(jq -cn --arg m "$1" --argjson p "$2" --argjson id "$REQUEST_ID" '{jsonrpc: "2.0", id: $id, method: $m, params: $p}') + local session_header=() + [ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION") + curl -s --max-time 60 -X POST "$ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + "${session_header[@]}" -d "$body" +} + +# Calls a tool; prints structuredContent, or {"__error": ""} for isError results. +tool() { # $1 = tool name, $2 = arguments JSON + local response + response=$(rpc tools/call "$(jq -cn --arg n "$1" --argjson a "$2" '{name: $n, arguments: $a}')") + echo "$response" | jq -e '.result' >/dev/null 2>&1 || fail "$1: protocol error: $response" + if [ "$(echo "$response" | jq -r '.result.isError')" = "true" ]; then + echo "$response" | jq -c '{__error: .result.content[0].text}' + else + echo "$response" | jq -c '.result.structuredContent' + fi +} + +# Runs an indirect ability through mcp-adapter-execute-ability; prints its result or {"__error": …}. +ability() { # $1 = ability name (lit/…), $2 = parameters JSON + local out + out=$(tool mcp-adapter-execute-ability "$(jq -cn --arg n "$1" --argjson p "$2" '{ability_name: $n, parameters: $p}')") + if echo "$out" | jq -e 'has("__error")' >/dev/null; then + echo "$out" + elif [ "$(echo "$out" | jq -r '.success')" = "true" ]; then + echo "$out" | jq -c '.data' + else + echo "$out" | jq -c '{__error: .error}' + fi +} + +# Prints the lit error code (e.g. rev_mismatch) of a tool()/ability() result, or "". +error_code() { # $1 = result JSON + echo "$1" | jq -r '(.__error // "") | (try fromjson catch {}) | (.code // "")' +} +``` + +- [ ] **Step 3: Ende-zu-Ende-Skript** + +`tests/Integration/wp-basis-smoke.sh`: +```bash +#!/usr/bin/env bash +# End-to-end checks of the Etappe-1a tools (content, status, copies, revisions, +# menus, WordPress settings, change log) through the OAuth-protected endpoint. +# Runs INSIDE the DDEV web container: +# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh +# Temporarily activates the installed Pods plugin (restored afterwards) and +# restores every setting/menu location it changes. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +PODS_WAS_ACTIVE=no +wp plugin is-active pods && PODS_WAS_ACTIVE=yes +ORIGINAL_DESCRIPTION=$(wp option get blogdescription) +ORIGINAL_LOCATIONS=$(wp eval 'echo wp_json_encode(get_nav_menu_locations());') +CREATED_POSTS=() +MENU_ID="" + +cleanup() { + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + [ -n "$MENU_ID" ] && wp menu delete "$MENU_ID" >/dev/null 2>&1 || true + wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 || true + LIT_LOCATIONS="$ORIGINAL_LOCATIONS" wp eval \ + 'set_theme_mod("nav_menu_locations", (array) json_decode(getenv("LIT_LOCATIONS"), true));' >/dev/null 2>&1 || true + wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" delete >/dev/null 2>&1 || true + [ "$PODS_WAS_ACTIVE" = "no" ] && wp plugin deactivate pods >/dev/null 2>&1 || true + cleanup_app_passwords_for "$ADMIN_LOGIN" +} +trap cleanup EXIT + +wp plugin activate pods >/dev/null +wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" create >/dev/null +connect "$ADMIN_LOGIN" + +# 1. Tool list: direct tools visible with annotations, the rest only via meta-tools +LIST=$(rpc tools/list '{}') +for t in lit-content-types lit-content-query lit-content-get lit-content-create lit-content-update lit-post-publish; do + echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t" +done +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-post-trash")' >/dev/null \ + && fail "lit-post-trash must only be reachable via execute-ability" +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-content-get") | .annotations.readOnlyHint == true' >/dev/null \ + || fail "lit-content-get lacks readOnlyHint" +pass "tools/list: direct content tools with annotations, indirect status tools" + +# 2. Schema detection incl. Pods +TYPES=$(tool lit-content-types '{}') +echo "$TYPES" | jq -e '.post_types[] | select(.name == "page")' >/dev/null || fail "content-types lacks page: $TYPES" +PERSON_TYPE=$(tool lit-content-types '{"post_type":"lit_person"}') +echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "role" and .required == true and .source == "pods")' >/dev/null \ + || fail "Pods field role missing: $PERSON_TYPE" +echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "level" and (.choices | length) == 2)' >/dev/null \ + || fail "Pods choices missing: $PERSON_TYPE" +pass "content-types lists post types and Pods fields" + +# 3. Create, read, update with rev lock +CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Seite","content":"

Hallo

"}') +PAGE_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty') +[ -n "$PAGE_ID" ] || fail "content-create: $CREATED" +CREATED_POSTS+=("$PAGE_ID") +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "new page is not a draft" +REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') +STALE=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"000000000000\",\"title\":\"x\"}") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale rev accepted: $STALE" +UPDATED=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"$REV\",\"content\":\"

Neu

\"}") +echo "$UPDATED" | jq -e '.ok == true and .log_id != null' >/dev/null || fail "content-update: $UPDATED" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "update changed the status" +pass "content create/get/update, rev lock, status unchanged" + +# 4. Pods entries: required field enforced, values written through Pods +MISSING=$(tool lit-content-create '{"post_type":"lit_person","title":"Ohne Funktion"}') +[ "$(error_code "$MISSING")" = "validation_failed" ] || fail "missing required Pods field accepted: $MISSING" +PERSON=$(tool lit-content-create '{"post_type":"lit_person","title":"Anna","fields":{"role":"Leitung","level":"senior"}}') +PERSON_ID=$(echo "$PERSON" | jq -r '.changed[0].id // empty') +[ -n "$PERSON_ID" ] || fail "Pods entry: $PERSON" +CREATED_POSTS+=("$PERSON_ID") +tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -e '.fields.role == "Leitung" and .fields.level == "senior"' >/dev/null \ + || fail "Pods values not readable" +pass "Pods entry with validated fields" + +# 5. Status tools +tool lit-post-publish "{\"post_id\":$PAGE_ID}" | jq -e '.changed[0].action == "published"' >/dev/null || fail "publish" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "page not published" +ability lit/post-unpublish "{\"post_id\":$PAGE_ID}" | jq -e '.ok == true' >/dev/null || fail "unpublish" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "page not back to draft" +FUTURE=$(date -d '+2 days' '+%Y-%m-%d 10:00') +ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"$FUTURE\"}" | jq -e '.ok == true' >/dev/null || fail "schedule" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "future" ] || fail "page not scheduled" +PAST=$(ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"2020-01-01 10:00\"}") +[ "$(error_code "$PAST")" = "validation_failed" ] || fail "past schedule accepted: $PAST" +tool lit-post-publish "{\"post_id\":$PAGE_ID}" >/dev/null +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "scheduled page did not publish now" +pass "publish/unpublish/schedule, past dates rejected" + +# 6. Copy and replace +COPY=$(ability lit/post-duplicate "{\"post_id\":$PAGE_ID}") +COPY_ID=$(echo "$COPY" | jq -r '.changed[0].id // empty') +[ -n "$COPY_ID" ] || fail "duplicate: $COPY" +CREATED_POSTS+=("$COPY_ID") +tool lit-content-update "{\"post_id\":$COPY_ID,\"content\":\"

Aus Kopie

\"}" >/dev/null +NOCONFIRM=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID}") +[ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "replace without confirm: $NOCONFIRM" +ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"confirm\":true}" \ + | jq -e '.revision_id != null' >/dev/null || fail "replace-with-copy" +[ "$(wp post get "$PAGE_ID" --field=post_content)" = "

Aus Kopie

" ] || fail "original not replaced" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "replace changed the original's status" +[ "$(wp post get "$COPY_ID" --field=post_status)" = "trash" ] || fail "copy not trashed" +pass "duplicate and replace-with-copy (status kept, copy trashed)" + +# 7. Revisions +REVISIONS=$(ability lit/revisions-list "{\"post_id\":$PAGE_ID}") +OLDEST=$(echo "$REVISIONS" | jq -r '.items[-1].id // empty') +[ -n "$OLDEST" ] || fail "revisions-list: $REVISIONS" +ability lit/revision-restore "{\"revision_id\":$OLDEST,\"confirm\":true}" | jq -e '.ok == true' >/dev/null \ + || fail "revision-restore" +pass "revisions list and restore" + +# 8. Trash +ability lit/post-trash "{\"post_id\":$PERSON_ID,\"confirm\":true}" | jq -e '.changed[0].action == "trashed"' >/dev/null \ + || fail "post-trash" +[ "$(wp post get "$PERSON_ID" --field=post_status)" = "trash" ] || fail "entry not in trash" +pass "post-trash moves to trash" + +# 9. Menus +MENU=$(ability lit/menu-create '{"name":"LIT Smoke Menü"}') +MENU_ID=$(echo "$MENU" | jq -r '.menu_id // empty') +[ -n "$MENU_ID" ] || fail "menu-create: $MENU" +ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"items\":[{\"type\":\"post\",\"object_id\":$PAGE_ID,\"children\":[{\"type\":\"custom\",\"url\":\"/kontakt\",\"title\":\"Kontakt\"}]}]}" \ + | jq -e '.ok == true' >/dev/null || fail "menu-set-items (tree)" +TREE=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}") +CHILD_ID=$(echo "$TREE" | jq -r '.items[0].children[0].id // empty') +[ "$(echo "$TREE" | jq -r '.items[0].children[0].title')" = "Kontakt" ] || fail "menu tree: $TREE" +REMOVE=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}]}") +[ "$(error_code "$REMOVE")" = "confirm_required" ] || fail "menu removal without confirm: $REMOVE" +ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}],\"confirm\":true}" \ + | jq -e '(.previous_items[0].children | length) == 1 and (.items[0].children | length) == 0' >/dev/null \ + || fail "menu removal with confirm" +LOCATION=$(ability lit/menu-list '{}' | jq -r '.locations[0].location // empty') +if [ -n "$LOCATION" ]; then + ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":$MENU_ID}" | jq -e '.ok == true' >/dev/null \ + || fail "menu-assign-location" +fi +pass "menu create, tree, operations with confirm, location" + +# 10. WordPress settings (whitelist) +ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname")' >/dev/null \ + || fail "wp-settings-get" +BLOCKED=$(ability lit/wp-settings-update '{"settings":{"admin_email":"x@example.invalid"}}') +[ "$(error_code "$BLOCKED")" = "validation_failed" ] || fail "blocked setting accepted: $BLOCKED" +ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT Smoke Test"}}' \ + | jq -e '.previous.blogdescription != null' >/dev/null || fail "wp-settings-update" +[ "$(wp option get blogdescription)" = "LIT Smoke Test" ] || fail "blogdescription not updated" +pass "WordPress settings whitelist" + +# 11. Change log with client name +PREFIX=$(wp db prefix) +ROWS=$(wp db query "SELECT COUNT(*) FROM ${PREFIX}lit_mcp_log WHERE ability = 'lit/content-create' AND client = 'lit-mcp-integration-test' AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)" --skip-column-names) +[ "$ROWS" -ge 2 ] || fail "change log rows missing (found $ROWS)" +pass "change log records ability and client" + +echo "ALL CHECKS PASSED" +``` + +- [ ] **Step 4: Ende-zu-Ende-Test ausführen** + +Run: +```bash +cd /home/janniclas/Projekte/WordPress +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh +timeout 120 ddev wp plugin is-active pods || echo "pods inactive (restored)" +``` +Expected: beide Skripte `ALL CHECKS PASSED`; Pods danach wieder inaktiv. Schlägt ein Schritt mit `ability_invalid_output` fehl, passt die Ausgabe nicht zum `outputSchema` – Schema oder Ausgabe korrigieren und einen Unit-Test für den Fall ergänzen. + +- [ ] **Step 5: Unit-Tests und Lint** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add docs/guides tests/Integration/lib.sh tests/Integration/wp-basis-smoke.sh +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: end-to-end checks for the WordPress basics; content and workflow guides + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +## Self-Review (durchgeführt beim Schreiben) + +- **Spec-Abdeckung (Teil 1a):** §6.1 Schema inkl. Pods → Tasks 4–6; §6.2 Content-Tools, Status, Kopie/Austausch, Papierkorb, Revisionen, `lit_mcp_after_save` → Tasks 8–12; §6.4 Menüs → Tasks 13–14; §6.5 Einstellungen → Task 15; §8.1 ToolResult → Task 1; §8.2 Fehler mit hint, alles oder nichts, Stapel → Tasks 1, 7, 9; §8.3 Protokolltabelle, Aufbewahrung, Cron → Task 3 (Ansicht/Leeren in 1b); §9.1 Instructions mit Inhaltstypen → Task 6; §9.2 Guides `content-types`, `workflows` → Task 16; §11.3 Schemas, Annotations, Cursor, deterministische Tool-Reihenfolge → alle Ability-Tasks, `WordPressIntegrationTest`; §11.4 Output-Schema-Prüfung je Ability → Task 16 (WordPress validiert jede Ausgabe). Etappe-0-Rückstände: (e) Flag-Reset → Task 2, (f) `Tested up to` → Task 1. Medien §6.3, Einstellungsseite §8.4, Expositions-Policy §4.3, Sichtbarkeit, Hinweise, Aufräumen, Doku → Plan 1b. +- **Bewusste Abweichungen/Präzisierungen:** Divi-Kurzform in `lit-content-create` (§6.2) kommt mit Etappe 2 (BlockTree fehlt noch); bis dahin nur Hinweis. `rev` ist für Nicht-Divi-Inhalte optional und nur in `lit-content-update` geprüft. Fehler-JSON in der Nachricht, weil Adapter 0.5 nur den Text weitergibt. Menüeinträge werden wie im WordPress-Menü-Editor endgültig entfernt (kein Papierkorb für `nav_menu_item`), aber nur mit `confirm: true` und mit `previous_items`. +- **Typ-Konsistenz geprüft:** `PostGuard::editable/postType/id`, `RevisionPoint::capture/latest`, `ChangeLog::record(…, ?int $revisionId)`, `ToolResult::with()`-Extras (`original_id`, `menu_id`, `previous_menu_id`, `items`, `previous_items`, `previous`) stimmen zwischen Interfaces-Blöcken, Code und Tests überein. diff --git a/docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md b/docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md new file mode 100644 index 0000000..566aae6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md @@ -0,0 +1,6082 @@ +# Etappe 1b – WordPress-Basis: Medien, Einstellungsseite, Exposition, Aufräumen – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Claude verwaltet die Mediathek (suchen, ansehen inkl. Vorschaubild, Upload per URL und Datei, Download, Metadaten im Stapel, Löschen mit Verwendungsprüfung); Administratoren steuern LIT MCP über „Einstellungen → LIT MCP“ (Verbindung, Zugriff inkl. Widerruf, Tools direkt/indirekt/deaktiviert, Protokoll, Limits); nicht freigegebene Benutzer sehen keine Tools; Deaktivieren/Löschen räumt die eigenen Daten auf. + +**Architecture:** Neue Integration `MediaIntegration` mit `MediaUsageFinder` (Beitragsbilder, Inhalte inkl. Divi-Attribute, Datei-/Bildfelder, Website-Icon) und `MediaImporter` (SSRF-geschützter Download, MIME-Whitelist, Größenlimit). `ExposurePolicy` ersetzt `Ability::isDirect()` als Entscheidung (bleibt Standard) für `AbilityRegistrar` und `ServerRegistrar`. `VisibilityFilter` nutzt die Adapter-Filter `mcp_adapter_tools_list` und `mcp_adapter_initialize_response`. Die Einstellungsseite besteht aus einer `SettingsPage` mit `AdminTab`-Implementierungen (Settings API, `WP_List_Table`, kein JS-Framework). `uninstall.php` und `Lifecycle::deactivate()` räumen nur eigene Daten auf. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API, Settings API, `WP_List_Table`, Site Health), `wordpress/mcp-adapter` 0.5.0, `wp-media/mcp-oauth` 1.1.2, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV. + +**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4.3, §4.5, §6.3, §8.1–8.4, §10, §11.3, §12 Etappe 1) + +**Voraussetzung:** Plan `docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md` ist vollständig umgesetzt (u. a. `Errors`, `ToolResult`, `Cursor`, `JsonMap`, `ChangeLog`, `Limits`, `Lifecycle`, `ContentSchema`, `PostGuard`, `WordPressIntegration`, `tests/Integration/lib.sh`, Stubs `WP_Post`, `WP_Term`, `wpdb`). + +## Global Constraints + +- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Keine neuen Plugins installieren; Pods darf für Integrationstests aktiviert und muss danach wieder deaktiviert werden. +- PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3), Plugin-Header und `readme.txt` mit `Tested up to: 7.0`. +- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` nach jedem Task sauber. +- Unit-Tests über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); die dort per `Functions\stubs()` vorbelegten Funktionen (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Admin-Ausgaben mit `Functions\stubEscapeFunctions()` und `Functions\stubTranslationFunctions()` testen. +- Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. +- Ability-Namen `lit/-`; Annotations exakt (`readonly` für Lese-Tools, `destructive` nur für `lit/media-delete`); Input-/Output-Schemas als JSON Schema 2020-12 im von `rest_validate_value_from_schema()` geprüften Umfang; Tools mit Bild- oder Ressourcen-Ergebnis (`lit/media-preview`, `lit/media-download`) haben **kein** `outputSchema`. +- Fachliche Fehler nur über `Errors` (`lit_*`, JSON mit `hint`); Schreib-Tools liefern `ToolResult` und schreiben einen Protokolleintrag; Stapel-Tools melden das Ergebnis je Eintrag. +- Medien: Import nur über `download_url()` (nutzt `wp_safe_remote_get`, SSRF-Schutz) nach Vorprüfung mit `wp_http_validate_url()`; MIME-Whitelist (Filter `lit_mcp_upload_mimes`); Größenlimit aus `Limits` (Standard 5 MB); `lit/media-delete` nur mit `confirm: true` und bei Verwendung nur mit `force: true`. +- Einstellungsseite „Einstellungen → LIT MCP“ (`options-general.php?page=lit-mcp`), Tabs Verbindung, Zugriff, Tools, Protokoll, Limits; Settings API, `WP_List_Table`, kein JS-Framework; alle Formulare mit Nonce und `manage_options`. Für das Divi-Code-Modul gibt es keine Einstellung. +- Aufräumen: nur eigene Daten (Optionen `lit_mcp_*`, Tabelle `{prefix}lit_mcp_log`, Cron `lit_mcp_log_cleanup`, Transient `lit_mcp_endpoint_check`); Daten von mcp-oauth (`mcp_jwt_secret`, `wpmedia_mcp_oauth_rewrite_version`, Benutzer-Meta `mcp_refresh_jti_*`, Anwendungspasswörter) bleiben unangetastet. +- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) ` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`. + +## Review Focus + +1. **Medium wird noch verwendet** (Beitragsbild, Divi-Bildmodul `"id":""`, `wp-image-`, Bildgröße im Inhalt, Pods-Bildfeld, Website-Icon): `lit-media-delete` mit `confirm` aber ohne `force` → `lit_in_use` mit Verwendungsliste, nichts gelöscht → Tests in Task 1 (Finder) und Task 5. +2. **Import-Missbrauch:** URL auf `127.0.0.1`/interne Hosts, Datei über dem Limit, `.php` als `.png` getarnt, ungültiges Base64 → abgelehnt, temporäre Datei gelöscht, kein Anhang → Tests in Task 4. +3. **Unsinnige Tool-Einstellung:** `lit/media-preview` auf „indirekt“ (Bildergebnisse gehen nur direkt) oder `lit/guide` auf „deaktiviert“ → Policy fällt auf den Standard zurück; kaputte Optionswerte werden ignoriert → Test in Task 6. +4. **Benutzer ohne `lit_mcp_use` mit gültigem Token:** `tools/list` leer, Instructions ohne Website-Details; Listen fremder MCP-Server unverändert → Tests in Task 7. +5. **Löschen des Plugins:** eigene Optionen, Tabelle, Cron und Transient weg (auf Multisite je Website), mcp-oauth-Daten bleiben → Test in Task 12. + +--- + +## File Structure + +``` +wp-lit-mcp.php (geändert) Version 0.2.0 +readme.txt WordPress-Readme: Auswirkungen auf die Website, Deinstallation +uninstall.php entfernt eigene Daten +phpcs.xml.dist (geändert) uninstall.php prüfen +src/ + Plugin.php (geändert) Media, Policy, Sichtbarkeit, Admin + Lifecycle.php (geändert) Deaktivierung räumt Rewrite/Transient auf + Abilities/ + AbilityRegistrar.php (geändert) registriert nur nicht deaktivierte Abilities + ExposurePolicy.php direkt / indirekt / deaktiviert je Ability + ChangeLog/ChangeLog.php (geändert) query, abilities, clear + Integrations/ + IntegrationRegistry.php (geändert) directAbilityNames() entfällt + WordPress/Schema/ContentSchema.php (geändert) mediaFieldKeys() + Media/ + MediaIntegration.php liefert die Medien-Abilities + MediaGuard.php lädt Anhang + Rechteprüfung + MediaPresenter.php Ausgabeformat eines Mediums + MediaUsageFinder.php Verwendungssuche + MediaImporter.php Upload per URL/Base64 mit Whitelist und Limit + AdminIncludes.php lädt wp-admin/includes für Medienfunktionen + MediaSearchAbility.php lit/media-search + MediaGetAbility.php lit/media-get + MediaPreviewAbility.php lit/media-preview (ImageContent) + MediaDownloadAbility.php lit/media-download (eingebettete Ressource) + MediaUploadFromUrlAbility.php lit/media-upload-from-url + MediaUploadAbility.php lit/media-upload + MediaUpdateAbility.php lit/media-update (Stapel) + MediaDeleteAbility.php lit/media-delete + Server/ + ServerRegistrar.php (geändert) Tool-Liste aus ExposurePolicy + VisibilityFilter.php Tool-Liste/Instructions für Nicht-Freigegebene + Admin/ + AdminTab.php Interface eines Tabs + SettingsPage.php Menüpunkt, Tabs, Hinweise + ConnectionStatus.php Adapter/OAuth/HTTPS/Discovery/Endpunkt-Prüfung + ConnectionTab.php Tab „Verbindung“ + AdminNotices.php Admin-Hinweise (OAuth aus, Discovery kaputt, HTTPS) + SiteHealth.php Site-Health-Test „LIT MCP Endpunkt“ + ConnectionRepository.php OAuth-Verbindungen (Anwendungspasswörter von mcp-oauth) + AccessTab.php Tab „Zugriff“: Rollen, Verbindungen, Widerruf + ToolsTab.php Tab „Tools“: Exposition je Ability + LimitsTab.php Tab „Limits“ + LogTab.php Tab „Protokoll“: Filter, Tabelle, Leeren + LogListTable.php WP_List_Table des Protokolls +docs/guides/overview.md (geändert) Medien-Werkzeuge +docs/guides/media.md Anleitung Medien +tests/ + Stubs/WpApplicationPasswords.php Stub für Verbindungen + Unit/… Tests je Klasse + Unit/UninstallTest.php prüft uninstall.php + Integration/admin-render.php rendert alle Tabs serverseitig + Integration/media-admin-smoke.sh Ende-zu-Ende-Test Medien, Policy, Sichtbarkeit, Admin +``` + +--- + +### Task 1: Medien-Grundlagen – `MediaGuard`, `MediaPresenter`, `MediaUsageFinder`, `lit/media-get`, `MediaIntegration` + +Verwendungssuche (Spec §6.3): Anhang-ID als Beitragsbild, im Inhalt (`wp-image-`-Klasse, Divi-5-Attribut `"id":""`, Dateipfad inkl. Bildgrößen), in Datei-/Bildfeldern (Pods-`file`-Felder und Beziehungen auf Medien, Schlüssel aus `ContentSchema`) und als Website-Icon. + +**Files:** +- Create: `src/Integrations/Media/MediaGuard.php`, `src/Integrations/Media/MediaPresenter.php`, `src/Integrations/Media/MediaUsageFinder.php`, `src/Integrations/Media/MediaGetAbility.php`, `src/Integrations/Media/MediaIntegration.php` +- Modify: `src/Integrations/WordPress/Schema/ContentSchema.php` (Methode `mediaFieldKeys()`), `src/Plugin.php` (Integration registrieren) +- Test: `tests/Unit/Integrations/Media/MediaUsageFinderTest.php`, `tests/Unit/Integrations/Media/MediaPresenterTest.php`, `tests/Unit/Integrations/Media/MediaGetAbilityTest.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php`, `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php` (Test ergänzen) + +**Interfaces:** +- Consumes: `PostGuard::id()`, `ContentSchema::postTypeNames()/fields()`, `FieldDefinition` (1a), `Errors`, `JsonMap`, `ChangeLog`, `Limits` (1a). +- Produces: + - `ContentSchema::mediaFieldKeys(): list` – Schlüssel aller Felder mit `type === 'file'` oder `related.object_type === 'media'` über alle verwalteten Typen. + - `MediaGuard::attachment(mixed $id, string $capability = 'edit_post'): WP_Post|WP_Error`. + - `MediaPresenter` (nicht final): `summary(WP_Post $a): array{id, title, filename, mime_type, url, alt, width, height, filesize, date}`, `full(WP_Post $a, list $usages): array` (+ `caption`, `description`, `parent`, `sizes` (Map Name → `{url,width,height}`), `usages`, `edit_url`), `static summarySchema(): array`, `static fullSchema(): array`. + - `MediaUsageFinder` (nicht final): `__construct(Closure $mediaMetaKeys, ?\wpdb $db = null)`; `usages(int $attachmentId, int $max = 20): list`; `isUsed(int $attachmentId): bool`. + - `MediaGetAbility(MediaPresenter $presenter, MediaUsageFinder $finder)` – `lit/media-get`, Input `{id}`, Output `fullSchema()`; indirekt; readonly. + - `MediaIntegration`: `__construct(ContentSchema $schema, ChangeLog $log, Limits $limits)`, `id()` = `media`, immer verfügbar; `abilities()` wächst in Tasks 2–5. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaUsageFinderTest.php`: +```php +justReturn('2026/09/foto.jpg'); + Functions\when('get_option')->justReturn('33'); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('get_results') + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "meta_key = '_thumbnail_id'") + && str_contains($sql, "meta_value = '33'"))) + ->andReturn([(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]); + $db->expects('get_results') + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'post_content LIKE') + && str_contains($sql, 'wp-image-33') + && str_contains($sql, '"id":"33"') + && str_contains($sql, '2026/09/foto.') + && str_contains($sql, '2026/09/foto-'))) + ->andReturn([ + (object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page'], + (object) ['ID' => 9, 'post_title' => 'Start', 'post_type' => 'page'], + ]); + $db->expects('get_results') + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "meta_key IN ('photo')"))) + ->andReturn([(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']]); + + $usages = (new MediaUsageFinder(static fn (): array => ['photo'], $db))->usages(33); + + self::assertSame( + [ + ['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured'], + ['post_id' => 9, 'title' => 'Start', 'post_type' => 'page', 'where' => 'content'], + ['post_id' => 12, 'title' => 'Anna', 'post_type' => 'lit_person', 'where' => 'field'], + ['post_id' => 0, 'title' => 'Website-Icon', 'post_type' => 'option', 'where' => 'site_icon'], + ], + $usages + ); + } + + public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void + { + Functions\when('get_post_meta')->justReturn(''); + Functions\when('get_option')->justReturn('0'); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('get_results')->twice()->andReturn([]); + + self::assertFalse((new MediaUsageFinder(static fn (): array => [], $db))->isUsed(33)); + } +} +``` + +`tests/Unit/Integrations/Media/MediaPresenterTest.php`: +```php + 33, + 'post_type' => 'attachment', + 'post_title' => 'Foto', + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'Bildunterschrift', + 'post_content' => 'Beschreibung', + 'post_parent' => 5, + ]); + } + + private function stubCommon(array $meta): void + { + Functions\when('wp_get_attachment_metadata')->justReturn($meta); + Functions\when('get_attached_file')->justReturn('/var/www/html/wp-content/uploads/2026/09/foto.jpg'); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/2026/09/foto.jpg'); + Functions\when('get_post_meta')->justReturn('Porträt'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=33&action=edit'); + } + + public function testSummaryHasFileDataAndAlt(): void + { + $this->stubCommon(['width' => 1600, 'height' => 900, 'filesize' => 123456]); + + $summary = (new MediaPresenter())->summary($this->attachment()); + + self::assertSame('foto.jpg', $summary['filename']); + self::assertSame('Porträt', $summary['alt']); + self::assertSame(1600, $summary['width']); + self::assertSame(123456, $summary['filesize']); + } + + public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void + { + $this->stubCommon(['sizes' => ['medium' => ['width' => 300, 'height' => 169]]]); + Functions\when('wp_get_attachment_image_src')->justReturn(['https://kunde.de/foto-300x169.jpg', 300, 169, true]); + $usages = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured']]; + + $full = (new MediaPresenter())->full($this->attachment(), $usages); + + self::assertSame(['medium' => ['url' => 'https://kunde.de/foto-300x169.jpg', 'width' => 300, 'height' => 169]], $full['sizes']); + self::assertSame($usages, $full['usages']); + self::assertSame('Bildunterschrift', $full['caption']); + self::assertNull($full['width']); + + Functions\when('wp_get_attachment_metadata')->justReturn(false); + self::assertInstanceOf(stdClass::class, (new MediaPresenter())->full($this->attachment(), [])['sizes']); + } +} +``` + +`tests/Unit/Integrations/Media/MediaGetAbilityTest.php`: +```php + 33, 'post_type' => 'attachment']); + Functions\when('get_post')->justReturn($attachment); + Functions\when('current_user_can')->justReturn(true); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->expects('usages')->with(33)->andReturn([]); + $presenter = Mockery::mock(MediaPresenter::class); + $presenter->expects('full')->with($attachment, [])->andReturn(['id' => 33]); + + self::assertSame(['id' => 33], (new MediaGetAbility($presenter, $finder))->execute(['id' => 33])); + } + + public function testNonAttachmentIsNotFound(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page'])); + $ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class)); + + self::assertSame('lit_not_found', $ability->permission(['id' => 5])->get_error_code()); + } +} +``` + +`tests/Unit/Integrations/Media/MediaIntegrationTest.php`: +```php +id()); + self::assertTrue($integration->isAvailable()); + self::assertSame(self::EXPECTED, array_map(static fn (Ability $a): string => $a->name(), $integration->abilities())); + + foreach ($integration->abilities() as $ability) { + self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name()); + } + } +} +``` + +In `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php` ergänzen: +```php + public function testMediaFieldKeysCoverFileFieldsAndMediaRelations(): void + { + Functions\when('get_post_types')->justReturn(['page' => 'page', 'lit_person' => 'lit_person']); + $source = Mockery::mock(FieldSource::class); + $source->allows('id')->andReturn('pods'); + $source->allows('fieldsFor')->andReturnUsing(static fn (string $type): array => $type === 'lit_person' + ? [ + new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable'), + new FieldDefinition('logo', 'Logo', 'relation', 'pods', 'writable', false, false, null, [ + 'object_type' => 'media', + 'name' => null, + ]), + new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), + ] + : [new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable')]); + + self::assertSame(['photo', 'logo'], (new ContentSchema([$source]))->mediaFieldKeys()); + } +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test -- --filter 'Media|ContentSchemaTest'` +Expected: FAIL (Klassen fehlen, `mediaFieldKeys()` fehlt). + +- [ ] **Step 3: Implementierung** + +In `src/Integrations/WordPress/Schema/ContentSchema.php` nach `sourceOf()` ergänzen: +```php + /** + * Keys of fields that store attachment IDs (file fields, relations to media) – + * used by the media usage search. + * + * @return list + */ + public function mediaFieldKeys(): array + { + $keys = []; + foreach ($this->postTypeNames() as $postType) { + foreach ($this->fields($postType) as $field) { + if ($field->type === 'file' || ($field->related['object_type'] ?? null) === 'media') { + $keys[] = $field->key; + } + } + } + + return array_values(array_unique($keys)); + } +``` + +`src/Integrations/Media/MediaGuard.php`: +```php + 'positive Ganzzahl erwartet']); + } + + $attachment = get_post($attachmentId); + if (! $attachment instanceof WP_Post || $attachment->post_type !== 'attachment') { + return Errors::notFound( + sprintf('Medium %d nicht gefunden.', $attachmentId), + 'Medien über lit-media-search suchen.' + ); + } + + if (! current_user_can($capability, $attachmentId)) { + return Errors::forbidden(sprintf('Keine Berechtigung für Medium %d.', $attachmentId)); + } + + return $attachment; + } +} +``` + +`src/Integrations/Media/MediaPresenter.php`: +```php + */ + public function summary(WP_Post $attachment): array + { + $meta = $this->metadata($attachment); + + return [ + 'id' => (int) $attachment->ID, + 'title' => (string) $attachment->post_title, + 'filename' => wp_basename((string) get_attached_file((int) $attachment->ID)), + 'mime_type' => (string) $attachment->post_mime_type, + 'url' => (string) wp_get_attachment_url((int) $attachment->ID), + 'alt' => (string) get_post_meta((int) $attachment->ID, '_wp_attachment_image_alt', true), + 'width' => isset($meta['width']) ? (int) $meta['width'] : null, + 'height' => isset($meta['height']) ? (int) $meta['height'] : null, + 'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null, + 'date' => (string) $attachment->post_date, + ]; + } + + /** + * @param list> $usages + * @return array + */ + public function full(WP_Post $attachment, array $usages): array + { + $sizes = []; + foreach ((array) ($this->metadata($attachment)['sizes'] ?? []) as $name => $size) { + $source = wp_get_attachment_image_src((int) $attachment->ID, (string) $name); + if (is_array($source)) { + $sizes[(string) $name] = [ + 'url' => (string) $source[0], + 'width' => (int) $source[1], + 'height' => (int) $source[2], + ]; + } + } + + return array_merge($this->summary($attachment), [ + 'caption' => (string) $attachment->post_excerpt, + 'description' => (string) $attachment->post_content, + 'parent' => (int) $attachment->post_parent, + 'sizes' => JsonMap::of($sizes), + 'usages' => $usages, + 'edit_url' => (string) get_edit_post_link((int) $attachment->ID, 'raw'), + ]); + } + + /** @return array */ + public static function summarySchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'filename' => ['type' => 'string'], + 'mime_type' => ['type' => 'string'], + 'url' => ['type' => 'string'], + 'alt' => ['type' => 'string'], + 'width' => ['type' => ['integer', 'null']], + 'height' => ['type' => ['integer', 'null']], + 'filesize' => ['type' => ['integer', 'null']], + 'date' => ['type' => 'string'], + ], + 'required' => ['id', 'title', 'mime_type', 'url', 'alt'], + ]; + } + + /** @return array */ + public static function fullSchema(): array + { + $schema = self::summarySchema(); + $schema['properties'] = array_merge($schema['properties'], [ + 'caption' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'parent' => ['type' => 'integer'], + 'sizes' => ['type' => 'object'], + 'usages' => ['type' => 'array', 'items' => ['type' => 'object']], + 'edit_url' => ['type' => 'string'], + ]); + $schema['required'] = array_merge($schema['required'], ['sizes', 'usages']); + + return $schema; + } + + /** @return array */ + private function metadata(WP_Post $attachment): array + { + $meta = wp_get_attachment_metadata((int) $attachment->ID); + + return is_array($meta) ? $meta : []; + } +} +``` + +`src/Integrations/Media/MediaUsageFinder.php`: +```php + $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()). + */ + public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null) + { + } + + /** @return list */ + public function usages(int $attachmentId, int $max = 20): array + { + $wpdb = $this->db ?? $GLOBALS['wpdb']; + $found = []; + $this->collect($found, $this->featured($wpdb, $attachmentId, $max), 'featured'); + $this->collect($found, $this->content($wpdb, $attachmentId, $max), 'content'); + $this->collect($found, $this->fields($wpdb, $attachmentId, $max), 'field'); + + if ((int) get_option('site_icon') === $attachmentId) { + $found['site_icon'] = [ + 'post_id' => 0, + 'title' => 'Website-Icon', + 'post_type' => 'option', + 'where' => 'site_icon', + ]; + } + + return array_slice(array_values($found), 0, $max); + } + + public function isUsed(int $attachmentId): bool + { + return $this->usages($attachmentId, 1) !== []; + } + + /** @return list */ + private function featured(\wpdb $wpdb, int $attachmentId, int $max): array + { + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s + AND p.post_status NOT IN ('auto-draft', 'trash', 'inherit') LIMIT %d", + (string) $attachmentId, + $max + )); + } + + /** @return list */ + private function content(\wpdb $wpdb, int $attachmentId, int $max): array + { + $patterns = ['wp-image-' . $attachmentId, '"id":"' . $attachmentId . '"']; + $file = (string) get_post_meta($attachmentId, '_wp_attached_file', true); + $base = (string) preg_replace('/\.[^.\/]+$/', '', $file); + if ($base !== '') { + // Original file and generated sizes (foto.jpg, foto-300x200.jpg). + $patterns[] = $base . '.'; + $patterns[] = $base . '-'; + } + + $likes = array_map(static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%', $patterns); + $conditions = implode(' OR ', array_fill(0, count($likes), 'p.post_content LIKE %s')); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $conditions only holds placeholders. + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p + WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item') + AND p.post_status NOT IN ('auto-draft', 'trash', 'inherit') + AND ({$conditions}) LIMIT %d", + ...array_merge($likes, [$max]) + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** @return list */ + private function fields(\wpdb $wpdb, int $attachmentId, int $max): array + { + $keys = ($this->mediaMetaKeys)(); + if ($keys === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($keys), '%s')); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders only holds placeholders. + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT DISTINCT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value = %s + AND p.post_status NOT IN ('auto-draft', 'trash', 'inherit') LIMIT %d", + ...array_merge($keys, [(string) $attachmentId, $max]) + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * @param array $found + * @param list $rows + */ + private function collect(array &$found, array $rows, string $where): void + { + foreach ($rows as $row) { + $found['post:' . (int) $row->ID] ??= [ + 'post_id' => (int) $row->ID, + 'title' => (string) $row->post_title, + 'post_type' => (string) $row->post_type, + 'where' => $where, + ]; + } + } +} +``` + +`src/Integrations/Media/MediaGetAbility.php`: +```php + 'object', + 'properties' => ['id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return MediaPresenter::fullSchema(); + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + return $this->presenter->full($attachment, $this->finder->usages((int) $attachment->ID)); + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: +```php + $this->schema->mediaFieldKeys()); + + return [ + new MediaGetAbility($presenter, $finder), + ]; + } +} +``` + +`src/Plugin.php`: Import `use LIT\MCP\Integrations\Media\MediaIntegration;` ergänzen und `boot()` vollständig ersetzen durch: +```php + public static function boot(string $pluginFile): void + { + self::$pluginFile = $pluginFile; + + $missing = (new Dependencies())->missing(); + if ($missing !== []) { + self::noticeMissing($missing); + return; + } + + (new Access())->register(); + (new RequestContext())->register(); + + $limits = new Limits(); + $changeLog = new ChangeLog(); + (new ChangeLogInstaller($changeLog))->maybeInstall(); + (new ChangeLogCleanup($changeLog, $limits))->register(); + + $schema = self::contentSchema(); + $registry = new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides'), + new WordPressIntegration($schema, $changeLog), + new MediaIntegration($schema, $changeLog, $limits) + ); + + (new AbilityRegistrar($registry))->register(); + (new ServerRegistrar( + $registry, + new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames()) + ))->register(); + + \WPMedia\MCP\OAuth\Bootstrap::instance(); + } +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media src/Integrations/WordPress/Schema/ContentSchema.php src/Plugin.php tests/Unit/Integrations/Media tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media usage finder and media-get tool + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 2: `lit/media-search` inkl. `missing_alt` und `unused` + +**Files:** +- Create: `src/Integrations/Media/MediaSearchAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaSearchAbilityTest.php` + +**Interfaces:** +- Consumes: `MediaPresenter::summary()/summarySchema()`, `MediaUsageFinder::isUsed()` (Task 1), `Cursor` (1a). +- Produces: `MediaSearchAbility(MediaPresenter $presenter, MediaUsageFinder $finder)` – `lit/media-search`, Input `{search?, mime?, date_after?, date_before?, missing_alt?, unused?, limit?, cursor?}`, Output `{items: list, next_cursor: ?string, warnings: list}`; direkt; readonly, idempotent. `unused` prüft höchstens `MAX_SCAN = 500` Medien je Aufruf (in Blöcken zu 50) und meldet eine Unterbrechung als Warnung mit `next_cursor`. + +- [ ] **Step 1: Failing Test** + +`tests/Unit/Integrations/Media/MediaSearchAbilityTest.php`: +```php +allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]); + + return $presenter; + } + + /** @return list */ + private function attachments(int ...$ids): array + { + return array_map(static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'attachment']), $ids); + } + + public function testMissingAltLimitsToImagesWithoutAltText(): void + { + Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool => + $args['post_type'] === 'attachment' + && $args['post_mime_type'] === 'image' + && $args['s'] === 'team' + && $args['meta_query'] === [ + 'relation' => 'OR', + ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], + ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], + ] + && $args['posts_per_page'] === 21 + && $args['offset'] === 0))->andReturn($this->attachments(1, 2)); + + $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['search' => 'team', 'missing_alt' => true]); + + self::assertSame([['id' => 1], ['id' => 2]], $result['items']); + self::assertNull($result['next_cursor']); + self::assertSame([], $result['warnings']); + } + + public function testUnusedFiltersThroughTheUsageFinder(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturnUsing(static fn (int $id): bool => $id === 2); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); + + self::assertSame([['id' => 1], ['id' => 3]], $result['items']); + self::assertNull($result['next_cursor']); + } + + public function testUnusedStopsAtTheLimitWithACursor(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturn(false); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]); + + self::assertSame([['id' => 1]], $result['items']); + self::assertSame(1, Cursor::decode($result['next_cursor'])); + } + + public function testRequiresUploadRights(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)); + + self::assertSame('lit_forbidden', $ability->permission([])->get_error_code()); + self::assertTrue($ability->isDirect()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaSearchAbilityTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/MediaSearchAbility.php`: +```php + 'object', + 'properties' => array_merge([ + 'search' => ['type' => 'string'], + 'mime' => ['type' => 'string', 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'], + 'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'missing_alt' => ['type' => 'boolean'], + 'unused' => ['type' => 'boolean'], + ], Cursor::inputProperties()), + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => MediaPresenter::summarySchema()], + 'next_cursor' => ['type' => ['string', 'null']], + 'warnings' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['items', 'next_cursor', 'warnings'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('upload_files') + ? true + : Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).'); + } + + public function execute(array $input): mixed + { + $offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + + $limit = Cursor::limit($input['limit'] ?? null); + $args = $this->queryArgs($input); + + if (($input['unused'] ?? false) === true) { + $page = $this->unusedPage($args, $limit, $offset); + } else { + $posts = get_posts(array_merge($args, ['posts_per_page' => $limit + 1, 'offset' => $offset])); + $page = Cursor::page(array_values($posts), $limit, $offset) + ['warnings' => []]; + } + + return [ + 'items' => array_map( + fn (WP_Post $attachment): array => $this->presenter->summary($attachment), + $page['items'] + ), + 'next_cursor' => $page['next_cursor'], + 'warnings' => $page['warnings'], + ]; + } + + /** + * @param array $input + * @return array + */ + private function queryArgs(array $input): array + { + $args = [ + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + + if (isset($input['search']) && $input['search'] !== '') { + $args['s'] = (string) $input['search']; + } + + if (isset($input['mime']) && $input['mime'] !== '') { + $args['post_mime_type'] = (string) $input['mime']; + } + + if (($input['missing_alt'] ?? false) === true) { + $args['post_mime_type'] ??= 'image'; + $args['meta_query'] = [ + 'relation' => 'OR', + ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], + ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], + ]; + } + + $date = []; + if (isset($input['date_after'])) { + $date['after'] = (string) $input['date_after']; + } + if (isset($input['date_before'])) { + $date['before'] = (string) $input['date_before']; + } + if ($date !== []) { + $args['date_query'] = [array_merge($date, ['inclusive' => true])]; + } + + return $args; + } + + /** + * @param array $args + * @return array{items: list, next_cursor: ?string, warnings: list} + */ + private function unusedPage(array $args, int $limit, int $offset): array + { + $items = []; + $position = $offset; + $scanned = 0; + + while ($scanned < self::MAX_SCAN) { + $batch = array_values(get_posts(array_merge($args, [ + 'posts_per_page' => self::BATCH, + 'offset' => $position, + ]))); + + foreach ($batch as $attachment) { + $position++; + $scanned++; + if (! $this->finder->isUsed((int) $attachment->ID)) { + $items[] = $attachment; + if (count($items) === $limit) { + return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []]; + } + } + } + + if (count($batch) < self::BATCH) { + return ['items' => $items, 'next_cursor' => null, 'warnings' => []]; + } + } + + return [ + 'items' => $items, + 'next_cursor' => Cursor::encode($position), + 'warnings' => [sprintf( + 'Suche nach unbenutzten Medien nach %d geprüften Medien unterbrochen; mit next_cursor fortsetzen.', + self::MAX_SCAN + )], + ]; + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: `abilities()`-Rückgabe ersetzen durch +```php + return [ + new MediaSearchAbility($presenter, $finder), + new MediaGetAbility($presenter, $finder), + ]; +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media-search with missing_alt and unused filters + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 3: `lit/media-preview` (ImageContent) und `lit/media-download` (eingebettete Ressource) + +Der Adapter wandelt ein Ergebnis `['type' => 'image', 'results' => , 'mimeType' => …]` in `ImageContent` und `['type' => 'resource', 'resource' => ['uri', 'mimeType', 'blob']]` in eine eingebettete Ressource um (`ToolsHandler::call_tool`). Solche Tools haben kein `outputSchema`; Bilder funktionieren nur als direktes Tool (Spec §4.3) – `ExposurePolicy` erzwingt das in Task 6. + +**Files:** +- Create: `src/Integrations/Media/AdminIncludes.php`, `src/Integrations/Media/MediaPreviewAbility.php`, `src/Integrations/Media/MediaDownloadAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php`, `tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php` + +**Interfaces:** +- Consumes: `MediaGuard` (Task 1), `Limits::uploadBytes()/uploadMegabytes()` (1a), `Errors`. +- Produces: + - `AdminIncludes::load(): void` – lädt `wp-admin/includes/file.php`, `media.php`, `image.php`, sofern deren Funktionen fehlen und die Datei existiert. + - `MediaPreviewAbility()` – `lit/media-preview`, Input `{id, max_size? (64–1024, Standard 512)}`, Ergebnis `{type: 'image', results: , mimeType}`; direkt; readonly, idempotent; `outputSchema()` = `null`. + - `MediaDownloadAbility(Limits $limits)` – `lit/media-download`, Input `{id, size? (Standard full)}`, Ergebnis `{type: 'resource', resource: {uri, mimeType, blob}}`; indirekt; readonly, idempotent; `outputSchema()` = `null`; Dateien über dem Upload-Limit werden abgelehnt. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php`: +```php +source = (string) tempnam(sys_get_temp_dir(), 'lit-src'); + file_put_contents($this->source, 'original'); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 33, + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_attached_file')->justReturn($this->source); + Functions\when('wp_tempnam')->alias(static fn (): string => (string) tempnam(sys_get_temp_dir(), 'lit-prev')); + Functions\when('wp_delete_file')->alias(static function (string $file): void { + if (is_file($file)) { + unlink($file); + } + }); + } + + protected function tearDown(): void + { + if (is_file($this->source)) { + unlink($this->source); + } + parent::tearDown(); + } + + public function testReturnsADownscaledImageResult(): void + { + Functions\when('wp_attachment_is_image')->justReturn(true); + $editor = Mockery::mock(); + $editor->allows('get_size')->andReturn(['width' => 2000, 'height' => 1000]); + $editor->expects('resize')->with(256, 256, false)->andReturn(true); + $editor->expects('save')->andReturnUsing(static function (string $path, string $mime): array { + file_put_contents($path, 'JPEGDATA'); + return ['path' => $path, 'mime-type' => $mime]; + }); + Functions\when('wp_get_image_editor')->justReturn($editor); + + $result = (new MediaPreviewAbility())->execute(['id' => 33, 'max_size' => 256]); + + self::assertSame('image', $result['type']); + self::assertSame('JPEGDATA', $result['results']); + self::assertSame('image/jpeg', $result['mimeType']); + } + + public function testNonImagesAreUnsupported(): void + { + Functions\when('wp_attachment_is_image')->justReturn(false); + + $result = (new MediaPreviewAbility())->execute(['id' => 33]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testIsADirectToolWithoutOutputSchema(): void + { + $ability = new MediaPreviewAbility(); + + self::assertNull($ability->outputSchema()); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } +} +``` + +`tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php`: +```php +file = sys_get_temp_dir() . '/lit-download-test.pdf'; + file_put_contents($this->file, 'PDFDATA'); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 40, + 'post_type' => 'attachment', + 'post_mime_type' => 'application/pdf', + ])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_attached_file')->justReturn($this->file); + Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/preise.pdf'); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_check_filetype')->justReturn(['ext' => 'pdf', 'type' => 'application/pdf']); + } + + protected function tearDown(): void + { + if (is_file($this->file)) { + unlink($this->file); + } + parent::tearDown(); + } + + public function testReturnsTheFileAsEmbeddedBlobResource(): void + { + Functions\when('get_option')->justReturn([]); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('resource', $result['type']); + self::assertSame('https://kunde.de/wp-content/uploads/preise.pdf', $result['resource']['uri']); + self::assertSame('application/pdf', $result['resource']['mimeType']); + self::assertSame(base64_encode('PDFDATA'), $result['resource']['blob']); + } + + public function testFilesAboveTheLimitAreRefused(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 1]); + file_put_contents($this->file, str_repeat('x', 1024 * 1024 + 1)); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testUnknownSizeIsNotFound(): void + { + Functions\when('get_option')->justReturn([]); + Functions\when('image_get_intermediate_size')->justReturn(false); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40, 'size' => 'huge']); + + self::assertSame('lit_not_found', $result->get_error_code()); + self::assertNull((new MediaDownloadAbility(new Limits()))->outputSchema()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + 'lit/media-preview', + 'lit/media-download', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaPreviewAbilityTest|MediaDownloadAbilityTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/AdminIncludes.php`: +```php + 'wp-admin/includes/file.php', + 'media_handle_sideload' => 'wp-admin/includes/media.php', + 'wp_generate_attachment_metadata' => 'wp-admin/includes/image.php', + ]; + + public static function load(): void + { + foreach (self::FILES as $function => $file) { + if (! function_exists($function) && is_readable(ABSPATH . $file)) { + require_once ABSPATH . $file; + } + } + } +} +``` + +`src/Integrations/Media/MediaPreviewAbility.php`: +```php + 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + ]; + + public function name(): string + { + return 'lit/media-preview'; + } + + public function label(): string + { + return 'Bildvorschau'; + } + + public function description(): string + { + return 'Zeigt ein Bild der Mediathek als verkleinerte Vorschau (längste Kante max_size, Standard 512 px), ' + . 'damit der Inhalt beurteilt werden kann, z. B. für Alt-Texte.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'max_size' => ['type' => 'integer', 'minimum' => 64, 'maximum' => 1024], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return null; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + if (! wp_attachment_is_image($id)) { + return Errors::unsupported( + 'Das Medium ist kein Bild.', + 'Metadaten über lit-media-get abrufen; Dateien über lit-media-download.' + ); + } + + $file = get_attached_file($id); + if (! is_string($file) || ! is_readable($file)) { + return Errors::notFound('Die Bilddatei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.'); + } + + AdminIncludes::load(); + $editor = wp_get_image_editor($file); + if ($editor instanceof WP_Error) { + return Errors::unsupported( + 'Das Bild kann nicht verarbeitet werden: ' . $editor->get_error_message(), + 'Metadaten über lit-media-get abrufen.' + ); + } + + $max = max(64, min(1024, (int) ($input['max_size'] ?? self::DEFAULT_SIZE))); + $size = (array) $editor->get_size(); + if (max((int) ($size['width'] ?? 0), (int) ($size['height'] ?? 0)) > $max) { + $resized = $editor->resize($max, $max, false); + if ($resized instanceof WP_Error) { + return Errors::unsupported( + 'Vorschau konnte nicht verkleinert werden.', + 'Metadaten über lit-media-get abrufen.' + ); + } + } + + $mime = isset(self::EXTENSIONS[$attachment->post_mime_type]) + ? (string) $attachment->post_mime_type + : 'image/jpeg'; + $target = wp_tempnam('lit-mcp-preview'); + $saved = $editor->save($target . '.' . self::EXTENSIONS[$mime], $mime); + wp_delete_file($target); + if (! is_array($saved) || ! isset($saved['path'])) { + return Errors::unsupported( + 'Vorschau konnte nicht erzeugt werden.', + 'Metadaten über lit-media-get abrufen.' + ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file. + $bytes = file_get_contents((string) $saved['path']); + wp_delete_file((string) $saved['path']); + if ($bytes === false) { + return Errors::internal(); + } + + return ['type' => 'image', 'results' => $bytes, 'mimeType' => (string) ($saved['mime-type'] ?? $mime)]; + } +} +``` + +`src/Integrations/Media/MediaDownloadAbility.php`: +```php + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'size' => ['type' => 'string', 'description' => 'full (Standard) oder Name einer Bildgröße.'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return null; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + $size = (string) ($input['size'] ?? 'full'); + if ($size === 'full') { + $path = get_attached_file($id); + $url = (string) wp_get_attachment_url($id); + } else { + $data = image_get_intermediate_size($id, $size); + if (! is_array($data) || ! isset($data['path'])) { + return Errors::notFound( + sprintf('Die Größe "%s" gibt es für dieses Medium nicht.', $size), + 'Verfügbare Größen stehen unter sizes in lit-media-get.' + ); + } + $path = wp_get_upload_dir()['basedir'] . '/' . $data['path']; + $url = (string) ($data['url'] ?? ''); + } + + if (! is_string($path) || ! is_readable($path)) { + return Errors::notFound('Die Datei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.'); + } + + $bytesOnDisk = (int) filesize($path); + if ($bytesOnDisk > $this->limits->uploadBytes()) { + return Errors::unsupported( + sprintf( + 'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).', + $bytesOnDisk / 1048576, + $this->limits->uploadMegabytes() + ), + 'Kleinere Bildgröße (size) wählen oder die URL aus lit-media-get verwenden.' + ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local upload file. + $bytes = file_get_contents($path); + if ($bytes === false) { + return Errors::internal(); + } + + $type = wp_check_filetype(wp_basename($path)); + $mime = is_string($type['type'] ?? null) && $type['type'] !== '' + ? $type['type'] + : (string) $attachment->post_mime_type; + + return [ + 'type' => 'resource', + 'resource' => ['uri' => $url, 'mimeType' => $mime, 'blob' => base64_encode($bytes)], + ]; + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: `abilities()`-Rückgabe ersetzen durch +```php + return [ + new MediaSearchAbility($presenter, $finder), + new MediaGetAbility($presenter, $finder), + new MediaPreviewAbility(), + new MediaDownloadAbility($this->limits), + ]; +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS (lange Zeilen umbrechen). + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media preview as image content and download as embedded resource + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 4: Upload – `MediaImporter`, `lit/media-upload-from-url`, `lit/media-upload` + +Spec §6.3/§10: Import per URL über `wp_safe_remote_get` (hier `download_url()`, das intern `wp_safe_remote_get` nutzt; vorher `wp_http_validate_url()`, damit interne Adressen gar nicht erst angefragt werden), MIME-Whitelist, Größenlimit; Base64-Upload mit einstellbarem Limit (Standard 5 MB). `wp_check_filetype_and_ext()` prüft zusätzlich den echten Dateiinhalt (getarnte Dateien fallen durch). + +**Files:** +- Create: `src/Integrations/Media/MediaImporter.php`, `src/Integrations/Media/MediaUploadFromUrlAbility.php`, `src/Integrations/Media/MediaUploadAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaImporterTest.php`, `tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php` + +**Interfaces:** +- Consumes: `AdminIncludes::load()` (Task 3), `MediaPresenter::summary()/summarySchema()` (Task 1), `Limits`, `ChangeLog`, `ToolResult`, `Errors`. +- Produces: + - `MediaImporter` (nicht final): `MIMES` (Endungen → MIME: jpg/jpeg/jpe, png, gif, webp, avif, pdf, mp4/m4v, mp3; Filter `lit_mcp_upload_mimes`); `__construct(Limits $limits)`; `fromUrl(string $url, ?string $filename, array $meta): int|WP_Error`; `fromBase64(string $data, string $filename, array $meta): int|WP_Error` (`$meta` = `{title?, alt?, post_id?}`); löscht die temporäre Datei bei jedem Fehler. + - `MediaUploadFromUrlAbility(MediaImporter $importer, MediaPresenter $presenter, ChangeLog $log)` – `lit/media-upload-from-url`, Input `{url, filename?, title?, alt?, post_id?}`; direkt; Ergebnis-Extra `attachment` (summary). + - `MediaUploadAbility(MediaImporter $importer, MediaPresenter $presenter, ChangeLog $log, Limits $limits)` – `lit/media-upload`, Input `{filename, data (Base64), title?, alt?, post_id?}`; indirekt; Ergebnis-Extra `attachment`. + - Beide: Recht `upload_files`, bei `post_id` zusätzlich `edit_post` für diesen Eintrag; nicht idempotent. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaImporterTest.php`: +```php + */ + private array $tempFiles = []; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_option')->justReturn(['upload_mb' => 1]); + Functions\when('wp_tempnam')->alias(function (): string { + $file = (string) tempnam(sys_get_temp_dir(), 'lit-up'); + $this->tempFiles[] = $file; + return $file; + }); + Functions\when('wp_delete_file')->alias(static function (string $file): void { + if (is_file($file)) { + unlink($file); + } + }); + Functions\when('sanitize_file_name')->returnArg(); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + public function testBase64UploadIsSideloadedWithAltText(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(\Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png' && is_file($file['tmp_name'])), 5, 'Logo') + ->andReturn(55); + Functions\expect('update_post_meta')->once()->with(55, '_wp_attachment_image_alt', 'Firmenlogo'); + + $id = (new MediaImporter(new Limits()))->fromBase64(base64_encode('PNGDATA'), 'logo.png', [ + 'title' => 'Logo', + 'alt' => 'Firmenlogo', + 'post_id' => 5, + ]); + + self::assertSame(55, $id); + } + + public function testOversizedBase64IsRejectedBeforeDecoding(): void + { + $result = (new MediaImporter(new Limits()))->fromBase64(str_repeat('A', 2 * 1024 * 1024), 'big.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload'); + } + + public function testInvalidBase64IsRejected(): void + { + $result = (new MediaImporter(new Limits()))->fromBase64('***', 'x.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testDisguisedFileIsRejectedAndTheTempFileRemoved(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->never(); + + $result = (new MediaImporter(new Limits()))->fromBase64(base64_encode('get_error_code()); + foreach ($this->tempFiles as $file) { + self::assertFileDoesNotExist($file); + } + } + + public function testInternalUrlsAreRejectedWithoutRequest(): void + { + Functions\when('wp_http_validate_url')->justReturn(false); + Functions\expect('download_url')->never(); + + $result = (new MediaImporter(new Limits()))->fromUrl('http://127.0.0.1/wp-config.php', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testFailedDownloadIsReported(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\when('download_url')->justReturn(new WP_Error('http_404', 'Not Found')); + + $result = (new MediaImporter(new Limits()))->fromUrl('https://example.com/fehlt.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Not Found', $result->get_error_message()); + } +} +``` + +`tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php`: +```php +allows('summary')->andReturn(['id' => 55, 'title' => 'Logo']); + + return $presenter; + } + + public function testUploadFromUrlReportsTheNewAttachment(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 55, 'post_type' => 'attachment', 'post_title' => 'Logo'])); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=55&action=edit'); + $importer = Mockery::mock(MediaImporter::class); + $importer->expects('fromUrl')->with('https://example.com/logo.png', null, ['alt' => 'Logo'])->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-upload-from-url', 'attachment', '55', Mockery::type('string'))->andReturn(3); + + $result = (new MediaUploadFromUrlAbility($importer, $this->presenter(), $log)) + ->execute(['url' => 'https://example.com/logo.png', 'alt' => 'Logo']); + + self::assertSame(['id' => 55, 'title' => 'Logo'], $result['attachment']); + self::assertSame('created', $result['changed'][0]['action']); + self::assertSame(3, $result['log_id']); + } + + public function testImporterErrorsArePassedThrough(): void + { + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromBase64')->andReturn(Errors::invalidInput('zu groß')); + + $result = (new MediaUploadAbility($importer, $this->presenter(), Mockery::mock(ChangeLog::class), new Limits())) + ->execute(['filename' => 'x.png', 'data' => 'AAAA']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testPermissionNeedsUploadRightsAndEditRightsOnTheParent(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $cap === 'upload_files'); + $ability = new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->presenter(), Mockery::mock(ChangeLog::class)); + + self::assertTrue($ability->permission(['url' => 'https://example.com/a.png'])); + self::assertSame('lit_forbidden', $ability->permission(['url' => 'https://example.com/a.png', 'post_id' => 5])->get_error_code()); + } + + public function testDescriptionNamesTheConfiguredLimit(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 8]); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->presenter(), Mockery::mock(ChangeLog::class), new Limits()); + + self::assertStringContainsString('8 MB', $ability->description()); + self::assertFalse($ability->isDirect()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + 'lit/media-preview', + 'lit/media-upload-from-url', + 'lit/media-upload', + 'lit/media-download', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaImporterTest|MediaUploadAbilitiesTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/MediaImporter.php`: +```php + 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'pdf' => 'application/pdf', + 'mp4|m4v' => 'video/mp4', + 'mp3' => 'audio/mpeg', + ]; + + public function __construct(private Limits $limits) + { + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromUrl(string $url, ?string $filename, array $meta): int|WP_Error + { + if (! wp_http_validate_url($url)) { + return Errors::invalidInput( + 'Die URL ist nicht erlaubt.', + ['url' => 'nur öffentlich erreichbare http(s)-Adressen'], + 'Eine öffentlich erreichbare Datei-URL angeben (keine internen oder lokalen Adressen).' + ); + } + + AdminIncludes::load(); + $tmp = download_url($url, 30); + if ($tmp instanceof WP_Error) { + return Errors::invalidInput( + 'Download fehlgeschlagen: ' . $tmp->get_error_message(), + ['url' => 'nicht abrufbar'], + 'URL prüfen; nur öffentlich erreichbare Dateien können importiert werden.' + ); + } + + $name = $filename ?? wp_basename((string) wp_parse_url($url, PHP_URL_PATH)); + + return $this->import((string) $tmp, $name, $meta); + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromBase64(string $data, string $filename, array $meta): int|WP_Error + { + $estimated = intdiv(strlen($data) * 3, 4); + if ($estimated > $this->limits->uploadBytes() + 2) { + return $this->tooLarge($estimated); + } + + $bytes = base64_decode($data, true); + if ($bytes === false || $bytes === '') { + return Errors::invalidInput( + 'data ist kein gültiges Base64.', + ['data' => 'Base64-kodierter Dateiinhalt erwartet'] + ); + } + + AdminIncludes::load(); + $tmp = (string) wp_tempnam($filename); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file. + if (file_put_contents($tmp, $bytes) === false) { + wp_delete_file($tmp); + return Errors::internal(); + } + + return $this->import($tmp, $filename, $meta); + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + private function import(string $tmp, string $filename, array $meta): int|WP_Error + { + $size = (int) filesize($tmp); + if ($size > $this->limits->uploadBytes()) { + wp_delete_file($tmp); + return $this->tooLarge($size); + } + + $mimes = $this->mimes(); + $name = sanitize_file_name($filename !== '' ? $filename : 'datei'); + $check = wp_check_filetype_and_ext($tmp, $name, $mimes); + if (empty($check['type']) || empty($check['ext'])) { + wp_delete_file($tmp); + return Errors::invalidInput( + 'Dateityp nicht erlaubt oder Inhalt passt nicht zur Endung.', + ['filename' => 'erlaubt: ' . implode(', ', array_keys($mimes))], + 'Nur Bilder (jpg, png, gif, webp, avif), PDF, MP4 und MP3 hochladen.' + ); + } + + if (! empty($check['proper_filename'])) { + $name = (string) $check['proper_filename']; + } + + $attachmentId = media_handle_sideload( + ['name' => $name, 'tmp_name' => $tmp], + (int) ($meta['post_id'] ?? 0), + isset($meta['title']) ? (string) $meta['title'] : null + ); + if ($attachmentId instanceof WP_Error) { + wp_delete_file($tmp); + return Errors::invalidInput('WordPress hat die Datei abgelehnt: ' . $attachmentId->get_error_message()); + } + + if (isset($meta['alt']) && $meta['alt'] !== '') { + $alt = sanitize_text_field((string) $meta['alt']); + update_post_meta((int) $attachmentId, '_wp_attachment_image_alt', $alt); + } + + return (int) $attachmentId; + } + + /** @return array */ + private function mimes(): array + { + $mimes = apply_filters('lit_mcp_upload_mimes', self::MIMES); + + return is_array($mimes) && $mimes !== [] ? $mimes : self::MIMES; + } + + private function tooLarge(int $bytes): WP_Error + { + return Errors::invalidInput( + sprintf('Datei zu groß (%.1f MB, Limit %d MB).', $bytes / 1048576, $this->limits->uploadMegabytes()), + ['data' => 'über dem Upload-Limit'], + 'Kleinere Datei verwenden oder das Limit unter Einstellungen → LIT MCP → Limits erhöhen lassen.' + ); + } +} +``` + +`src/Integrations/Media/MediaUploadFromUrlAbility.php`: +```php + 'object', + 'properties' => [ + 'url' => ['type' => 'string', 'minLength' => 1], + 'filename' => ['type' => 'string', 'description' => 'Optional: Dateiname in der Mediathek.'], + 'title' => ['type' => 'string'], + 'alt' => ['type' => 'string'], + 'post_id' => ['type' => 'integer', 'minimum' => 1], + ], + 'required' => ['url'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['attachment' => MediaPresenter::summarySchema()], ['attachment']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + if (! current_user_can('upload_files')) { + return Errors::forbidden('Keine Berechtigung zum Hochladen (upload_files).'); + } + + $postId = (int) ($input['post_id'] ?? 0); + + return $postId === 0 || current_user_can('edit_post', $postId) + ? true + : Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $postId)); + } + + public function execute(array $input): mixed + { + $meta = array_intersect_key($input, ['title' => true, 'alt' => true, 'post_id' => true]); + $filename = isset($input['filename']) ? (string) $input['filename'] : null; + $attachmentId = $this->importer->fromUrl((string) $input['url'], $filename, $meta); + if ($attachmentId instanceof WP_Error) { + return $attachmentId; + } + + $attachment = get_post($attachmentId); + $title = $attachment instanceof WP_Post ? (string) $attachment->post_title : ''; + $details = $attachment instanceof WP_Post ? $this->presenter->summary($attachment) : ['id' => $attachmentId]; + $summary = sprintf('Datei "%s" von URL importiert (ID %d)', $title, $attachmentId); + $logId = $this->log->record($this->name(), 'attachment', (string) $attachmentId, $summary); + + return ToolResult::ok($summary . '.') + ->changed((string) $attachmentId, 'attachment', 'created', $title) + ->logId($logId) + ->urls(null, (string) get_edit_post_link($attachmentId, 'raw')) + ->with('attachment', $details) + ->toArray(); + } +} +``` + +`src/Integrations/Media/MediaUploadAbility.php`: +```php +limits->uploadMegabytes() + ); + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'filename' => ['type' => 'string', 'minLength' => 1, 'description' => 'Dateiname mit Endung.'], + 'data' => ['type' => 'string', 'minLength' => 1, 'description' => 'Dateiinhalt als Base64.'], + 'title' => ['type' => 'string'], + 'alt' => ['type' => 'string'], + 'post_id' => ['type' => 'integer', 'minimum' => 1], + ], + 'required' => ['filename', 'data'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['attachment' => MediaPresenter::summarySchema()], ['attachment']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + if (! current_user_can('upload_files')) { + return Errors::forbidden('Keine Berechtigung zum Hochladen (upload_files).'); + } + + $postId = (int) ($input['post_id'] ?? 0); + + return $postId === 0 || current_user_can('edit_post', $postId) + ? true + : Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $postId)); + } + + public function execute(array $input): mixed + { + $meta = array_intersect_key($input, ['title' => true, 'alt' => true, 'post_id' => true]); + $attachmentId = $this->importer->fromBase64((string) $input['data'], (string) $input['filename'], $meta); + if ($attachmentId instanceof WP_Error) { + return $attachmentId; + } + + $attachment = get_post($attachmentId); + $title = $attachment instanceof WP_Post ? (string) $attachment->post_title : ''; + $details = $attachment instanceof WP_Post ? $this->presenter->summary($attachment) : ['id' => $attachmentId]; + $summary = sprintf('Datei "%s" hochgeladen (ID %d)', $title, $attachmentId); + $logId = $this->log->record($this->name(), 'attachment', (string) $attachmentId, $summary); + + return ToolResult::ok($summary . '.') + ->changed((string) $attachmentId, 'attachment', 'created', $title) + ->logId($logId) + ->urls(null, (string) get_edit_post_link($attachmentId, 'raw')) + ->with('attachment', $details) + ->toArray(); + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: `abilities()` ersetzen durch +```php + public function abilities(): array + { + $presenter = new MediaPresenter(); + $finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys()); + $importer = new MediaImporter($this->limits); + + return [ + new MediaSearchAbility($presenter, $finder), + new MediaGetAbility($presenter, $finder), + new MediaPreviewAbility(), + new MediaUploadFromUrlAbility($importer, $presenter, $this->log), + new MediaUploadAbility($importer, $presenter, $this->log, $this->limits), + new MediaDownloadAbility($this->limits), + ]; + } +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS (lange Zeilen umbrechen). + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media upload by URL and base64 with whitelist and size limit + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 5: `lit/media-update` (Stapel) und `lit/media-delete` (mit Verwendungsprüfung) + +WordPress hat für Medien standardmäßig keinen Papierkorb (`MEDIA_TRASH` = false): `wp_delete_attachment($id, false)` legt nur bei aktivem Medien-Papierkorb in den Papierkorb, sonst löscht es endgültig. Deshalb verlangt das Tool immer `confirm: true` und sagt in der Rückfrage, was passieren wird. + +**Files:** +- Create: `src/Integrations/Media/MediaUpdateAbility.php`, `src/Integrations/Media/MediaDeleteAbility.php` +- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php` +- Test: `tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php`, `tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php` + +**Interfaces:** +- Consumes: `MediaGuard`, `MediaUsageFinder::usages()` (Task 1), `ChangeLog`, `ToolResult`, `Errors`. +- Produces: + - `MediaUpdateAbility(ChangeLog $log)` – `lit/media-update`, Input `{items: list<{id, alt?, title?, caption?, description?}>}` (1–50); Ergebnis-Extra `results: list<{id, ok, error: ?string, log_id: ?int}>`; indirekt; idempotent; Fehler einzelner Einträge brechen die anderen nicht ab. + - `MediaDeleteAbility(MediaUsageFinder $finder, ChangeLog $log, ?callable $mediaTrash = null)` – `lit/media-delete`, Input `{id, confirm?, force?}`; Ergebnis-Extra `usages`; indirekt; `destructive: true`, nicht idempotent; `changed.action` = `trashed` oder `deleted`. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php`: +```php +alias(static fn (int $id) => $id === 33 + ? new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_title' => 'Foto']) + : null); + Functions\when('current_user_can')->justReturn(true); + Functions\expect('wp_update_post')->once()->with(['post_excerpt' => 'Unser Team', 'ID' => 33], true)->andReturn(33); + Functions\expect('update_post_meta')->once()->with(33, '_wp_attachment_image_alt', 'Team vor dem Haus'); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->once()->andReturn(41); + + $result = (new MediaUpdateAbility($log))->execute(['items' => [ + ['id' => 33, 'alt' => 'Team vor dem Haus', 'caption' => 'Unser Team'], + ['id' => 99, 'alt' => 'x'], + ]]); + + self::assertSame(['id' => 33, 'ok' => true, 'error' => null, 'log_id' => 41], $result['results'][0]); + self::assertFalse($result['results'][1]['ok']); + self::assertStringContainsString('nicht gefunden', (string) $result['results'][1]['error']); + self::assertStringContainsString('1 von 2', $result['summary']); + } +} +``` + +`tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php`: +```php + 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured']]; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_title' => 'Foto'])); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(array $usages, ChangeLog $log): MediaDeleteAbility + { + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('usages')->andReturn($usages); + + return new MediaDeleteAbility($finder, $log, static fn (): bool => false); + } + + public function testConfirmationIsRequiredAndMentionsUsages(): void + { + Functions\expect('wp_delete_attachment')->never(); + + $result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertStringContainsString('endgültig', $result->get_error_message()); + self::assertSame(self::USAGE, $result->get_error_data()['usages']); + } + + public function testUsedMediaNeedsForce(): void + { + Functions\expect('wp_delete_attachment')->never(); + + $result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]); + + self::assertSame('lit_in_use', $result->get_error_code()); + } + + public function testForceDeletesUsedMedia(): void + { + Functions\expect('wp_delete_attachment')->once()->with(33, false)->andReturn(new WP_Post(['ID' => 33])); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-delete', 'attachment', '33', 'Medium "Foto" gelöscht')->andReturn(50); + + $result = $this->ability(self::USAGE, $log)->execute(['id' => 33, 'confirm' => true, 'force' => true]); + + self::assertSame('deleted', $result['changed'][0]['action']); + self::assertSame(self::USAGE, $result['usages']); + } + + public function testIsDestructive(): void + { + $ability = $this->ability([], Mockery::mock(ChangeLog::class)); + + self::assertTrue($ability->annotations()['destructive']); + self::assertFalse($ability->isDirect()); + } +} +``` + +In `MediaIntegrationTest` die Konstante ersetzen: +```php + private const EXPECTED = [ + 'lit/media-search', + 'lit/media-get', + 'lit/media-preview', + 'lit/media-upload-from-url', + 'lit/media-upload', + 'lit/media-download', + 'lit/media-update', + 'lit/media-delete', + ]; +``` + +- [ ] **Step 2: Run – muss fehlschlagen** + +Run: `composer test -- --filter 'MediaUpdateAbilityTest|MediaDeleteAbilityTest|MediaIntegrationTest'` +Expected: FAIL. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Media/MediaUpdateAbility.php`: +```php + 'post_title', 'caption' => 'post_excerpt', 'description' => 'post_content']; + + public function __construct(private ChangeLog $log) + { + } + + public function name(): string + { + return 'lit/media-update'; + } + + public function label(): string + { + return 'Medien-Metadaten ändern'; + } + + public function description(): string + { + return 'Ändert Alt-Text, Titel, Bildunterschrift und Beschreibung von bis zu 50 Medien in einem Aufruf. ' + . 'Das Ergebnis meldet jeden Eintrag einzeln (results); Fehler einzelner Einträge brechen die ' + . 'anderen nicht ab.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 50, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'alt' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'caption' => ['type' => 'string'], + 'description' => ['type' => 'string'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ], + ], + ], + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema([ + 'results' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'ok' => ['type' => 'boolean'], + 'error' => ['type' => ['string', 'null']], + 'log_id' => ['type' => ['integer', 'null']], + ], + 'required' => ['id', 'ok', 'error'], + ], + ], + ], ['results']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('upload_files') + ? true + : Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).'); + } + + public function execute(array $input): mixed + { + $items = is_array($input['items'] ?? null) ? $input['items'] : []; + $results = []; + $changed = []; + $lastLogId = null; + + foreach ($items as $item) { + $item = is_array($item) ? $item : []; + $outcome = $this->updateOne($item); + $results[] = $outcome['result']; + if ($outcome['result']['ok']) { + $changed[] = [(string) $outcome['result']['id'], $outcome['title']]; + $lastLogId = $outcome['result']['log_id']; + } + } + + $result = ToolResult::ok(sprintf('%d von %d Medien aktualisiert.', count($changed), count($items))); + foreach ($changed as [$id, $title]) { + $result->changed($id, 'attachment', 'updated', $title); + } + + return $result->logId($lastLogId)->with('results', $results)->toArray(); + } + + /** + * @param array $item + * @return array{result: array{id: int, ok: bool, error: ?string, log_id: ?int}, title: string} + */ + private function updateOne(array $item): array + { + $id = (int) ($item['id'] ?? 0); + $attachment = MediaGuard::attachment($item['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $this->failure($id, $attachment); + } + + $data = []; + foreach (self::FIELDS as $key => $column) { + if (array_key_exists($key, $item)) { + $data[$column] = (string) $item[$key]; + } + } + + if ($data === [] && ! array_key_exists('alt', $item)) { + return $this->failure($id, Errors::invalidInput('Nichts zu ändern.')); + } + + if ($data !== []) { + $updated = wp_update_post(wp_slash(array_merge($data, ['ID' => $id])), true); + if ($updated instanceof WP_Error) { + return $this->failure($id, Errors::invalidInput($updated->get_error_message())); + } + } + + if (array_key_exists('alt', $item)) { + update_post_meta($id, '_wp_attachment_image_alt', sanitize_text_field((string) $item['alt'])); + } + + $keys = implode(', ', array_keys(array_intersect_key($item, self::FIELDS + ['alt' => true]))); + $logId = $this->log->record( + $this->name(), + 'attachment', + (string) $id, + sprintf('Metadaten von "%s" geändert: %s', $attachment->post_title, $keys) + ); + + return [ + 'result' => ['id' => $id, 'ok' => true, 'error' => null, 'log_id' => $logId], + 'title' => (string) ($item['title'] ?? $attachment->post_title), + ]; + } + + /** @return array{result: array{id: int, ok: bool, error: ?string, log_id: ?int}, title: string} */ + private function failure(int $id, WP_Error $error): array + { + $payload = json_decode($error->get_error_message(), true); + $message = is_array($payload) ? (string) ($payload['message'] ?? '') : $error->get_error_message(); + + return ['result' => ['id' => $id, 'ok' => false, 'error' => $message, 'log_id' => null], 'title' => '']; + } +} +``` + +`src/Integrations/Media/MediaDeleteAbility.php`: +```php +mediaTrash = $mediaTrash ?? static fn (): bool => defined('MEDIA_TRASH') && MEDIA_TRASH + && (! defined('EMPTY_TRASH_DAYS') || EMPTY_TRASH_DAYS); + } + + public function name(): string + { + return 'lit/media-delete'; + } + + public function label(): string + { + return 'Medium löschen'; + } + + public function description(): string + { + return 'Löscht ein Medium (ohne aktivierten Medien-Papierkorb endgültig). Nur nach Rückfrage mit ' + . 'confirm: true; wird das Medium noch verwendet, lehnt das Tool ab, außer force: true.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'confirm' => ['type' => 'boolean'], + 'force' => ['type' => 'boolean', 'description' => 'Trotz Verwendung löschen.'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['usages' => ['type' => 'array', 'items' => ['type' => 'object']]], ['usages']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post'); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post'); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + $title = (string) $attachment->post_title; + $trash = ($this->mediaTrash)(); + $usages = $this->finder->usages($id); + + if (($input['confirm'] ?? false) !== true) { + $what = $trash ? 'in den Papierkorb legen' : 'endgültig löschen'; + $message = sprintf('Medium "%s" (ID %d) %s?', $title, $id, $what); + if ($usages !== []) { + $message .= sprintf(' Es wird an %d Stelle(n) verwendet.', count($usages)); + } + + return Errors::confirmRequired($message, ['usages' => $usages]); + } + + if ($usages !== [] && ($input['force'] ?? false) !== true) { + return Errors::inUse(sprintf('Medium "%s" wird noch verwendet; nichts wurde gelöscht.', $title), $usages); + } + + $deleted = wp_delete_attachment($id, false); + if (! $deleted) { + return Errors::unsupported('WordPress hat das Löschen abgelehnt.', 'Medium in der Mediathek prüfen.'); + } + + $summary = sprintf('Medium "%s" %s', $title, $trash ? 'in den Papierkorb gelegt' : 'gelöscht'); + $logId = $this->log->record($this->name(), 'attachment', (string) $id, $summary); + + return ToolResult::ok($summary . '.') + ->changed((string) $id, 'attachment', $trash ? 'trashed' : 'deleted', $title) + ->logId($logId) + ->with('usages', $usages) + ->toArray(); + } +} +``` + +`src/Integrations/Media/MediaIntegration.php`: in `abilities()` nach `new MediaDownloadAbility($this->limits),` einfügen: +```php + new MediaUpdateAbility($this->log), + new MediaDeleteAbility($finder, $this->log), +``` + +- [ ] **Step 4: Run – muss bestehen** + +Run: `composer test && composer lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Integrations/Media tests/Unit/Integrations/Media +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: batch media metadata updates and delete with usage check + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- +### Task 6: Expositions-Policy – direkt / indirekt / deaktiviert je Ability + +Aus Etappe 0 offen (a): Welche Abilities direkte Tools, nur über die Meta-Tools erreichbar oder ganz abgeschaltet sind, entscheidet ab jetzt `ExposurePolicy` (Option `lit_mcp_exposure`, gepflegt im Tab „Tools“, Task 10). `Ability::isDirect()` bleibt der Standard. Deaktivierte Abilities werden gar nicht erst bei der Abilities API registriert – damit sind sie weder als Tool noch über `mcp-adapter-execute-ability` erreichbar. `lit/media-preview` liefert Bilder und funktioniert nur direkt; `lit/guide` ist die Einstiegshilfe und lässt sich nicht deaktivieren. + +**Files:** +- Create: `src/Abilities/ExposurePolicy.php` +- Modify: `src/Abilities/AbilityRegistrar.php` (Konstruktor, `registerAbilities()`), `src/Server/ServerRegistrar.php` (Konstruktor, `serverArguments()`), `src/Integrations/IntegrationRegistry.php` (`directAbilityNames()` entfernen), `src/Plugin.php` (`boot()`) +- Test: `tests/Unit/Abilities/ExposurePolicyTest.php`, `tests/Unit/Abilities/AbilityRegistrarTest.php` (Test ergänzen), `tests/Unit/Server/ServerRegistrarTest.php` (Test ergänzen), `tests/Unit/Integrations/IntegrationRegistryTest.php` (Assertion entfernen) + +**Interfaces:** +- Consumes: `Ability::name()/isDirect()`, `IntegrationRegistry::abilities()`. +- Produces: + - `ExposurePolicy` (final): `OPTION = 'lit_mcp_exposure'`; `DIRECT = 'direct'`, `INDIRECT = 'indirect'`, `DISABLED = 'disabled'`, `MODES`; `DIRECT_ONLY = ['lit/media-preview']`; `REQUIRED = ['lit/guide']`; `__construct(array $overrides = [])`; `static fromOption(): self`; `mode(Ability $a): string`; `defaultMode(Ability $a): string`; `enabled(list $abilities): list`; `directNames(list $abilities): list` (Reihenfolge der Registry); `static isAllowed(string $name, string $mode): bool`; `static sanitize(mixed $input): array` (Settings-API-Callback). + - Geänderte Signaturen: `AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null)`; `ServerRegistrar::__construct(IntegrationRegistry $registry, InstructionsBuilder $instructions, ?callable $oauthEnabled = null, ?callable $createAdapterServer = null, ?callable $classExists = null, ?ExposurePolicy $policy = null)` (neuer letzter Parameter; ohne Policy gilt `new ExposurePolicy()` = `isDirect()`). + - Entfernt: `IntegrationRegistry::directAbilityNames()` (einziger Aufrufer war `ServerRegistrar`). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Abilities/ExposurePolicyTest.php`: +```php +allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn($direct); + + return $ability; + } + + public function testDefaultFollowsIsDirect(): void + { + $policy = new ExposurePolicy(); + + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testOverridesChangeTheMode(): void + { + $policy = new ExposurePolicy(['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled']); + + self::assertSame('indirect', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('disabled', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testImpossibleOrBrokenOverridesFallBackToTheDefault(): void + { + $policy = new ExposurePolicy([ + 'lit/media-preview' => 'indirect', + 'lit/guide' => 'disabled', + 'lit/content-get' => 'sometimes', + ]); + + self::assertSame('direct', $policy->mode($this->ability('lit/media-preview', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + } + + public function testEnabledAndDirectNamesKeepTheRegistryOrder(): void + { + $a = $this->ability('lit/a', true); + $b = $this->ability('lit/b', false); + $c = $this->ability('lit/c', true); + $policy = new ExposurePolicy(['lit/b' => 'direct', 'lit/c' => 'disabled']); + + self::assertSame([$a, $b], $policy->enabled([$a, $b, $c])); + self::assertSame(['lit/a', 'lit/b'], $policy->directNames([$a, $b, $c])); + } + + public function testSanitizeKeepsOnlyValidEntries(): void + { + self::assertSame( + ['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled'], + ExposurePolicy::sanitize([ + 'lit/menu-list' => 'disabled', + 'lit/content-get' => 'indirect', + 'lit/guide' => 'disabled', + ''], + 'vbscript' => ['vbscript:msgbox(1)'], + 'protocol-relative' => ['//evil.test/'], + 'protocol-relative backslash' => ['/\\evil.test/'], + 'leading space' => [' https://example.test/'], + 'control character' => ["https://example.test/\x00"], + ]; + } + + /** @dataProvider badUrls */ + public function testDangerousUrlsAreRejected(string $url): void + { + $plan = (new MenuWriter())->plan([['type' => 'custom', 'url' => $url, 'title' => 'X']], []); + + self::assertStringContainsString('url', $plan['errors']['items.0']); + } + + public function testSafeUrlsAreAccepted(): void + { + $plan = (new MenuWriter())->plan([ + ['type' => 'custom', 'url' => '/kontakt', 'title' => 'A'], + ['type' => 'custom', 'url' => '#', 'title' => 'B'], + ['type' => 'custom', 'url' => 'HTTPS://example.test/', 'title' => 'C'], + ['type' => 'custom', 'url' => 'mailto:info@example.test', 'title' => 'D'], + ['type' => 'custom', 'url' => 'tel:+4912345', 'title' => 'E'], + ], []); + + self::assertSame([], $plan['errors']); + } + public function testIdZeroMeansANewItem(): void { $plan = (new MenuWriter())->plan([['id' => 0, 'type' => 'custom', 'url' => '#', 'title' => 'Mehr']], []); @@ -160,22 +236,34 @@ final class MenuWriterTest extends TestCase public function testMenuGetOutputRoundTripsAsANoOp(): void { Functions\expect('get_post')->never(); + Functions\expect('wp_update_nav_menu_item')->never(); + Functions\expect('wp_delete_post')->never(); $current = $this->realistic(); $tree = json_decode((string) json_encode(MenuTree::build(array_values($current))), true); - $calls = []; - Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) { - $calls[$id] = $args; - return $id; - }); - Functions\expect('wp_delete_post')->never(); $writer = new MenuWriter(); $plan = $writer->plan($tree, $current); $stats = $writer->apply(3, $tree, $current); self::assertSame(['errors' => [], 'removals' => [], 'warnings' => []], $plan); + self::assertSame(['created' => 0, 'updated' => 0, 'removed' => 0], $stats); + } + + public function testOnlyMovedItemsAreRewrittenAndKeepTheirData(): void + { + $current = $this->realistic(); + $calls = []; + Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) { + $calls[$id] = $args; + return $id; + }); + + // Kontakt(13) moves to the front: every item's position shifts by one. + $tree = MenuTree::build(array_values($current)); + array_unshift($tree, array_pop($tree)); + $stats = (new MenuWriter())->apply(3, $tree, $current); + self::assertSame(['created' => 0, 'updated' => 4, 'removed' => 0], $stats); - self::assertSame([10, 11, 12, 13], array_keys($calls)); foreach ($current as $id => $item) { self::assertSame($item->post_title, $calls[$id]['menu-item-title'], "title of $id"); self::assertSame((int) $item->menu_item_parent, $calls[$id]['menu-item-parent-id'], "parent of $id"); @@ -183,7 +271,27 @@ final class MenuWriterTest extends TestCase } self::assertSame('', $calls[10]['menu-item-url']); self::assertSame('/kontakt', $calls[13]['menu-item-url']); - self::assertSame([1, 2, 3, 4], array_column($calls, 'menu-item-position')); + self::assertSame([1, 2, 3, 4], [$calls[13]['menu-item-position'], $calls[10]['menu-item-position'], + $calls[11]['menu-item-position'], $calls[12]['menu-item-position']]); + } + + public function testMovingOnlyTheLastItemUnderAnotherRewritesOnlyThatItem(): void + { + $current = $this->realistic(); + $calls = []; + Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) { + $calls[$id] = $args; + return $id; + }); + + $stats = (new MenuWriter())->apply(3, [ + ['id' => 10], + ['id' => 11, 'children' => [['id' => 12], ['id' => 13]]], + ], $current); + + self::assertSame(['created' => 0, 'updated' => 1, 'removed' => 0], $stats); + self::assertSame([13], array_keys($calls)); + self::assertSame(11, $calls[13]['menu-item-parent-id']); } public function testChangedTitleOfAnExistingItemBecomesItsLabel(): void @@ -201,8 +309,8 @@ final class MenuWriterTest extends TestCase ['id' => 13, 'title' => 'Kontakt', 'url' => 'mailto:info@example.test'], ], $current); + self::assertSame([10, 13], array_keys($calls)); self::assertSame('Startseite', $calls[10]['menu-item-title']); - self::assertSame('Wir über uns', $calls[11]['menu-item-title']); self::assertSame('mailto:info@example.test', $calls[13]['menu-item-url']); } From 9cc9d4f2dcf7413761dfa459218ae3ccd271fa58 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 00:34:26 +0200 Subject: [PATCH 043/185] feat: whitelisted WordPress settings tools Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Settings/SettingsWhitelist.php | 227 ++++++++++++++++++ .../Settings/WpSettingsGetAbility.php | 103 ++++++++ .../Settings/WpSettingsUpdateAbility.php | 227 ++++++++++++++++++ .../WordPress/WordPressIntegration.php | 6 + .../Settings/SettingsWhitelistTest.php | 82 +++++++ .../Settings/WpSettingsAbilitiesTest.php | 135 +++++++++++ .../WordPress/WordPressIntegrationTest.php | 2 + 7 files changed, 782 insertions(+) create mode 100644 src/Integrations/WordPress/Settings/SettingsWhitelist.php create mode 100644 src/Integrations/WordPress/Settings/WpSettingsGetAbility.php create mode 100644 src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php create mode 100644 tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php create mode 100644 tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php diff --git a/src/Integrations/WordPress/Settings/SettingsWhitelist.php b/src/Integrations/WordPress/Settings/SettingsWhitelist.php new file mode 100644 index 0000000..6639939 --- /dev/null +++ b/src/Integrations/WordPress/Settings/SettingsWhitelist.php @@ -0,0 +1,227 @@ + [group, type, description] */ + public const SETTINGS = [ + 'blogname' => ['general', 'string', 'Titel der Website'], + 'blogdescription' => ['general', 'string', 'Untertitel der Website'], + 'WPLANG' => ['general', 'string', 'Sprache (Locale, z. B. de_DE; en_US = Englisch); nur installierte Sprachen'], + 'timezone_string' => ['general', 'string', 'Zeitzone, z. B. Europe/Berlin'], + 'date_format' => ['general', 'string', 'Datumsformat (PHP), z. B. j. F Y'], + 'time_format' => ['general', 'string', 'Zeitformat (PHP), z. B. H:i'], + 'show_on_front' => ['reading', 'string', 'Startseite: posts (neueste Beiträge) oder page (statische Seite)'], + 'page_on_front' => ['reading', 'integer', 'ID der statischen Startseite (0 = keine)'], + 'page_for_posts' => ['reading', 'integer', 'ID der Beitragsseite (0 = keine)'], + 'posts_per_page' => ['reading', 'integer', 'Beiträge pro Seite (1–100)'], + 'default_comment_status' => ['discussion', 'string', 'Kommentare für neue Beiträge: open oder closed'], + 'default_ping_status' => ['discussion', 'string', 'Pingbacks für neue Beiträge: open oder closed'], + 'permalink_structure' => ['permalinks', 'string', 'Permalink-Struktur, z. B. /%postname%/ (leer = einfach)'], + 'site_icon' => ['site_icon', 'integer', 'Anhang-ID des Website-Icons (Bild, empfohlen 512×512; 0 = keins)'], + ]; + + public const BLOCKED = ['admin_email', 'users_can_register', 'default_role', 'siteurl', 'home']; + public const GROUPS = ['general', 'reading', 'discussion', 'permalinks', 'site_icon']; + + private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon']; + + public function current(string $key): mixed + { + $value = get_option($key); + if ($key === 'WPLANG') { + return $value === '' || $value === false ? 'en_US' : (string) $value; + } + + if (in_array($key, self::INTEGER_KEYS, true)) { + return (int) $value; + } + + return (string) $value; + } + + /** + * @param array $pending All values of the current call. + * @return string|null Error message, null when valid. + */ + public function validate(string $key, mixed $value, array $pending): ?string + { + if (in_array($key, self::BLOCKED, true)) { + return 'Gesperrt: aus Sicherheitsgründen nicht über LIT MCP änderbar.'; + } + + if (! isset(self::SETTINGS[$key])) { + return 'Nicht freigegeben; erlaubt: ' . implode(', ', array_keys(self::SETTINGS)) . '.'; + } + + return match ($key) { + 'blogname', 'blogdescription' => $this->checkText($value), + 'WPLANG' => $this->checkLanguage($value), + 'timezone_string' => $this->checkTimezone($value), + 'date_format', 'time_format' => $this->checkFormat($value), + 'show_on_front' => $this->checkShowOnFront($value, $pending), + 'page_on_front', 'page_for_posts' => $this->checkPage($key, $value, $pending), + 'posts_per_page' => $this->checkPostsPerPage($value), + 'default_comment_status', 'default_ping_status' => $this->checkOpenClosed($value), + 'permalink_structure' => $this->checkPermalink($value), + 'site_icon' => $this->checkSiteIcon($value), + }; + } + + public function normalize(string $key, mixed $value): mixed + { + if ($key === 'WPLANG' && $value === 'en_US') { + return ''; + } + + return $value; + } + + private function checkText(mixed $value): ?string + { + if (is_string($value) && mb_strlen($value) <= 200) { + return null; + } + + return 'Text bis 200 Zeichen erwartet.'; + } + + private function checkLanguage(mixed $value): ?string + { + if ($value === 'en_US' || $value === '') { + return null; + } + + $installed = get_available_languages(); + if (is_string($value) && in_array($value, $installed, true)) { + return null; + } + + return sprintf('Sprache nicht installiert; verfügbar: en_US, %s.', implode(', ', $installed)); + } + + private function checkTimezone(mixed $value): ?string + { + if (is_string($value) && in_array($value, timezone_identifiers_list(), true)) { + return null; + } + + return 'Zeitzonen-Kennung wie Europe/Berlin erwartet.'; + } + + private function checkFormat(mixed $value): ?string + { + if (is_string($value) && trim($value) !== '' && mb_strlen($value) <= 50) { + return null; + } + + return 'Nicht leeres Format bis 50 Zeichen erwartet.'; + } + + /** @param array $pending */ + private function checkShowOnFront(mixed $value, array $pending): ?string + { + if ($value === 'posts') { + return null; + } + + if ($value !== 'page') { + return 'posts oder page erwartet.'; + } + + $front = (int) ($pending['page_on_front'] ?? get_option('page_on_front')); + if ($front > 0) { + return null; + } + + return 'Für page zuerst page_on_front setzen (im selben Aufruf möglich).'; + } + + /** @param array $pending */ + private function checkPage(string $key, mixed $value, array $pending): ?string + { + if (! is_int($value) || $value < 0) { + return 'Seiten-ID (0 = keine) erwartet.'; + } + + if ($value === 0) { + return null; + } + + $page = get_post($value); + if (! $page instanceof WP_Post || $page->post_type !== 'page' || $page->post_status !== 'publish') { + return sprintf('%d ist keine veröffentlichte Seite.', $value); + } + + $other = $key === 'page_on_front' ? 'page_for_posts' : 'page_on_front'; + $otherValue = (int) ($pending[$other] ?? get_option($other)); + if ($otherValue === $value) { + return 'Startseite und Beitragsseite müssen verschiedene Seiten sein.'; + } + + return null; + } + + private function checkPostsPerPage(mixed $value): ?string + { + if (is_int($value) && $value >= 1 && $value <= 100) { + return null; + } + + return 'Ganzzahl von 1 bis 100 erwartet.'; + } + + private function checkOpenClosed(mixed $value): ?string + { + if (in_array($value, ['open', 'closed'], true)) { + return null; + } + + return 'open oder closed erwartet.'; + } + + private function checkPermalink(mixed $value): ?string + { + if ($value === '') { + return null; + } + + if ( + is_string($value) + && str_starts_with($value, '/') + && str_contains($value, '%') + && mb_strlen($value) <= 200 + && preg_match('/\s/', $value) !== 1 + ) { + return null; + } + + return 'Leer oder eine Struktur wie /%postname%/ erwartet.'; + } + + private function checkSiteIcon(mixed $value): ?string + { + if (! is_int($value) || $value < 0) { + return 'Anhang-ID (0 = keins) erwartet.'; + } + + if ($value === 0 || wp_attachment_is_image($value)) { + return null; + } + + return sprintf('%d ist kein Bild der Mediathek.', $value); + } +} diff --git a/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php b/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php new file mode 100644 index 0000000..71dd857 --- /dev/null +++ b/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php @@ -0,0 +1,103 @@ + 'object', + 'properties' => ['group' => ['type' => 'string', 'enum' => SettingsWhitelist::GROUPS]], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'settings' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'key' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'value' => ['type' => ['string', 'integer']], + 'description' => ['type' => 'string'], + ], + 'required' => ['key', 'group', 'type', 'value', 'description'], + ], + ], + ], + 'required' => ['settings'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('manage_options') + ? true + : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); + } + + public function execute(array $input): mixed + { + $group = isset($input['group']) ? (string) $input['group'] : ''; + $settings = []; + foreach (SettingsWhitelist::SETTINGS as $key => [$settingGroup, $type, $description]) { + if ($group !== '' && $settingGroup !== $group) { + continue; + } + + $settings[] = [ + 'key' => $key, + 'group' => $settingGroup, + 'type' => $type, + 'value' => $this->whitelist->current($key), + 'description' => $description, + ]; + } + + return ['settings' => $settings]; + } +} diff --git a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php new file mode 100644 index 0000000..7ba3072 --- /dev/null +++ b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php @@ -0,0 +1,227 @@ + 'object', + 'properties' => [ + 'settings' => [ + 'type' => 'object', + 'description' => 'Schlüssel → neuer Wert, mindestens einer, z. B. {"blogname": "Praxis Muster"}.', + ], + ], + 'required' => ['settings'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['previous' => ['type' => 'object']], ['previous']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('manage_options') + ? true + : Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).'); + } + + public function execute(array $input): mixed + { + $settings = is_array($input['settings'] ?? null) ? $input['settings'] : []; + if ($settings === []) { + return Errors::invalidInput('Keine Einstellungen angegeben.', ['settings' => 'mindestens ein Schlüssel']); + } + + $errors = $this->validateAll($settings); + if ($errors !== []) { + return Errors::invalidInput( + 'Einstellungen ungültig; nichts wurde gespeichert.', + $errors, + 'Werte gemäß fields korrigieren; aktuelle Werte über lit-wp-settings-get.' + ); + } + + return $this->write($settings); + } + + /** + * @param array $settings + * @return array + */ + private function validateAll(array $settings): array + { + $errors = []; + foreach ($settings as $key => $value) { + $error = $this->whitelist->validate((string) $key, $value, $settings); + if ($error !== null) { + $errors[(string) $key] = $error; + } + } + + return $errors; + } + + /** + * All values are already known-valid; writes only the ones that actually change. + * If a write fails partway through, the already-written keys are restored to their + * previous values so the call stays all-or-nothing. + * + * @param array $settings + */ + private function write(array $settings): array|WP_Error + { + $previous = []; + foreach ($settings as $key => $value) { + $previous[(string) $key] = $this->whitelist->current((string) $key); + } + + $changed = []; + try { + foreach ($settings as $key => $value) { + $key = (string) $key; + if ($previous[$key] === $value) { + continue; + } + + $this->writeOne($key, $value); + $changed[] = $key; + } + } catch (Throwable $e) { + return $this->fail($changed, $previous, $e); + } + + if ($changed === []) { + return ToolResult::ok('Keine Änderung – alle Werte waren bereits gesetzt.') + ->with('previous', JsonMap::of($previous)) + ->toArray(); + } + + $summary = 'Einstellungen geändert: ' . implode(', ', $changed); + $logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary); + + $result = ToolResult::ok($summary . '.'); + foreach ($changed as $key) { + $result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]); + } + + return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray(); + } + + private function writeOne(string $key, mixed $value): void + { + if ($key === 'permalink_structure') { + $this->setPermalinkStructure((string) $value); + + return; + } + + if (update_option($key, $this->whitelist->normalize($key, $value)) === false) { + throw new RuntimeException(sprintf('update_option(%s) returned false', $key)); + } + } + + /** + * A write failed; restores the keys already written to their previous values and + * reports write_failed (fully restored) or partial_write (some keys could not be + * restored – the client is told which, so it can check and correct them). + * + * @param list $changed Keys written so far, in write order. + * @param array $previous Their values before this call. + */ + private function fail(array $changed, array $previous, Throwable $cause): WP_Error + { + $stuck = []; + foreach (array_reverse($changed) as $key) { + try { + $this->writeOne($key, $previous[$key]); + } catch (Throwable $e) { + $stuck[] = $key; + } + } + + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + sprintf('[wp-lit-mcp] wp-settings-update failed: %s', $cause) + ); + + if ($stuck === []) { + return Errors::writeFailed('Eine Einstellung konnte nicht gespeichert werden.'); + } + + return Errors::partialWrite( + 'Eine Einstellung konnte nicht gespeichert werden.', + null, + null, + sprintf( + 'Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', + implode(', ', $stuck) + ), + 'Aktuelle Werte über lit-wp-settings-get prüfen und ggf. erneut auf die gewünschten Werte setzen.' + ); + } + + private function setPermalinkStructure(string $structure): void + { + global $wp_rewrite; + $wp_rewrite->set_permalink_structure($structure); + flush_rewrite_rules(); + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index a682a28..b08912d 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -25,6 +25,9 @@ use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldValidator; use LIT\MCP\Integrations\WordPress\Schema\TermAssigner; +use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist; +use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility; +use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility; use LIT\MCP\Integrations\WordPress\Status\PostCloner; use LIT\MCP\Integrations\WordPress\Status\PostDuplicateAbility; use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility; @@ -58,6 +61,7 @@ final class WordPressIntegration implements Integration $presenter = new PostPresenter($this->schema); $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner()); $cloner = new PostCloner(); + $whitelist = new SettingsWhitelist(); return [ new ContentTypesAbility($this->schema), @@ -78,6 +82,8 @@ final class WordPressIntegration implements Integration new MenuCreateAbility($this->log), new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $this->log), new MenuAssignLocationAbility($this->log), + new WpSettingsGetAbility($whitelist), + new WpSettingsUpdateAbility($whitelist, $this->log), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php new file mode 100644 index 0000000..ea11f92 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php @@ -0,0 +1,82 @@ +validate('admin_email', 'x@y.de', [])); + self::assertStringStartsWith('Nicht freigegeben', (string) $whitelist->validate('template', 'twentyten', [])); + } + + public function testStaticFrontPageNeedsAPublishedDistinctPage(): void + { + Functions\when('get_option')->justReturn(0); + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish']), + 6 => new WP_Post(['ID' => 6, 'post_type' => 'page', 'post_status' => 'draft']), + default => null, + }); + $whitelist = new SettingsWhitelist(); + + self::assertNotNull($whitelist->validate('show_on_front', 'page', [])); + self::assertNull($whitelist->validate('show_on_front', 'page', ['page_on_front' => 5])); + self::assertNull($whitelist->validate('page_on_front', 5, [])); + self::assertNotNull($whitelist->validate('page_on_front', 6, [])); + self::assertNotNull($whitelist->validate('page_on_front', 5, ['page_for_posts' => 5])); + self::assertNull($whitelist->validate('page_for_posts', 0, [])); + } + + public function testFormatsLanguagesAndRanges(): void + { + Functions\when('get_available_languages')->justReturn(['de_DE']); + Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id === 33); + $whitelist = new SettingsWhitelist(); + + self::assertNull($whitelist->validate('WPLANG', 'de_DE', [])); + self::assertNull($whitelist->validate('WPLANG', 'en_US', [])); + self::assertStringContainsString('de_DE', (string) $whitelist->validate('WPLANG', 'fr_FR', [])); + self::assertNull($whitelist->validate('timezone_string', 'Europe/Berlin', [])); + self::assertNotNull($whitelist->validate('timezone_string', 'Mars/Base', [])); + self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', [])); + self::assertNull($whitelist->validate('permalink_structure', '', [])); + self::assertNotNull($whitelist->validate('permalink_structure', 'postname', [])); + self::assertNotNull($whitelist->validate('posts_per_page', 0, [])); + self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', [])); + self::assertNull($whitelist->validate('site_icon', 33, [])); + self::assertNotNull($whitelist->validate('site_icon', 34, [])); + self::assertSame('', $whitelist->normalize('WPLANG', 'en_US')); + } + + public function testCurrentNormalizesLanguageAndNumbers(): void + { + Functions\when('get_option')->alias(static fn (string $key) => ['WPLANG' => '', 'page_on_front' => '5'][$key]); + $whitelist = new SettingsWhitelist(); + + self::assertSame('en_US', $whitelist->current('WPLANG')); + self::assertSame(5, $whitelist->current('page_on_front')); + } +} diff --git a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php new file mode 100644 index 0000000..2225125 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php @@ -0,0 +1,135 @@ +justReturn('Kunde'); + + $result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']); + + self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key')); + self::assertSame('discussion', $result['settings'][0]['group']); + } + + public function testUpdateIsAllOrNothing(): void + { + Functions\expect('update_option')->never(); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class))) + ->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']); + } + + public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void + { + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'blogdescription' => 'Gleich', + 'permalink_structure' => '', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu'); + $rewrite = Mockery::mock(); + $rewrite->expects('set_permalink_structure')->with('/%postname%/'); + $GLOBALS['wp_rewrite'] = $rewrite; + Functions\expect('flush_rewrite_rules')->once(); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/wp-settings-update', 'option', 'blogname,permalink_structure', Mockery::type('string'))->andReturn(30); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'blogdescription' => 'Gleich', + 'permalink_structure' => '/%postname%/', + ]]); + + self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id')); + self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']); + self::assertSame(30, $result['log_id']); + } + + public function testRestoresPreviousValuesWhenAWriteFailsPartway(): void + { + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'blogdescription' => 'Vorher', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true); + Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false); + Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(true); + $log = Mockery::mock(ChangeLog::class); + $log->shouldNotReceive('record'); + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + + try { + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'blogdescription' => 'Auch neu', + ]]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertSame('lit_write_failed', $result->get_error_code()); + } + + public function testReportsPartialWriteWhenRestoringAlsoFails(): void + { + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'blogdescription' => 'Vorher', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true); + Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false); + Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(false); + $log = Mockery::mock(ChangeLog::class); + $log->shouldNotReceive('record'); + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + + try { + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'blogdescription' => 'Auch neu', + ]]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertSame('lit_partial_write', $result->get_error_code()); + self::assertStringContainsString('blogname', $result->get_error_message()); + } + + public function testSettingsToolsNeedManageOptions(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options'); + + self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code()); + self::assertSame( + 'lit_forbidden', + (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class)))->permission([])->get_error_code() + ); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index 6e59c55..1c8fb28 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -33,6 +33,8 @@ final class WordPressIntegrationTest extends TestCase 'lit/menu-create', 'lit/menu-set-items', 'lit/menu-assign-location', + 'lit/wp-settings-get', + 'lit/wp-settings-update', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void From fc216e3eafe68de26ad50fe23e16ce3fb5e392e9 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 00:47:35 +0200 Subject: [PATCH 044/185] fix: verify option writes before treating update_option() false as failure Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Settings/SettingsWhitelist.php | 4 +- .../Settings/WpSettingsUpdateAbility.php | 31 +++++++- .../Settings/SettingsWhitelistTest.php | 11 +++ .../Settings/WpSettingsAbilitiesTest.php | 75 +++++++++++++++++-- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/Integrations/WordPress/Settings/SettingsWhitelist.php b/src/Integrations/WordPress/Settings/SettingsWhitelist.php index 6639939..1a239b8 100644 --- a/src/Integrations/WordPress/Settings/SettingsWhitelist.php +++ b/src/Integrations/WordPress/Settings/SettingsWhitelist.php @@ -101,7 +101,9 @@ final class SettingsWhitelist private function checkLanguage(mixed $value): ?string { - if ($value === 'en_US' || $value === '') { + // Clients say en_US for English; '' is WordPress' internal storage form for it + // (see normalize()) and is not accepted as input. + if ($value === 'en_US') { return null; } diff --git a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php index 7ba3072..26451c4 100644 --- a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php +++ b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php @@ -138,7 +138,7 @@ final class WpSettingsUpdateAbility implements Ability try { foreach ($settings as $key => $value) { $key = (string) $key; - if ($previous[$key] === $value) { + if ($this->isStored($key, $this->whitelist->normalize($key, $value))) { continue; } @@ -168,17 +168,34 @@ final class WpSettingsUpdateAbility implements Ability private function writeOne(string $key, mixed $value): void { + $normalized = $this->whitelist->normalize($key, $value); + if ($key === 'permalink_structure') { - $this->setPermalinkStructure((string) $value); + $this->setPermalinkStructure((string) $normalized); return; } - if (update_option($key, $this->whitelist->normalize($key, $value)) === false) { - throw new RuntimeException(sprintf('update_option(%s) returned false', $key)); + // update_option() returns false both for a genuine failure and for a value + // that already matches the stored option (WordPress core short-circuits the + // write in that case, see wp-includes/option.php) – get_option() afterwards + // tells the two apart, the same way MetaFieldSource::holds() does for meta. + if (update_option($key, $normalized) === false && ! $this->isStored($key, $normalized)) { + throw new RuntimeException(sprintf('update_option(%s) failed', $key)); } } + /** Whether option $key already holds $normalized, compared like WordPress compares scalars. */ + private function isStored(string $key, mixed $normalized): bool + { + $stored = get_option($key); + if (is_scalar($stored) && is_scalar($normalized)) { + return (string) $stored === (string) $normalized; + } + + return $stored == $normalized; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + } + /** * A write failed; restores the keys already written to their previous values and * reports write_failed (fully restored) or partial_write (some keys could not be @@ -223,5 +240,11 @@ final class WpSettingsUpdateAbility implements Ability global $wp_rewrite; $wp_rewrite->set_permalink_structure($structure); flush_rewrite_rules(); + + // set_permalink_structure() has no return value to check; verify the option + // itself so a failure here is caught and rolled back like any other setting. + if (! $this->isStored('permalink_structure', $structure)) { + throw new RuntimeException('set_permalink_structure() did not update the stored value.'); + } } } diff --git a/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php index ea11f92..e1f55f2 100644 --- a/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php +++ b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php @@ -71,6 +71,17 @@ final class SettingsWhitelistTest extends TestCase self::assertSame('', $whitelist->normalize('WPLANG', 'en_US')); } + public function testWplangAcceptsOnlyTheEnUsSentinelNotTheRawEmptyString(): void + { + // '' is WordPress' internal storage form for English (see current()/normalize()); + // clients must say en_US, never the internal '', which is also not an installed + // language code and would otherwise be rejected as "not installed" anyway. + Functions\when('get_available_languages')->justReturn(['de_DE']); + $whitelist = new SettingsWhitelist(); + + self::assertNotNull($whitelist->validate('WPLANG', '', [])); + } + public function testCurrentNormalizesLanguageAndNumbers(): void { Functions\when('get_option')->alias(static fn (string $key) => ['WPLANG' => '', 'page_on_front' => '5'][$key]); diff --git a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php index 2225125..f250978 100644 --- a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php +++ b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php @@ -41,16 +41,41 @@ final class WpSettingsAbilitiesTest extends TestCase self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']); } + public function testRejectsAGenuinelyUnknownKey(): void + { + Functions\expect('update_option')->never(); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), Mockery::mock(ChangeLog::class))) + ->execute(['settings' => ['template' => 'twentyten']]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('template', $result->get_error_data()['fields']); + } + public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void { - Functions\when('get_option')->alias(static fn (string $key) => [ + // A mutating stub: writeOne() verifies a write via get_option() afterwards, so + // the stub must actually reflect what update_option()/set_permalink_structure() + // "wrote", not just return a fixed value. + $options = [ 'blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => '', - ][$key]); - Functions\expect('update_option')->once()->with('blogname', 'Neu'); + ]; + Functions\when('get_option')->alias(static function (string $key) use (&$options) { + return $options[$key]; + }); + Functions\expect('update_option')->once()->with('blogname', 'Neu') + ->andReturnUsing(static function (string $key, $value) use (&$options): bool { + $options[$key] = $value; + + return true; + }); $rewrite = Mockery::mock(); - $rewrite->expects('set_permalink_structure')->with('/%postname%/'); + $rewrite->expects('set_permalink_structure')->with('/%postname%/') + ->andReturnUsing(static function (string $structure) use (&$options): void { + $options['permalink_structure'] = $structure; + }); $GLOBALS['wp_rewrite'] = $rewrite; Functions\expect('flush_rewrite_rules')->once(); $log = Mockery::mock(ChangeLog::class); @@ -67,6 +92,32 @@ final class WpSettingsAbilitiesTest extends TestCase self::assertSame(30, $result['log_id']); } + public function testDoesNotTreatAnAlreadyStoredValueAsAWriteFailure(): void + { + // update_option() returns false both for a genuine failure and for a value that + // already matches the stored option. WPLANG's public value "en_US" normalizes to + // WordPress' internal "" – if the "already set?" check compared the raw client + // input against SettingsWhitelist::current()'s aliased public form instead of the + // normalized value against the actual stored option, this looked like a change, + // update_option() returned false because nothing needed to change, and that false + // was wrongly read as a failure – rolling back the unrelated blogname change too. + Functions\when('get_option')->alias(static fn (string $key) => [ + 'blogname' => 'Alt', + 'WPLANG' => '', + ][$key]); + Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andReturn(31); + + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ + 'blogname' => 'Neu', + 'WPLANG' => 'en_US', + ]]); + + self::assertSame(['blogname'], array_column($result['changed'], 'id')); + self::assertSame(31, $result['log_id']); + } + public function testRestoresPreviousValuesWhenAWriteFailsPartway(): void { Functions\when('get_option')->alias(static fn (string $key) => [ @@ -96,11 +147,21 @@ final class WpSettingsAbilitiesTest extends TestCase public function testReportsPartialWriteWhenRestoringAlsoFails(): void { - Functions\when('get_option')->alias(static fn (string $key) => [ + // Stateful: the restore write must genuinely not take effect (stay "Neu") for + // isStored() to correctly report the rollback as incomplete. + $options = [ 'blogname' => 'Alt', 'blogdescription' => 'Vorher', - ][$key]); - Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true); + ]; + Functions\when('get_option')->alias(static function (string $key) use (&$options) { + return $options[$key]; + }); + Functions\expect('update_option')->once()->with('blogname', 'Neu') + ->andReturnUsing(static function (string $key, $value) use (&$options): bool { + $options[$key] = $value; + + return true; + }); Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false); Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(false); $log = Mockery::mock(ChangeLog::class); From 46a445fe9d34952822d4a7e80fd9de75c95d222e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:01:29 +0200 Subject: [PATCH 045/185] test: end-to-end checks for the WordPress basics; content and workflow guides Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/content-types.md | 66 +++++++++++ docs/guides/overview.md | 20 +++- docs/guides/workflows.md | 37 ++++++ tests/Integration/lib.sh | 117 +++++++++++++++++++ tests/Integration/mcp-smoke.sh | 57 ++------- tests/Integration/wp-basis-smoke.sh | 174 ++++++++++++++++++++++++++++ 6 files changed, 422 insertions(+), 49 deletions(-) create mode 100644 docs/guides/content-types.md create mode 100644 docs/guides/workflows.md create mode 100644 tests/Integration/lib.sh create mode 100644 tests/Integration/wp-basis-smoke.sh diff --git a/docs/guides/content-types.md b/docs/guides/content-types.md new file mode 100644 index 0000000..da6fa91 --- /dev/null +++ b/docs/guides/content-types.md @@ -0,0 +1,66 @@ +# Inhalte, Felder und Status + +## Inhaltstypen erkennen + +- `lit-content-types` ohne Parameter listet alle Inhaltstypen (Seiten, Beiträge, eigene Typen) mit + Taxonomien, Anzahl und ob Divi dafür aktiv ist. +- `lit-content-types` mit `post_type` liefert zusätzlich alle Felder: Schlüssel, Typ, Pflicht, Auswahlwerte + (`choices`), Beziehungsziel (`related`) und Schreibstatus: + - `writable` – sicher beschreibbar (Pods-Feld oder registriertes Meta-Feld), + - `readonly` – nicht beschreibbar, + - `unknown` – vorhandener Meta-Schlüssel ohne bekannten Typ; Schreiben geht, der Wert wird unverändert + gespeichert (Warnung in der Antwort). + +## Lesen und Suchen + +- `lit-content-query`: Filter `status`, `search`, `taxonomy` (`{taxonomy, terms: [IDs oder Slugs]}`), + `field` (`{key, value, compare}`), `date_after`/`date_before` (JJJJ-MM-TT), `parent`; Blättern mit + `next_cursor` → `cursor`. `field` und `taxonomy` sind auf Feld- bzw. Taxonomienamen beschränkt, die + `lit-content-types` für diesen Inhaltstyp auch anzeigt – ein unbekannter Name wird abgelehnt, keine leere + Trefferliste. Geliefert werden nur Einträge, die der verbundene Nutzer selbst bearbeiten darf (fremde + Entwürfe/private Inhalte ohne die passende Berechtigung erscheinen nicht). +- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. + Bei Divi-Seiten fehlt `content`; `hints` verweist auf `lit-divi-outline`. + +## Anlegen und Ändern + +- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler + bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). +- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist + **Pflicht** – hat jemand parallel gespeichert (Inhalt, Titel, Auszug, Status oder irgendetwas anderes am + Eintrag), kommt `rev_mismatch`: neu lesen, Änderung auf dem aktuellen Stand wiederholen. +- Begriffe: `terms: {"category": [4, "Neuer Begriff"]}` ersetzt die Zuordnung dieser Taxonomie (IDs oder + Namen; unbekannte Namen werden angelegt, falls erlaubt). +- Felder leeren: Wert `null`. Pflichtfelder können nicht geleert werden. +- Beziehungsfelder erwarten Eintrags-IDs, Datei-/Bildfelder Anhang-IDs aus der Mediathek. + +## Status + +- Veröffentlichen, Zurückziehen und Planen sind eigene Werkzeuge (`lit-post-publish`, `lit-post-unpublish`, + `lit-post-schedule` mit `date` = „JJJJ-MM-TT HH:MM“ in der Zeitzone der Website). Nur auf ausdrücklichen + Wunsch des Nutzers verwenden. +- `lit-post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage. + +## Sicher überarbeiten + +1. `lit-post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. +2. Kopie mit `lit-content-update` (oder den Divi-Tools) bearbeiten und dem Nutzer die Vorschau zeigen. +3. `lit-post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). + Voraussetzungen: die Kopie muss noch Entwurf oder ausstehend sein, `rev` des Originals aus + `lit-content-get` ist Pflicht (sie deckt Titel, Inhalt, Auszug und Status ab – Felder/Meta, die direkt am + Original geändert wurden, werden dabei überschrieben), und erst nach Rückfrage mit `confirm: true` + aufrufen. Vorher entsteht eine Revision **und** eine vollständige Sicherung des bisherigen Originals im + Papierkorb (`backup_id` in der Antwort); danach wandert die Kopie ebenfalls in den Papierkorb. Ist der + Papierkorb der Website deaktiviert, wird der Austausch verweigert, statt die Kopie endgültig zu löschen. + +Jede Änderung liefert `revision_id` (Stand vorher) und `log_id`. Rückgängig: `lit-revisions-list` → +`lit-revision-restore` mit der `rev` des Eintrags (aus `lit-content-get`) und `confirm: true`. Revisionen +umfassen Titel, Inhalt und Auszug – Felder und Begriffe nicht. + +## Wenn ein Schreibvorgang fehlschlägt + +Schlägt ein Schreibvorgang nach ersten Änderungen fehl, meldet das Tool `lit_write_failed` (alles wurde +zurückgenommen, nichts wurde gespeichert) oder `lit_partial_write` (die Rücknahme war unvollständig). Beide +Fehler stecken als JSON (`code`, `message`, `hint`, ggf. weitere Felder) in der Fehlermeldung. Bei +`lit_partial_write` vor einem erneuten Versuch immer erst den aktuellen Stand prüfen (`lit-content-get`, +`lit-menu-get` o. Ä.) statt den Aufruf einfach zu wiederholen – dem `hint` folgen. diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 6f8cdd6..2f40e7b 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -15,9 +15,21 @@ des verbundenen WordPress-Benutzers. 4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools verlangen den `rev` des letzten Lesevorgangs. -## Verfügbare Werkzeuge (Etappe 0) +## Werkzeuge -- `lit-guide` – diese Anleitungen. Weitere Themen: siehe Feld `topics`. -- `lit-site-status` – Überblick über Website, aktive Integrationen und eigene Rechte. +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`. -Weitere Werkzeuge kommen mit den nächsten Ausbaustufen hinzu. +Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`): +Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, +`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs +(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), +Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`). + +Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`. + +## Rückmeldungen + +- Erfolg: `ok`, `summary`, `changed`, `warnings`, `revision_id` (Stand vorher), `log_id`, URLs. +- Fehler: JSON mit `code`, `message`, `hint` (und ggf. `fields`). Dem `hint` folgen. +- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen. diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md new file mode 100644 index 0000000..8b06d5e --- /dev/null +++ b/docs/guides/workflows.md @@ -0,0 +1,37 @@ +# Abläufe + +## Neue Website aufsetzen + +1. `lit-site-status` und `lit-content-types` lesen. +2. Grundeinstellungen: `lit-wp-settings-get`, dann `lit-wp-settings-update` (Titel, Untertitel, Sprache, + Zeitzone, Datumsformat, Permalinks `/%postname%/`). `previous` in der Antwort enthält die alten Werte. +3. Seiten als Entwürfe anlegen (`lit-content-create`), Hierarchie über `parent`. +4. Startseite: Seite veröffentlichen (nach Rückfrage), dann + `{"settings": {"page_on_front": , "show_on_front": "page"}}`. +5. Menü: `lit-menu-create` (optional mit `location` aus `lit-menu-list`), Einträge mit `lit-menu-set-items`. +6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. + +## Menüs pflegen + +- `lit-menu-get` liefert den Baum. Für größere Umbauten den Baum verändern und vollständig als `items` an + `lit-menu-set-items` senden (bestehende Einträge mit `id`, neue ohne `id`). Wird der Baum unverändert + zurückgeschickt, meldet das Tool „keine Änderung“ und schreibt nichts. +- Für kleine Änderungen `operations`: `{op: "add", item, parent_id?, position?}`, + `{op: "move", id, parent_id?, position?}`, `{op: "remove", id}` (Kinder eines entfernten Eintrags rücken + an seine Stelle). +- Neue Einträge ohne `id`: `type` `post`/`taxonomy` mit `object_id`, oder `custom` mit `url` (`/pfad`, + `#anker`, `http(s)`, `mailto`, `tel`) und `title`; jeweils optional `children`. Das Ziel eines bestehenden + Eintrags lässt sich nicht ändern – dafür entfernen und neu anlegen. +- Verlinkt werden nur für den Nutzer lesbare, veröffentlichte Inhalte und Begriffe öffentlicher Taxonomien. + Private Inhalte sind immer ausgeschlossen. Entwürfe, ausstehende oder geplante Inhalte lassen sich nur mit + `allow_unpublished: true` verlinken – WordPress zeigt den Menüpunkt sofort an, Besucher bekommen bis zur + Veröffentlichung einen toten Link (die Antwort enthält dazu eine Warnung). +- Fallen dabei Einträge weg, verlangt das Tool `confirm: true`; `previous_items` (Stand vorher) erlaubt das + Zurückbauen – neu angelegte Einträge erhalten dabei eine neue `id`, CSS-Klassen, Linkziel und Beschreibung + gehen verloren. + +## Einträge eines eigenen Inhaltstyps (Pods) + +1. `lit-content-types` mit `post_type` → Pflichtfelder, Auswahlwerte, Beziehungsziele. +2. `lit-content-create` mit allen Pflichtfeldern in `fields`. +3. Beziehungen über IDs (vorher mit `lit-content-query` suchen). diff --git a/tests/Integration/lib.sh b/tests/Integration/lib.sh new file mode 100644 index 0000000..ab64ba7 --- /dev/null +++ b/tests/Integration/lib.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Shared helpers for the MCP integration scripts (mcp-smoke.sh, wp-basis-smoke.sh, +# and Etappe 1b's smoke scripts). Source it from a script that runs INSIDE the DDEV +# web container with /var/www/html as its working directory (cd there before +# sourcing – HOST below shells out to `wp` immediately). +# Requires jq and curl (both present in the DDEV web image). + +PLUGIN=wp-content/plugins/WP-LIT-MCP +ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server + +# The site's own Host header (without scheme). A request that bypasses normal WP +# routing (e.g. the raw discovery rewrite target mcp-smoke.sh probes) needs this +# Host header, or WordPress 301s it to the canonical host before it ever reaches +# the code under test. +HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') + +TOKEN="" +SESSION="" +INIT_RESPONSE="" +REQUEST_ID=100 + +fail() { echo "FAIL: $*"; exit 1; } +pass() { echo "ok - $*"; } + +# Removes every "lit-mcp-integration-test" application password of a user. +cleanup_app_passwords_for() { # $1 = user_login + local login="$1" + [ -n "$login" ] || return 0 + local escaped=${login//\\/\\\\} + escaped=${escaped//\'/\\\'} + wp eval " +\$u = get_user_by('login', '${escaped}'); +if (\$u) { + foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) { + if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') { + WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']); + } + } +} +" >/dev/null 2>&1 || true +} + +# Mints an MCP access token for a user, exactly like mcp-oauth's TokenEndpoint does +# after a completed OAuth flow (mint-token.php). Also creates the throwaway +# "lit-mcp-integration-test" application password cleanup_app_passwords_for removes. +mint_token() { # $1 = user_login -> prints the token + wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$1" +} + +# Raw JSON-RPC POST to the MCP endpoint; prints headers+body (-i). $3 (session id) +# is optional. Low-level building block: used directly where a script needs the +# response headers (e.g. to read Mcp-Session-Id off the initialize response) and by +# rpc() below for everything after that. +mcp() { # $1 = token, $2 = json body, $3 = session id (optional) + local session_header=() + [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") + curl -s --max-time 20 -i -X POST "$ENDPOINT" \ + -H "Authorization: Bearer $1" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + "${session_header[@]}" -d "$2" +} + +# Mints a token for the user and opens an MCP session. Sets TOKEN, SESSION and +# INIT_RESPONSE (raw initialize response incl. headers). +connect() { # $1 = user_login + TOKEN=$(mint_token "$1") + local init + init=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') + SESSION=$(echo "$init" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) + INIT_RESPONSE="$init" + echo "$init" | grep -q '"protocolVersion"' || fail "initialize failed: $init" +} + +# JSON-RPC call over the session connect() opened; prints the response body only. +rpc() { # $1 = method, $2 = params JSON + REQUEST_ID=$((REQUEST_ID + 1)) + local body + body=$(jq -cn --arg m "$1" --argjson p "$2" --argjson id "$REQUEST_ID" '{jsonrpc: "2.0", id: $id, method: $m, params: $p}') + local session_header=() + [ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION") + curl -s --max-time 60 -X POST "$ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + "${session_header[@]}" -d "$body" +} + +# Calls a tool; prints structuredContent, or {"__error": ""} for isError results. +tool() { # $1 = tool name, $2 = arguments JSON + local response + response=$(rpc tools/call "$(jq -cn --arg n "$1" --argjson a "$2" '{name: $n, arguments: $a}')") + echo "$response" | jq -e '.result' >/dev/null 2>&1 || fail "$1: protocol error: $response" + if [ "$(echo "$response" | jq -r '.result.isError')" = "true" ]; then + echo "$response" | jq -c '{__error: .result.content[0].text}' + else + echo "$response" | jq -c '.result.structuredContent' + fi +} + +# Runs an indirect ability through mcp-adapter-execute-ability; prints its result or {"__error": …}. +ability() { # $1 = ability name (lit/…), $2 = parameters JSON + local out + out=$(tool mcp-adapter-execute-ability "$(jq -cn --arg n "$1" --argjson p "$2" '{ability_name: $n, parameters: $p}')") + if echo "$out" | jq -e 'has("__error")' >/dev/null; then + echo "$out" + elif [ "$(echo "$out" | jq -r '.success')" = "true" ]; then + echo "$out" | jq -c '.data' + else + echo "$out" | jq -c '{__error: .error}' + fi +} + +# Prints the lit error code (e.g. rev_mismatch) of a tool()/ability() result, or "". +error_code() { # $1 = result JSON + echo "$1" | jq -r '(.__error // "") | (try fromjson catch {}) | (.code // "")' +} diff --git a/tests/Integration/mcp-smoke.sh b/tests/Integration/mcp-smoke.sh index 3b1c4af..684d2b3 100644 --- a/tests/Integration/mcp-smoke.sh +++ b/tests/Integration/mcp-smoke.sh @@ -3,13 +3,9 @@ # Runs INSIDE the DDEV web container (wp-cli and curl available there): # cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh set -euo pipefail - -PLUGIN=wp-content/plugins/WP-LIT-MCP -ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server cd /var/www/html - -fail() { echo "FAIL: $*"; exit 1; } -pass() { echo "ok - $*"; } +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh # --- Cleanup: remove every "lit-mcp-integration-test" application password # this script mints (for every user it mints one for) and the throwaway @@ -17,23 +13,6 @@ pass() { echo "ok - $*"; } # (success, fail(), or an unexpected error under `set -e`). ADMIN_LOGIN="" -cleanup_app_passwords_for() { # $1 = user_login - local login="$1" - [ -n "$login" ] || return 0 - local escaped=${login//\\/\\\\} - escaped=${escaped//\'/\\\'} - wp eval " -\$u = get_user_by('login', '${escaped}'); -if (\$u) { - foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) { - if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') { - WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']); - } - } -} -" >/dev/null 2>&1 || true -} - cleanup() { cleanup_app_passwords_for "$ADMIN_LOGIN" cleanup_app_passwords_for lit-smoke-sub @@ -41,16 +20,6 @@ cleanup() { } trap cleanup EXIT -mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+body - local session_header=() - [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") - curl -s --max-time 20 -i -X POST "$ENDPOINT" \ - -H "Authorization: Bearer $1" \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - "${session_header[@]}" -d "$2" -} - # 0. Booting REST triggers no _doing_it_wrong(). A regression in the # unhook-before-create ordering (ServerRegistrar::createServer()) would # otherwise surface as a "duplicate server id" _doing_it_wrong() notice when @@ -73,14 +42,12 @@ pass "booting REST triggers no _doing_it_wrong()" # # The HARD checks below instead hit mcp-oauth's own rewrite target directly # (index.php?mcp_oauth_discovery=...) via a request whose Host matches the -# site's home URL (WordPress issues a 301 to the canonical host otherwise), -# proving the discovery documents themselves are correct. The ENVIRONMENT -# check further down separately probes the real `/.well-known/` path and only -# warns, never fails, so a misconfigured web server doesn't block this suite -# while still surfacing the risk (real MCP clients like claude.ai use the -# real path, not the rewrite target). -HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') - +# site's home URL (WordPress issues a 301 to the canonical host otherwise; +# HOST comes from lib.sh), proving the discovery documents themselves are +# correct. The ENVIRONMENT check further down separately probes the real +# `/.well-known/` path and only warns, never fails, so a misconfigured web +# server doesn't block this suite while still surfacing the risk (real MCP +# clients like claude.ai use the real path, not the rewrite target). curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ 'http://localhost/?mcp_oauth_discovery=protected-resource' \ | grep -q '"resource"' \ @@ -110,7 +77,7 @@ pass "401 + WWW-Authenticate without token" # 3. Administrator: initialize, tools/list, tools/call ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) -TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$ADMIN_LOGIN") +TOKEN=$(mint_token "$ADMIN_LOGIN") INIT=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') SESSION=$(echo "$INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) echo "$INIT" | grep -q 'LIT MCP pflegt' || fail "instructions missing in initialize result" @@ -130,8 +97,8 @@ pass "tools/call lit-site-status works" # 3b. mcp-adapter's default (unprotected) server must not exist: the route # it would register, mcp-adapter-default-server, must 404. Uses a temporary # application password (Basic auth) instead of the MCP bearer token, since -# this checks a plain REST route, not the MCP endpoint. Named like the mint- -# token.php passwords so the cleanup trap above also removes it. +# this checks a plain REST route, not the MCP endpoint. Named like mint_token's +# passwords so the cleanup trap above also removes it. APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain) DEFAULT_SERVER_STATUS=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' \ -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ @@ -150,7 +117,7 @@ pass "MCP token is rejected on /wp/v2/users/me" # 5. Role gate: subscriber has a valid token but must not use tools wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/dev/null -SUB_TOKEN=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" lit-smoke-sub) +SUB_TOKEN=$(mint_token lit-smoke-sub) SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION") diff --git a/tests/Integration/wp-basis-smoke.sh b/tests/Integration/wp-basis-smoke.sh new file mode 100644 index 0000000..4da025e --- /dev/null +++ b/tests/Integration/wp-basis-smoke.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# End-to-end checks of the Etappe-1a tools (content, status, copies, revisions, +# menus, WordPress settings, change log) through the OAuth-protected endpoint. +# Runs INSIDE the DDEV web container: +# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh +# Temporarily activates the installed Pods plugin (restored afterwards) and +# restores every setting/menu location it changes. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +PODS_WAS_ACTIVE=no +wp plugin is-active pods && PODS_WAS_ACTIVE=yes +ORIGINAL_DESCRIPTION=$(wp option get blogdescription) +ORIGINAL_LOCATIONS=$(wp eval 'echo wp_json_encode(get_nav_menu_locations());') +CREATED_POSTS=() +MENU_ID="" + +cleanup() { + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + [ -n "$MENU_ID" ] && wp menu delete "$MENU_ID" >/dev/null 2>&1 || true + wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 || true + LIT_LOCATIONS="$ORIGINAL_LOCATIONS" wp eval \ + 'set_theme_mod("nav_menu_locations", (array) json_decode(getenv("LIT_LOCATIONS"), true));' >/dev/null 2>&1 || true + wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" delete >/dev/null 2>&1 || true + [ "$PODS_WAS_ACTIVE" = "no" ] && wp plugin deactivate pods >/dev/null 2>&1 || true + cleanup_app_passwords_for "$ADMIN_LOGIN" +} +trap cleanup EXIT + +wp plugin activate pods >/dev/null +wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" create >/dev/null +connect "$ADMIN_LOGIN" + +# 1. Tool list: direct tools visible with annotations, the rest only via meta-tools +LIST=$(rpc tools/list '{}') +for t in lit-content-types lit-content-query lit-content-get lit-content-create lit-content-update lit-post-publish; do + echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t" +done +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-post-trash")' >/dev/null \ + && fail "lit-post-trash must only be reachable via execute-ability" +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-content-get") | .annotations.readOnlyHint == true' >/dev/null \ + || fail "lit-content-get lacks readOnlyHint" +pass "tools/list: direct content tools with annotations, indirect status tools" + +# 2. Schema detection incl. Pods +TYPES=$(tool lit-content-types '{}') +echo "$TYPES" | jq -e '.post_types[] | select(.name == "page")' >/dev/null || fail "content-types lacks page: $TYPES" +PERSON_TYPE=$(tool lit-content-types '{"post_type":"lit_person"}') +echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "role" and .required == true and .source == "pods")' >/dev/null \ + || fail "Pods field role missing: $PERSON_TYPE" +echo "$PERSON_TYPE" | jq -e '.post_types[0].fields[] | select(.key == "level" and (.choices | length) == 2)' >/dev/null \ + || fail "Pods choices missing: $PERSON_TYPE" +pass "content-types lists post types and Pods fields" + +# 3. Create, read, update with rev lock +CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke Seite","content":"

Hallo

"}') +PAGE_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty') +[ -n "$PAGE_ID" ] || fail "content-create: $CREATED" +CREATED_POSTS+=("$PAGE_ID") +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "new page is not a draft" +REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') +STALE=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"000000000000\",\"title\":\"x\"}") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale rev accepted: $STALE" +UPDATED=$(tool lit-content-update "{\"post_id\":$PAGE_ID,\"rev\":\"$REV\",\"content\":\"

Neu

\"}") +echo "$UPDATED" | jq -e '.ok == true and .log_id != null' >/dev/null || fail "content-update: $UPDATED" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "update changed the status" +pass "content create/get/update, rev lock, status unchanged" + +# 4. Pods entries: required field enforced, values written through Pods +MISSING=$(tool lit-content-create '{"post_type":"lit_person","title":"Ohne Funktion"}') +[ "$(error_code "$MISSING")" = "validation_failed" ] || fail "missing required Pods field accepted: $MISSING" +PERSON=$(tool lit-content-create '{"post_type":"lit_person","title":"Anna","fields":{"role":"Leitung","level":"senior"}}') +PERSON_ID=$(echo "$PERSON" | jq -r '.changed[0].id // empty') +[ -n "$PERSON_ID" ] || fail "Pods entry: $PERSON" +CREATED_POSTS+=("$PERSON_ID") +tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -e '.fields.role == "Leitung" and .fields.level == "senior"' >/dev/null \ + || fail "Pods values not readable" +pass "Pods entry with validated fields" + +# 5. Status tools +tool lit-post-publish "{\"post_id\":$PAGE_ID}" | jq -e '.changed[0].action == "published"' >/dev/null || fail "publish" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "page not published" +ability lit/post-unpublish "{\"post_id\":$PAGE_ID}" | jq -e '.ok == true' >/dev/null || fail "unpublish" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "draft" ] || fail "page not back to draft" +FUTURE=$(date -d '+2 days' '+%Y-%m-%d 10:00') +ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"$FUTURE\"}" | jq -e '.ok == true' >/dev/null || fail "schedule" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "future" ] || fail "page not scheduled" +PAST=$(ability lit/post-schedule "{\"post_id\":$PAGE_ID,\"date\":\"2020-01-01 10:00\"}") +[ "$(error_code "$PAST")" = "validation_failed" ] || fail "past schedule accepted: $PAST" +tool lit-post-publish "{\"post_id\":$PAGE_ID}" >/dev/null +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "scheduled page did not publish now" +pass "publish/unpublish/schedule, past dates rejected" + +# 6. Copy and replace (lit-content-update, lit/post-replace-with-copy: rev required) +COPY=$(ability lit/post-duplicate "{\"post_id\":$PAGE_ID}") +COPY_ID=$(echo "$COPY" | jq -r '.changed[0].id // empty') +[ -n "$COPY_ID" ] || fail "duplicate: $COPY" +CREATED_POSTS+=("$COPY_ID") +COPY_REV=$(echo "$COPY" | jq -r '.rev') +tool lit-content-update "{\"post_id\":$COPY_ID,\"rev\":\"$COPY_REV\",\"content\":\"

Aus Kopie

\"}" >/dev/null +ORIGINAL_REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') +NOCONFIRM=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"rev\":\"$ORIGINAL_REV\"}") +[ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "replace without confirm: $NOCONFIRM" +REPLACED=$(ability lit/post-replace-with-copy "{\"original_id\":$PAGE_ID,\"copy_id\":$COPY_ID,\"rev\":\"$ORIGINAL_REV\",\"confirm\":true}") +echo "$REPLACED" | jq -e '.revision_id != null and .backup_id != null' >/dev/null || fail "replace-with-copy: $REPLACED" +BACKUP_ID=$(echo "$REPLACED" | jq -r '.backup_id') +CREATED_POSTS+=("$BACKUP_ID") +[ "$(wp post get "$PAGE_ID" --field=post_content)" = "

Aus Kopie

" ] || fail "original not replaced" +[ "$(wp post get "$PAGE_ID" --field=post_status)" = "publish" ] || fail "replace changed the original's status" +[ "$(wp post get "$COPY_ID" --field=post_status)" = "trash" ] || fail "copy not trashed" +[ "$(wp post get "$BACKUP_ID" --field=post_status)" = "trash" ] || fail "backup of the original not trashed" +pass "duplicate and replace-with-copy (status kept, copy trashed, backup taken)" + +# 7. Revisions (lit/revision-restore: rev required) +REVISIONS=$(ability lit/revisions-list "{\"post_id\":$PAGE_ID}") +OLDEST=$(echo "$REVISIONS" | jq -r '.items[-1].id // empty') +[ -n "$OLDEST" ] || fail "revisions-list: $REVISIONS" +PAGE_REV=$(tool lit-content-get "{\"post_id\":$PAGE_ID}" | jq -r '.rev') +ability lit/revision-restore "{\"revision_id\":$OLDEST,\"rev\":\"$PAGE_REV\",\"confirm\":true}" | jq -e '.ok == true' >/dev/null \ + || fail "revision-restore" +pass "revisions list and restore" + +# 8. Trash +ability lit/post-trash "{\"post_id\":$PERSON_ID,\"confirm\":true}" | jq -e '.changed[0].action == "trashed"' >/dev/null \ + || fail "post-trash" +[ "$(wp post get "$PERSON_ID" --field=post_status)" = "trash" ] || fail "entry not in trash" +pass "post-trash moves to trash" + +# 9. Menus +MENU=$(ability lit/menu-create '{"name":"LIT Smoke Menü"}') +MENU_ID=$(echo "$MENU" | jq -r '.menu_id // empty') +[ -n "$MENU_ID" ] || fail "menu-create: $MENU" +ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"items\":[{\"type\":\"post\",\"object_id\":$PAGE_ID,\"children\":[{\"type\":\"custom\",\"url\":\"/kontakt\",\"title\":\"Kontakt\"}]}]}" \ + | jq -e '.ok == true' >/dev/null || fail "menu-set-items (tree)" +TREE=$(ability lit/menu-get "{\"menu_id\":$MENU_ID}") +CHILD_ID=$(echo "$TREE" | jq -r '.items[0].children[0].id // empty') +[ "$(echo "$TREE" | jq -r '.items[0].children[0].title')" = "Kontakt" ] || fail "menu tree: $TREE" +UNCHANGED_ARGS=$(jq -cn --argjson id "$MENU_ID" --argjson items "$(echo "$TREE" | jq -c '.items')" '{menu_id: $id, items: $items}') +UNCHANGED=$(ability lit/menu-set-items "$UNCHANGED_ARGS") +echo "$UNCHANGED" | jq -e '.summary | contains("keine Änderung")' >/dev/null || fail "menu no-op: $UNCHANGED" +REMOVE=$(ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}]}") +[ "$(error_code "$REMOVE")" = "confirm_required" ] || fail "menu removal without confirm: $REMOVE" +ability lit/menu-set-items "{\"menu_id\":$MENU_ID,\"operations\":[{\"op\":\"remove\",\"id\":$CHILD_ID}],\"confirm\":true}" \ + | jq -e '(.previous_items[0].children | length) == 1 and (.items[0].children | length) == 0' >/dev/null \ + || fail "menu removal with confirm" +LOCATION=$(ability lit/menu-list '{}' | jq -r '.locations[0].location // empty') +if [ -n "$LOCATION" ]; then + ability lit/menu-assign-location "{\"location\":\"$LOCATION\",\"menu_id\":$MENU_ID}" | jq -e '.ok == true' >/dev/null \ + || fail "menu-assign-location" +fi +pass "menu create, tree, no-op resend, operations with confirm, location" + +# 10. WordPress settings (whitelist) +ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname")' >/dev/null \ + || fail "wp-settings-get" +BLOCKED=$(ability lit/wp-settings-update '{"settings":{"admin_email":"x@example.invalid"}}') +[ "$(error_code "$BLOCKED")" = "validation_failed" ] || fail "blocked setting accepted: $BLOCKED" +ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT Smoke Test"}}' \ + | jq -e '.previous.blogdescription != null' >/dev/null || fail "wp-settings-update" +[ "$(wp option get blogdescription)" = "LIT Smoke Test" ] || fail "blogdescription not updated" +pass "WordPress settings whitelist" + +# 11. Change log with client name +PREFIX=$(wp db prefix) +ROWS=$(wp db query "SELECT COUNT(*) FROM ${PREFIX}lit_mcp_log WHERE ability = 'lit/content-create' AND client = 'lit-mcp-integration-test' AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)" --skip-column-names) +[ "$ROWS" -ge 2 ] || fail "change log rows missing (found $ROWS)" +pass "change log records ability and client" + +echo "ALL CHECKS PASSED" From 0c4da2885519ae94b588726bd956c8d90a74703b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:15:13 +0200 Subject: [PATCH 046/185] refactor: ChangeLog::recordPartial and column-size truncation recordPartial() replaces the partial-log FollowUp blocks in content-create, content-update and post-replace-with-copy; record() truncates ability, object_type and object_id to their column sizes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/ChangeLog/ChangeLog.php | 28 +++++++++-- .../Content/ContentCreateAbility.php | 8 +--- .../Content/ContentUpdateAbility.php | 20 +++----- .../Status/PostReplaceWithCopyAbility.php | 20 +++----- tests/Unit/ChangeLog/ChangeLogTest.php | 47 +++++++++++++++++++ .../Content/ContentCreateAbilityTest.php | 10 ++-- .../Content/ContentUpdateAbilityTest.php | 4 +- .../Status/PostReplaceWithCopyAbilityTest.php | 4 +- 8 files changed, 94 insertions(+), 47 deletions(-) diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php index 73449f8..ef6160f 100644 --- a/src/ChangeLog/ChangeLog.php +++ b/src/ChangeLog/ChangeLog.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\ChangeLog; +use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Server\RequestContext; /** @@ -44,9 +45,9 @@ class ChangeLog 'created_at' => current_time('mysql', true), 'user_id' => get_current_user_id(), 'client' => $this->sanitizedClientName(), - 'ability' => $ability, - 'object_type' => $objectType, - 'object_id' => $objectId, + 'ability' => mb_substr($ability, 0, 100), + 'object_type' => mb_substr($objectType, 0, 50), + 'object_id' => mb_substr($objectId, 0, 191), 'summary' => mb_substr($summary, 0, 500), 'revision_id' => $revisionId, 'snapshot_id' => $snapshotId, @@ -64,6 +65,27 @@ class ChangeLog return (int) $wpdb->insert_id; } + /** + * Logs a write that failed and could not be rolled back completely (result "partial"). + * Never throws: the caller is already reporting an error, a log failure only goes to + * the PHP error log. + */ + public function recordPartial( + string $ability, + string $objectType, + string $objectId, + string $summary, + ?int $revisionId = null + ): void { + $ignored = []; + FollowUp::run( + fn () => $this->record($ability, $objectType, $objectId, $summary, $revisionId, null, 'partial'), + $ability . ' change log', + '', + $ignored + ); + } + public function purgeOlderThan(int $days): int { $wpdb = $this->db(); diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index caf158f..feb590d 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -204,12 +204,6 @@ final class ContentCreateAbility implements Ability private function logPartial(string $postType, string $postId, string $summary): void { - $ignored = []; - FollowUp::run( - fn () => $this->log->record($this->name(), $postType, $postId, $summary, null, null, 'partial'), - $this->name() . ' change log', - '', - $ignored - ); + $this->log->recordPartial($this->name(), $postType, $postId, $summary); } } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 31a74cb..0ac8692 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -247,20 +247,12 @@ final class ContentUpdateAbility implements Ability throw new WriteFailed(self::REASON, true, $postId, $revisionId, $cause, $detail); } - $ignored = []; - FollowUp::run( - fn () => $this->log->record( - $this->name(), - (string) $post->post_type, - (string) $postId, - sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title), - $revisionId, - null, - 'partial' - ), - $this->name() . ' change log', - '', - $ignored + $this->log->recordPartial( + $this->name(), + (string) $post->post_type, + (string) $postId, + sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $revisionId ); throw new WriteFailed( self::REASON, diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 7f2c403..2549d54 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -283,20 +283,12 @@ final class PostReplaceWithCopyAbility implements Ability try { $this->cloner->restore($original, $snapshot, $parts); } catch (Throwable $e) { - $ignored = []; - FollowUp::run( - fn () => $this->log->record( - $this->name(), - (string) $original->post_type, - (string) $originalId, - sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title), - $revisionId, - null, - 'partial' - ), - $this->name() . ' change log', - '', - $ignored + $this->log->recordPartial( + $this->name(), + (string) $original->post_type, + (string) $originalId, + sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title), + $revisionId ); throw new WriteFailed( self::REASON, diff --git a/tests/Unit/ChangeLog/ChangeLogTest.php b/tests/Unit/ChangeLog/ChangeLogTest.php index 1e1df8a..e757dcb 100644 --- a/tests/Unit/ChangeLog/ChangeLogTest.php +++ b/tests/Unit/ChangeLog/ChangeLogTest.php @@ -67,6 +67,53 @@ final class ChangeLogTest extends TestCase unlink($log); } + public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void + { + Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); + Functions\when('get_current_user_id')->justReturn(3); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('insert') + ->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool { + return $row['object_id'] === str_repeat('ä', 191) && $row['ability'] === str_repeat('ö', 100); + }), Mockery::type('array')) + ->andReturn(1); + + (new ChangeLog($db))->record(str_repeat('ö', 150), 'option', str_repeat('ä', 300), 'x'); + } + + public function testRecordPartialStoresResultPartial(): void + { + Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); + Functions\when('get_current_user_id')->justReturn(3); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('insert') + ->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool { + return $row['result'] === 'partial' && $row['revision_id'] === 7 && $row['object_id'] === '12'; + }), Mockery::type('array')) + ->andReturn(1); + + (new ChangeLog($db))->recordPartial('lit/content-update', 'page', '12', 'teilweise', 7); + } + + public function testRecordPartialNeverThrows(): void + { + Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); + Functions\when('get_current_user_id')->justReturn(3); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('insert')->andThrow(new \RuntimeException('db gone')); + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + + try { + (new ChangeLog($db))->recordPartial('lit/menu-set-items', 'nav_menu', '4', 'teilweise'); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertStringContainsString('db gone', (string) file_get_contents($log)); + unlink($log); + } + public function testPurgeDeletesRowsOlderThanTheRetention(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php index 09ab3b3..c104a55 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -134,8 +134,8 @@ final class ContentCreateAbilityTest extends TestCase Functions\when('wp_insert_post')->justReturn(42); Functions\when('wp_delete_post')->justReturn(false); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/content-create', 'lit_person', '42', Mockery::type('string'), null, null, 'partial') + $log->expects('recordPartial') + ->with('lit/content-create', 'lit_person', '42', Mockery::type('string')) ->andReturn(9); $failure = $this->execute($writer, $log); @@ -153,7 +153,7 @@ final class ContentCreateAbilityTest extends TestCase Functions\when('wp_insert_post')->justReturn(42); Functions\when('wp_delete_post')->justReturn(new WP_Post(['ID' => 42])); $log = Mockery::mock(ChangeLog::class); - $log->expects('record')->andReturn(9); + $log->expects('recordPartial'); $failure = $this->execute($writer, $log); @@ -168,8 +168,8 @@ final class ContentCreateAbilityTest extends TestCase throw new RuntimeException('save_post hook kaputt'); }); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/content-create', 'lit_person', '', Mockery::type('string'), null, null, 'partial') + $log->expects('recordPartial') + ->with('lit/content-create', 'lit_person', '', Mockery::type('string')) ->andReturn(9); $failure = $this->execute($writer, $log); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php index 12ef0f3..d8f0f09 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php @@ -261,8 +261,8 @@ final class ContentUpdateAbilityTest extends TestCase $this->captureUpdates($updates); Functions\when('wp_revisions_enabled')->justReturn(false); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/content-update', 'page', '5', Mockery::type('string'), 811, null, 'partial') + $log->expects('recordPartial') + ->with('lit/content-update', 'page', '5', Mockery::type('string'), 811) ->andReturn(12); $failure = $this->executeExpectingFailure($writer, $input, $log); diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php index 044a969..a6bc78c 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -228,8 +228,8 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $cloner->expects('restore')->with($this->original, self::SNAPSHOT, PostCloner::PARTS)->andThrow(new RuntimeException('Restore incomplete: terms')); Functions\expect('wp_trash_post')->never(); $log = Mockery::mock(ChangeLog::class); - $log->expects('record') - ->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900, null, 'partial') + $log->expects('recordPartial') + ->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900) ->andReturn(12); try { From 6a7193424ca827b2c553ba1517117bfbc994c909 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:15:29 +0200 Subject: [PATCH 047/185] refactor: Rev::matches replaces the inline rev checks Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Rev.php | 10 ++++++++++ .../WordPress/Content/ContentUpdateAbility.php | 2 +- .../WordPress/Revisions/RevisionRestoreAbility.php | 2 +- .../WordPress/Status/PostReplaceWithCopyAbility.php | 2 +- tests/Unit/Abilities/RevAndJsonMapTest.php | 10 ++++++++++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Abilities/Rev.php b/src/Abilities/Rev.php index 8b1ca36..457e245 100644 --- a/src/Abilities/Rev.php +++ b/src/Abilities/Rev.php @@ -32,4 +32,14 @@ final class Rev (string) $post->post_modified_gmt, ])); } + + /** + * Whether $input carries the current rev of $post (optimistic lock of the post tools). + * + * @param array $input + */ + public static function matches(array $input, WP_Post $post): bool + { + return isset($input['rev']) && $input['rev'] === self::ofPost($post); + } } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 0ac8692..fe92ad2 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -109,7 +109,7 @@ final class ContentUpdateAbility implements Ability return $post; } - if (! isset($input['rev']) || $input['rev'] !== Rev::ofPost($post)) { + if (! Rev::matches($input, $post)) { return Errors::revMismatch(); } diff --git a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php index 210d6c3..4055bd7 100644 --- a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php @@ -107,7 +107,7 @@ final class RevisionRestoreAbility implements Ability )); } - if (! isset($input['rev']) || $input['rev'] !== Rev::ofPost($post)) { + if (! Rev::matches($input, $post)) { return Errors::revMismatch(); } diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 2549d54..0386f45 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -185,7 +185,7 @@ final class PostReplaceWithCopyAbility implements Ability ); } - if (! isset($input['rev']) || $input['rev'] !== Rev::ofPost($original)) { + if (! Rev::matches($input, $original)) { return Errors::revMismatch(); } diff --git a/tests/Unit/Abilities/RevAndJsonMapTest.php b/tests/Unit/Abilities/RevAndJsonMapTest.php index 096fe56..bce6cf1 100644 --- a/tests/Unit/Abilities/RevAndJsonMapTest.php +++ b/tests/Unit/Abilities/RevAndJsonMapTest.php @@ -39,6 +39,16 @@ final class RevAndJsonMapTest extends TestCase } } + public function testMatchesComparesInputRevWithThePostRev(): void + { + $post = new WP_Post(['post_title' => 'T']); + + self::assertTrue(Rev::matches(['rev' => Rev::ofPost($post)], $post)); + self::assertFalse(Rev::matches(['rev' => 'abcdefabcdef'], $post)); + self::assertFalse(Rev::matches([], $post)); + self::assertFalse(Rev::matches(['rev' => 123], $post)); + } + public function testEmptyMapBecomesJsonObject(): void { self::assertInstanceOf(stdClass::class, JsonMap::of([])); From d831cf59ca8b8b79655c6684199d8c7657c99a5f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:20:21 +0200 Subject: [PATCH 048/185] fix: never publish a missed schedule, refuse trashed posts, fail on meta read errors - PostGuard::editable() refuses trashed entries unless the caller opts in (content-get, revisions-list, post-trash); publish/schedule/unpublish/update/ restore/duplicate/replace now refuse them. - PostGuard::statusStable(): content-update, revision-restore and replace-with-copy refuse a future post whose date has passed (any save would publish it); the content-update rollback and PostCloner row writes re-check. - PostCloner::ownMeta() treats a failed SELECT as an error; replace() reads the copy before its first write. - replace-with-copy needs create_posts (backup clone) and reports backup_id in write_failed/partial_write. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Errors.php | 11 +- src/Abilities/WriteFailed.php | 8 +- .../WordPress/Content/ContentGetAbility.php | 4 +- .../Content/ContentUpdateAbility.php | 11 ++ src/Integrations/WordPress/PostGuard.php | 59 +++++++++- .../Revisions/RevisionRestoreAbility.php | 6 + .../Revisions/RevisionsListAbility.php | 4 +- .../WordPress/Status/PostCloner.php | 38 ++++++- .../WordPress/Status/PostDuplicateAbility.php | 9 -- .../Status/PostReplaceWithCopyAbility.php | 53 ++++++--- .../WordPress/Status/PostTrashAbility.php | 2 +- tests/Stubs/Wpdb.php | 1 + tests/Unit/Abilities/WriteFailedTest.php | 9 ++ .../Content/ContentGetAbilityTest.php | 13 +++ .../Content/ContentUpdateAbilityTest.php | 50 +++++++++ .../Integrations/WordPress/PostGuardTest.php | 38 +++++++ .../Revisions/RevisionRestoreAbilityTest.php | 39 +++++++ .../Revisions/RevisionsListAbilityTest.php | 8 ++ .../WordPress/Status/PostClonerTest.php | 106 +++++++++++++++++- .../Status/PostPublishAbilityTest.php | 10 ++ .../Status/PostReplaceWithCopyAbilityTest.php | 34 +++++- .../Status/PostScheduleAbilityTest.php | 10 ++ .../WordPress/Status/PostTrashAbilityTest.php | 12 ++ .../Status/PostUnpublishAbilityTest.php | 9 ++ 24 files changed, 501 insertions(+), 43 deletions(-) diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index 60d1884..b98e378 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -98,15 +98,20 @@ final class Errors return self::make('unsupported', $message, $hint, 400); } - /** A write failed and was rolled back completely. */ - public static function writeFailed(string $reason): WP_Error + /** + * A write failed and was rolled back completely. + * + * @param array $details Extra error details (e.g. a backup that stays). + */ + public static function writeFailed(string $reason, array $details = []): WP_Error { return self::make( 'write_failed', trim('Speichern fehlgeschlagen: ' . $reason) . ' Nichts wurde gespeichert.', 'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den Administrator ' . 'informieren (Details im PHP-Error-Log der Website).', - 500 + 500, + $details ); } diff --git a/src/Abilities/WriteFailed.php b/src/Abilities/WriteFailed.php index 936a5da..a557dc5 100644 --- a/src/Abilities/WriteFailed.php +++ b/src/Abilities/WriteFailed.php @@ -13,12 +13,12 @@ use WP_Error; * written. getMessage() is the internal detail for the PHP error log; $reason is the * client-safe German text. The AbilityRegistrar logs the detail and returns toError(): * lit_write_failed when everything was rolled back, lit_partial_write otherwise. - * $state/$hint/$details customise the partial-write error for writes that are not about a - * post (see Errors::partialWrite()). + * $state/$hint customise the partial-write error for writes that are not about a post (see + * Errors::partialWrite()); $details are added to either error. */ final class WriteFailed extends RuntimeException { - /** @param array $details Extra error details for an incomplete rollback. */ + /** @param array $details Extra error details for the client. */ public function __construct( public readonly string $reason, public readonly bool $rolledBack, @@ -41,7 +41,7 @@ final class WriteFailed extends RuntimeException public function toError(): WP_Error { return $this->rolledBack - ? Errors::writeFailed($this->reason) + ? Errors::writeFailed($this->reason, $this->details) : Errors::partialWrite( $this->reason, $this->postId, diff --git a/src/Integrations/WordPress/Content/ContentGetAbility.php b/src/Integrations/WordPress/Content/ContentGetAbility.php index dae031f..d70db5b 100644 --- a/src/Integrations/WordPress/Content/ContentGetAbility.php +++ b/src/Integrations/WordPress/Content/ContentGetAbility.php @@ -59,14 +59,14 @@ final class ContentGetAbility implements Ability public function permission(array $input): bool|WP_Error { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); return $post instanceof WP_Error ? $post : true; } public function execute(array $input): mixed { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); if ($post instanceof WP_Error) { return $post; } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index fe92ad2..3d439c2 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -113,6 +113,11 @@ final class ContentUpdateAbility implements Ability return Errors::revMismatch(); } + $unstable = PostGuard::statusStable($post); + if ($unstable !== null) { + return $unstable; + } + if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) { return Errors::unsupported( 'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.', @@ -279,6 +284,12 @@ final class ContentUpdateAbility implements Ability return; } + // Both restore paths save through wp_update_post(), which would publish a missed schedule. + $current = get_post((int) $post->ID); + if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { + throw new RuntimeException('Post row not restored: saving it now would publish a missed schedule.'); + } + $fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED)); if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) { $restored = wp_restore_post_revision($revisionId, $fromRevision); diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index d247543..3aa842e 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -16,8 +16,17 @@ use WP_Post; */ final class PostGuard { - public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error - { + /** + * Loads an entry the current user may edit. Trashed entries are refused (every write + * on them would be surprising: publishing, scheduling or updating a trashed entry); + * readers and lit/post-trash pass $allowTrashed. + */ + public static function editable( + mixed $postId, + ContentSchema $schema, + string $field = 'post_id', + bool $allowTrashed = false + ): WP_Post|WP_Error { $id = self::id($postId); if ($id === null) { return Errors::invalidInput( @@ -35,9 +44,55 @@ final class PostGuard return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id)); } + if (! $allowTrashed && $post->post_status === 'trash') { + return Errors::invalidInput( + sprintf('Eintrag %d liegt im Papierkorb.', $id), + [$field => 'Eintrag liegt im Papierkorb'], + 'Eintrag zuerst in WordPress wiederherstellen.' + ); + } + return $post; } + /** + * A scheduled entry whose publication time has passed (WordPress' cron missed it). + * wp_update_post() publishes such an entry on any save, so writes must not touch it. + */ + public static function missedSchedule(WP_Post $post): bool + { + $gmt = (string) $post->post_date_gmt; + if ($post->post_status !== 'future' || $gmt === '' || $gmt === '0000-00-00 00:00:00') { + return false; + } + + $time = strtotime($gmt . ' UTC'); + + return $time !== false && $time <= time(); + } + + /** + * Refuses a write that would change the status as a side effect (spec §2: status is only + * changed on explicit request) – currently a missed schedule, which any save would publish. + */ + public static function statusStable(WP_Post $post, string $field = 'post_id'): ?WP_Error + { + if (! self::missedSchedule($post)) { + return null; + } + + return Errors::invalidInput( + sprintf( + 'Eintrag %d ist geplant, der Veröffentlichungszeitpunkt ist aber schon verstrichen; jedes Speichern ' + . 'würde ihn sofort veröffentlichen. Nichts wurde gespeichert.', + (int) $post->ID + ), + [$field => 'geplanter Zeitpunkt verstrichen'], + 'Beim Nutzer nachfragen: jetzt veröffentlichen (lit-post-publish) oder neu planen (lit/post-schedule ' + . 'über mcp-adapter-execute-ability); danach die Änderung wiederholen.' + ); + } + /** * The brief declares this `: object|WP_Error`, but `object` already covers every * class type, so PHP refuses `object` inside a union ("Type object cannot be diff --git a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php index 4055bd7..c6b9c88 100644 --- a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php @@ -111,6 +111,12 @@ final class RevisionRestoreAbility implements Ability return Errors::revMismatch(); } + // wp_restore_post_revision() saves via wp_update_post(), which would publish a missed schedule. + $unstable = PostGuard::statusStable($post, 'revision_id'); + if ($unstable !== null) { + return $unstable; + } + $backupId = RevisionPoint::capture($postId); $restored = wp_restore_post_revision((int) $revision->ID); if (! $restored) { diff --git a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php index d31f4bc..4bdd893 100644 --- a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php @@ -98,14 +98,14 @@ final class RevisionsListAbility implements Ability public function permission(array $input): bool|WP_Error { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); return $post instanceof WP_Error ? $post : true; } public function execute(array $input): mixed { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); if ($post instanceof WP_Error) { return $post; } diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php index 6c62d7d..fdfa993 100644 --- a/src/Integrations/WordPress/Status/PostCloner.php +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\WordPress\PostGuard; use RuntimeException; use Throwable; use WP_Error; @@ -79,7 +80,8 @@ class PostCloner /** * Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy - * into the original. Status, slug, date and author of the original stay. + * into the original. Status, slug, date and author of the original stay. Everything is + * read before the first write, so a failed read leaves the original untouched. * * @throws ReplaceFailed Naming the parts that were (possibly) written. */ @@ -88,14 +90,15 @@ class PostCloner $originalId = (int) $original->ID; $parts = []; try { + $meta = $this->ownMeta((int) $copy->ID); + $terms = $this->termIds($copy); + $this->assertNoMissedSchedule($originalId); $parts[] = 'post'; $this->updateRow($originalId, [ 'post_title' => self::withoutSuffix((string) $copy->post_title), 'post_content' => (string) $copy->post_content, 'post_excerpt' => (string) $copy->post_excerpt, ]); - $meta = $this->ownMeta((int) $copy->ID); - $terms = $this->termIds($copy); $parts[] = 'meta'; $this->replaceMeta($originalId, $meta); $parts[] = 'terms'; @@ -225,6 +228,7 @@ class PostCloner */ private function updateRow(int $postId, array $columns): void { + $this->assertNoMissedSchedule($postId); $result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true); if ($result instanceof WP_Error || ! $result) { $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; @@ -232,14 +236,33 @@ class PostCloner } } + /** + * wp_update_post() publishes a scheduled post whose date has passed – never rewrite such a row. + * + * @throws RuntimeException + */ + private function assertNoMissedSchedule(int $postId): void + { + $post = get_post($postId); + if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) { + throw new RuntimeException(sprintf( + 'Post %d not written: saving it now would publish a missed schedule.', + $postId + )); + } + } + /** * Stored meta of a post without SKIP_META: meta_value strings exactly as in the database. + * A failed query is an error – treating it as "no meta" would wipe meta on replace(). * * @return array> + * @throws RuntimeException */ private function ownMeta(int $postId): array { $db = $this->db(); + $db->last_error = ''; $rows = $db->get_results( $db->prepare( "SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id", @@ -247,9 +270,16 @@ class PostCloner ), 'ARRAY_A' ); + if (! is_array($rows) || (string) $db->last_error !== '') { + throw new RuntimeException(sprintf( + 'Meta of post %d could not be read: %s', + $postId, + (string) $db->last_error !== '' ? (string) $db->last_error : 'no result' + )); + } $meta = []; - foreach ((array) $rows as $row) { + foreach ($rows as $row) { $key = (string) $row['meta_key']; if (! in_array($key, self::SKIP_META, true)) { $meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value']; diff --git a/src/Integrations/WordPress/Status/PostDuplicateAbility.php b/src/Integrations/WordPress/Status/PostDuplicateAbility.php index b0988b5..704cadf 100644 --- a/src/Integrations/WordPress/Status/PostDuplicateAbility.php +++ b/src/Integrations/WordPress/Status/PostDuplicateAbility.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\Ability; -use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; @@ -126,14 +125,6 @@ final class PostDuplicateAbility implements Ability return $post; } - if ($post->post_status === 'trash') { - return Errors::invalidInput( - 'Der Eintrag liegt im Papierkorb.', - ['post_id' => 'Eintrag liegt im Papierkorb'], - 'Eintrag zuerst in WordPress wiederherstellen.' - ); - } - $type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts'); return $type instanceof WP_Error ? $type : $post; diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 0386f45..711e1f2 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -134,9 +134,9 @@ final class PostReplaceWithCopyAbility implements Ability try { $this->cloner->replace($original, $copy); } catch (ReplaceFailed $e) { - $this->rollBack($original, $snapshot, $e->parts, $revisionId, $e->getPrevious() ?? $e); + $this->rollBack($original, $snapshot, $e->parts, $revisionId, $backupId, $e->getPrevious() ?? $e); } catch (Throwable $e) { - $this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $e); + $this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $backupId, $e); } return $this->result($original, $copy, $input, $revisionId, $backupId); @@ -169,14 +169,6 @@ final class PostReplaceWithCopyAbility implements Ability ); } - if ($original->post_status === 'trash') { - return Errors::invalidInput( - 'Das Original liegt im Papierkorb.', - ['original_id' => 'Original liegt im Papierkorb'], - 'Original zuerst in WordPress wiederherstellen.' - ); - } - if (! in_array($copy->post_status, ['draft', 'pending'], true)) { return Errors::invalidInput( sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status), @@ -189,6 +181,11 @@ final class PostReplaceWithCopyAbility implements Ability return Errors::revMismatch(); } + $unstable = PostGuard::statusStable($original, 'original_id'); + if ($unstable !== null) { + return $unstable; + } + if (! ($this->trashEnabled)()) { return Errors::unsupported( 'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. ' @@ -269,17 +266,20 @@ final class PostReplaceWithCopyAbility implements Ability * @param array{post: array, meta: array>, terms: array>} * $snapshot Output of PostCloner::snapshot(). * @param list $parts Parts replace() had (possibly) written. - * @throws WriteFailed Always – rolledBack tells whether the original was restored completely. + * @throws WriteFailed Always – rolledBack tells whether the original was restored completely; + * both name the backup (backup_id), which stays in the trash. */ private function rollBack( WP_Post $original, array $snapshot, array $parts, ?int $revisionId, + int $backupId, Throwable $cause ): never { $originalId = (int) $original->ID; $detail = sprintf('Replace of post %d failed: %s', $originalId, $cause->getMessage()); + $details = ['backup_id' => $backupId]; try { $this->cloner->restore($original, $snapshot, $parts); } catch (Throwable $e) { @@ -296,11 +296,33 @@ final class PostReplaceWithCopyAbility implements Ability $originalId, $revisionId, $cause, - $detail . '; rollback incomplete: ' . $e->getMessage() + $detail . '; rollback incomplete: ' . $e->getMessage(), + sprintf( + 'Eintrag %d ist möglicherweise teilweise geändert worden (Stand vorher: Revision %s); die ' + . 'vollständige Sicherung des vorherigen Stands liegt als Eintrag %d im Papierkorb.', + $originalId, + $revisionId === null ? '–' : (string) $revisionId, + $backupId + ), + 'Den Aufruf nicht einfach wiederholen. Zuerst den Stand mit lit-content-get prüfen; Titel, Inhalt ' + . 'und Auszug lassen sich mit lit/revision-restore (revision_id) zurückholen, Felder/Meta und ' + . 'Begriffe stehen in der Sicherung backup_id (in WordPress aus dem Papierkorb holen). Dem ' + . 'Nutzer Bescheid geben.', + $details ); } - throw new WriteFailed(self::REASON, true, $originalId, $revisionId, $cause, $detail); + throw new WriteFailed( + self::REASON, + true, + $originalId, + $revisionId, + $cause, + $detail, + null, + null, + $details + ); } /** @@ -323,6 +345,9 @@ final class PostReplaceWithCopyAbility implements Ability return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.'); } - return [$original, $copy]; + // The swap first clones the original into a backup entry of the same type. + $type = PostGuard::postType((string) $original->post_type, $this->schema, 'create_posts'); + + return $type instanceof WP_Error ? $type : [$original, $copy]; } } diff --git a/src/Integrations/WordPress/Status/PostTrashAbility.php b/src/Integrations/WordPress/Status/PostTrashAbility.php index 4933da6..4c91efb 100644 --- a/src/Integrations/WordPress/Status/PostTrashAbility.php +++ b/src/Integrations/WordPress/Status/PostTrashAbility.php @@ -144,7 +144,7 @@ final class PostTrashAbility implements Ability /** @param array $input */ private function load(array $input): WP_Post|WP_Error { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); if ($post instanceof WP_Error) { return $post; } diff --git a/tests/Stubs/Wpdb.php b/tests/Stubs/Wpdb.php index 61c4338..541ca8c 100644 --- a/tests/Stubs/Wpdb.php +++ b/tests/Stubs/Wpdb.php @@ -16,6 +16,7 @@ if (! class_exists('wpdb')) { public $usermeta = 'wp_usermeta'; public $options = 'wp_options'; public $insert_id = 0; + public $last_error = ''; public function prepare($query, ...$args) { diff --git a/tests/Unit/Abilities/WriteFailedTest.php b/tests/Unit/Abilities/WriteFailedTest.php index f9a3fe5..d0728ab 100644 --- a/tests/Unit/Abilities/WriteFailedTest.php +++ b/tests/Unit/Abilities/WriteFailedTest.php @@ -46,4 +46,13 @@ final class WriteFailedTest extends TestCase self::assertSame('Mit lit-menu-get prüfen.', $error->get_error_data()['hint']); self::assertSame(3, $error->get_error_data()['menu_id']); } + + public function testCleanRollbackKeepsItsDetails(): void + { + $error = (new WriteFailed('Nicht übernommen.', true, details: ['backup_id' => 88]))->toError(); + + self::assertSame('lit_write_failed', $error->get_error_code()); + self::assertSame(88, $error->get_error_data()['backup_id']); + self::assertStringContainsString('"backup_id":88', $error->get_error_message()); + } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php index 7555a50..1f37160 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php @@ -29,6 +29,19 @@ final class ContentGetAbilityTest extends TestCase self::assertSame(['id' => 5], $ability->execute(['post_id' => 5])); } + public function testATrashedPostCanStillBeRead(): void + { + $post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $presenter = Mockery::mock(PostPresenter::class); + $presenter->expects('full')->with($post)->andReturn(['id' => 5]); + + self::assertSame(['id' => 5], (new ContentGetAbility($schema, $presenter))->execute(['post_id' => 5])); + } + public function testMissingPostIsNotFound(): void { Functions\when('get_post')->justReturn(null); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php index d8f0f09..2f8aed8 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php @@ -61,6 +61,32 @@ final class ContentUpdateAbilityTest extends TestCase ); } + public function testATrashedPostIsRefused(): void + { + $this->post->post_status = 'trash'; + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(PostWriter::class)) + ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testAMissedScheduleIsRefusedBecauseSavingWouldPublish(): void + { + $this->post->post_status = 'future'; + $this->post->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 600); + Functions\expect('wp_update_post')->never(); + Functions\expect('wp_save_post_revision')->never(); + + $result = $this->ability(Mockery::mock(PostWriter::class)) + ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('post_id', $result->get_error_data()['fields']); + self::assertStringContainsString('lit/post-schedule', $result->get_error_data()['hint']); + } + public function testStaleRevIsRejectedWithoutWriting(): void { Functions\expect('wp_update_post')->never(); @@ -254,6 +280,30 @@ final class ContentUpdateAbilityTest extends TestCase self::assertSame(['post_name' => 'team', 'ID' => 5], $updates[1]); } + public function testTheRollbackNeverRewritesARowWhoseScheduleHasPassedMeanwhile(): void + { + [$writer, $input] = $this->failingWriter(['post_name' => 'unser-team']); + $post = $this->post; + $updates = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates, $post): int { + $updates[] = $data; + // The entry turned into a missed schedule while the call ran (e.g. edited in WordPress). + $post->post_status = 'future'; + $post->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 5); + return 5; + }); + Functions\when('wp_revisions_enabled')->justReturn(true); + Functions\expect('wp_restore_post_revision')->never(); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial'); + + $failure = $this->executeExpectingFailure($writer, $input, $log); + + self::assertFalse($failure->rolledBack); + self::assertCount(1, $updates); + self::assertStringContainsString('publish', $failure->getMessage()); + } + public function testAnIncompleteRollbackIsAPartialWriteAndLogged(): void { [$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team'], false); diff --git a/tests/Unit/Integrations/WordPress/PostGuardTest.php b/tests/Unit/Integrations/WordPress/PostGuardTest.php index 5397f2b..a06d948 100644 --- a/tests/Unit/Integrations/WordPress/PostGuardTest.php +++ b/tests/Unit/Integrations/WordPress/PostGuardTest.php @@ -47,6 +47,44 @@ final class PostGuardTest extends TestCase self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code()); } + public function testTrashedPostsAreRefusedUnlessAllowed(): void + { + $post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + + $error = PostGuard::editable(5, $this->schema(), 'copy_id'); + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('copy_id', $error->get_error_data()['fields']); + self::assertSame($post, PostGuard::editable(5, $this->schema(), 'post_id', true)); + } + + public function testMissedScheduleIsAFuturePostWhoseDateHasPassed(): void + { + $past = gmdate('Y-m-d H:i:s', time() - 3600); + $future = gmdate('Y-m-d H:i:s', time() + 3600); + + self::assertTrue(PostGuard::missedSchedule(new WP_Post(['post_status' => 'future', 'post_date_gmt' => $past]))); + self::assertFalse(PostGuard::missedSchedule(new WP_Post(['post_status' => 'future', 'post_date_gmt' => $future]))); + self::assertFalse(PostGuard::missedSchedule(new WP_Post(['post_status' => 'draft', 'post_date_gmt' => $past]))); + self::assertFalse(PostGuard::missedSchedule( + new WP_Post(['post_status' => 'future', 'post_date_gmt' => '0000-00-00 00:00:00']) + )); + } + + public function testStatusStableRefusesAMissedScheduleWithAHint(): void + { + $post = new WP_Post(['ID' => 5, 'post_status' => 'future', 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 60)]); + + $error = PostGuard::statusStable($post, 'original_id'); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('original_id', $error->get_error_data()['fields']); + self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']); + self::assertStringContainsString('lit/post-schedule', $error->get_error_data()['hint']); + self::assertNull(PostGuard::statusStable(new WP_Post(['post_status' => 'publish']))); + } + public function testPostTypeChecksAvailabilityAndCapability(): void { $object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']]; diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php index d309937..97df141 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php @@ -76,6 +76,45 @@ final class RevisionRestoreAbilityTest extends TestCase self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']); } + public function testATrashedEntryIsRefused(): void + { + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5]), + 5 => new WP_Post(array_merge(self::CURRENT_POST, ['post_status' => 'trash'])), + default => null, + }); + Functions\expect('wp_restore_post_revision')->never(); + + $error = $this->ability(Mockery::mock(ChangeLog::class))->execute(['revision_id' => 810, 'rev' => 'x', 'confirm' => true]); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']); + } + + public function testAMissedScheduleIsRefusedBecauseRestoringWouldPublish(): void + { + $current = array_merge(self::CURRENT_POST, [ + 'post_status' => 'future', + 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 600), + ]); + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5]), + 5 => new WP_Post($current), + default => null, + }); + Functions\expect('wp_save_post_revision')->never(); + Functions\expect('wp_restore_post_revision')->never(); + + $error = $this->ability(Mockery::mock(ChangeLog::class))->execute([ + 'revision_id' => 810, + 'rev' => Rev::ofPost(new WP_Post($current)), + 'confirm' => true, + ]); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']); + } + public function testMissingOrStaleRevIsRevMismatch(): void { Functions\expect('wp_restore_post_revision')->never(); diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php index 8aeb846..89636e3 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php @@ -69,6 +69,14 @@ final class RevisionsListAbilityTest extends TestCase self::assertSame([PostPresenter::DIVI_HINT], $result['hints']); } + public function testRevisionsOfATrashedPostCanStillBeListed(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash'])); + Functions\when('wp_get_post_revisions')->justReturn([]); + + self::assertSame([], $this->ability()->execute(['post_id' => 5])['items']); + } + public function testInvalidCursorIsRejected(): void { $result = $this->ability()->execute(['post_id' => 5, 'cursor' => 'not-a-cursor']); diff --git a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php index 491ce4a..f2c58c0 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php @@ -24,17 +24,30 @@ final class PostClonerTest extends TestCase private bool $insertFails = false; + /** Post IDs whose meta SELECT fails: 'null' returns null, 'error' sets $wpdb->last_error. */ + private array $metaReadFails = []; + protected function setUp(): void { parent::setUp(); Functions\when('wp_cache_delete')->justReturn(true); + Functions\when('get_post')->justReturn(null); } private function cloner(): PostCloner { $db = Mockery::mock(\wpdb::class)->makePartial(); - $db->allows('get_results')->andReturnUsing(function (string $sql): array { + $db->allows('get_results')->andReturnUsing(function (string $sql) use ($db): ?array { preg_match('/post_id = (\d+)/', $sql, $match); + $db->last_error = ''; + $failure = $this->metaReadFails[(int) $match[1]] ?? null; + if ($failure === 'null') { + return null; + } + if ($failure === 'error') { + $db->last_error = 'Table wp_postmeta is marked as crashed'; + return []; + } $rows = []; foreach ($this->meta[(int) $match[1]] ?? [] as $key => $values) { foreach ($values as $value) { @@ -246,6 +259,7 @@ final class PostClonerTest extends TestCase public function testAReplaceRejectedByWordPressNamesOnlyThePostRow(): void { + Functions\when('get_object_taxonomies')->justReturn([]); Functions\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt')); Functions\expect('delete_post_meta')->never(); @@ -272,6 +286,96 @@ final class PostClonerTest extends TestCase } } + /** @return array */ + public function metaReadFailures(): array + { + return ['null result' => ['null'], 'database error' => ['error']]; + } + + /** @dataProvider metaReadFailures */ + public function testAFailedMetaReadIsAnErrorNotEmptyMeta(string $failure): void + { + $this->metaReadFails = [5 => $failure]; + Functions\when('get_object_taxonomies')->justReturn([]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Meta of post 5 could not be read'); + + $this->cloner()->snapshot($this->original()); + } + + /** @dataProvider metaReadFailures */ + public function testReplaceReadsTheCopyBeforeWritingAnything(string $failure): void + { + $this->metaReadFails = [77 => $failure]; + Functions\when('get_object_taxonomies')->justReturn([]); + Functions\expect('wp_update_post')->never(); + Functions\expect('delete_post_meta')->never(); + + try { + $this->cloner()->replace($this->original(), $this->copy()); + self::fail('ReplaceFailed expected'); + } catch (ReplaceFailed $e) { + self::assertSame([], $e->parts); + self::assertStringContainsString('Meta of post 77 could not be read', $e->getMessage()); + } + } + + public function testADuplicateWithAFailedMetaReadIsDeletedAgain(): void + { + $this->metaReadFails = [5 => 'error']; + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('wp_insert_post')->justReturn(90); + Functions\expect('wp_delete_post')->once()->with(90, true)->andReturn(new WP_Post(['ID' => 90])); + + try { + $this->cloner()->duplicate($this->original()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + private function missedSchedule(): void + { + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 5, + 'post_type' => 'page', + 'post_status' => 'future', + 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 60), + ])); + } + + public function testReplaceRefusesAMissedScheduleBeforeWriting(): void + { + $this->missedSchedule(); + Functions\when('get_object_taxonomies')->justReturn([]); + Functions\expect('wp_update_post')->never(); + + try { + $this->cloner()->replace($this->original(), $this->copy()); + self::fail('ReplaceFailed expected'); + } catch (ReplaceFailed $e) { + self::assertSame([], $e->parts); + self::assertStringContainsString('missed schedule', $e->getMessage()); + } + } + + public function testRestoreNeverRewritesTheRowOfAMissedSchedule(): void + { + $this->missedSchedule(); + Functions\expect('wp_update_post')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('missed schedule'); + + $this->cloner()->restore($this->original(), [ + 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], + 'meta' => [], + 'terms' => [], + ], ['post']); + } + public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void { $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['b:0;']]]; diff --git a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php index 54a1884..28d4efb 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php @@ -74,6 +74,16 @@ final class PostPublishAbilityTest extends TestCase self::assertStringContainsString('bereits veröffentlicht', $result['summary']); } + public function testATrashedPostIsRefused(): void + { + $this->stubPost(['post_status' => 'trash']); + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testPermissionNeedsPublishRights(): void { $this->stubPost(['post_status' => 'draft']); diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php index a6bc78c..6095927 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -31,6 +31,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']); Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy); Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); Functions\when('get_post_meta')->justReturn('5'); Functions\when('get_permalink')->justReturn('https://kunde.de/team/'); Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); @@ -105,6 +106,20 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::assertSame('lit_validation_failed', $result->get_error_code()); } + public function testAMissedScheduleOfTheOriginalIsRefused(): void + { + $this->original->post_status = 'future'; + $this->original->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 600); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('backup')->never(); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('original_id', $result->get_error_data()['fields']); + } + public function testAPendingCopyIsAccepted(): void { $this->copy->post_status = 'pending'; @@ -215,6 +230,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertSame(88, $e->toError()->get_error_data()['backup_id']); } } @@ -239,10 +255,26 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::assertFalse($e->rolledBack); self::assertSame(5, $e->postId); self::assertSame(900, $e->revisionId); - self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + $error = $e->toError(); + self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertSame(88, $error->get_error_data()['backup_id']); + self::assertStringContainsString('Sicherung', $error->get_error_message()); + self::assertStringContainsString('88', $error->get_error_message()); } } + public function testNeedsTheRightToCreateEntriesForTheBackup(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('backup')->never(); + $cloner->expects('replace')->never(); + $ability = $this->ability($cloner, Mockery::mock(ChangeLog::class)); + + self::assertSame('lit_forbidden', $ability->permission($this->input())->get_error_code()); + self::assertSame('lit_forbidden', $ability->execute($this->input())->get_error_code()); + } + public function testAFailedBackupAbortsBeforeTheOriginalIsTouched(): void { $cloner = Mockery::mock(PostCloner::class); diff --git a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php index 8bc6214..470eb11 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php @@ -52,6 +52,16 @@ final class PostScheduleAbilityTest extends TestCase self::assertStringContainsString('2099-06-01 09:30', $result['summary']); } + public function testATrashedPostIsRefused(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'trash'])); + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testPastAndImpossibleDatesAreRejected(): void { Functions\expect('wp_update_post')->never(); diff --git a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php index e19e9ea..c8029d6 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php @@ -60,6 +60,18 @@ final class PostTrashAbilityTest extends TestCase self::assertSame('lit_unsupported', $result->get_error_code()); } + public function testAnAlreadyTrashedPostIsANoOp(): void + { + $ability = $this->ability(Mockery::mock(ChangeLog::class)); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Alt', 'post_status' => 'trash'])); + Functions\expect('wp_trash_post')->never(); + + $result = $ability->execute(['post_id' => 5, 'confirm' => true]); + + self::assertSame([], $result['changed']); + self::assertStringContainsString('bereits im Papierkorb', $result['summary']); + } + public function testIsDestructiveAndIndirect(): void { $ability = $this->ability(Mockery::mock(ChangeLog::class)); diff --git a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php index 955d6c4..9a0954a 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php @@ -37,6 +37,15 @@ final class PostUnpublishAbilityTest extends TestCase self::assertSame('unpublished', $result['changed'][0]['action']); } + public function testATrashedPostIsRefusedInsteadOfBecomingADraft(): void + { + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class), 'trash')->execute(['post_id' => 5]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testDraftIsANoOp(): void { Functions\expect('wp_update_post')->never(); From e57b53d0e520e8d8a4ec45fa5b52b575afc06cd3 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:22:34 +0200 Subject: [PATCH 049/185] refactor: PostChangeResult for the after-save block of the post tools after_save hook, re-read, change log and ToolResult (rev, log_id, warnings) were repeated in nine post abilities; they now share one helper. Behaviour and warning texts are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Content/ContentCreateAbility.php | 27 ++---- .../Content/ContentUpdateAbility.php | 26 ++---- .../WordPress/PostChangeResult.php | 89 ++++++++++++++++++ .../Revisions/RevisionRestoreAbility.php | 26 +----- .../WordPress/Status/PostDuplicateAbility.php | 27 ++---- .../WordPress/Status/PostPublishAbility.php | 27 +----- .../Status/PostReplaceWithCopyAbility.php | 25 +---- .../WordPress/Status/PostScheduleAbility.php | 28 +----- .../WordPress/Status/PostTrashAbility.php | 20 +--- .../WordPress/Status/PostUnpublishAbility.php | 28 +----- .../Content/ContentCreateAbilityTest.php | 2 +- .../WordPress/PostChangeResultTest.php | 91 +++++++++++++++++++ .../Status/PostDuplicateAbilityTest.php | 2 +- .../Status/PostPublishAbilityTest.php | 2 +- .../WordPress/Status/PostTrashAbilityTest.php | 2 +- 15 files changed, 231 insertions(+), 191 deletions(-) create mode 100644 src/Integrations/WordPress/PostChangeResult.php create mode 100644 tests/Unit/Integrations/WordPress/PostChangeResultTest.php diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index feb590d..e4a14e7 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -6,11 +6,10 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Abilities\FollowUp; -use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use Throwable; @@ -136,29 +135,15 @@ final class ContentCreateAbility implements Ability private function result(int $postId, string $postType, array $input, array $warnings): array { // The draft is saved – failures from here on are logged and reported as warnings only. - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; der Entwurf ist gespeichert.', - $warnings - ); - - $post = get_post($postId); + $change = (new PostChangeResult($this->name(), $this->log, $postId, 'der Entwurf', $warnings)) + ->afterSave($input); + $post = $change->reread(); $title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title']; - $summary = sprintf('Entwurf "%s" angelegt', $title); - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), $postType, (string) $postId, $summary), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; der Entwurf ist gespeichert.', - $warnings - ); + $change->log($postType, sprintf('Entwurf "%s" angelegt', $title)); - return ToolResult::ok(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId)) + return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) ->changed((string) $postId, $postType, 'created', $title) - ->rev($post instanceof WP_Post ? Rev::ofPost($post) : null) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) - ->warnings($warnings) ->toArray(); } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 3d439c2..b8a32d7 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -6,11 +6,11 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\RevisionPoint; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -178,31 +178,17 @@ final class ContentUpdateAbility implements Ability $postType = (string) $post->post_type; // The change is saved – failures from here on are logged and reported as warnings only. - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.', - $warnings - ); - - $updated = get_post($postId); - $updated = $updated instanceof WP_Post ? $updated : $post; + $change = (new PostChangeResult($this->name(), $this->log, $postId, 'die Änderung', $warnings)) + ->afterSave($input); + $updated = $change->reread($post) ?? $post; $changedKeys = array_keys(array_intersect_key($input, array_flip(self::CHANGE_KEYS))); $summary = sprintf('"%s" geändert: %s', $updated->post_title, implode(', ', $changedKeys)); - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), $postType, (string) $postId, $summary, $revisionId), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', - $warnings - ); + $change->log($postType, $summary, $revisionId); - return ToolResult::ok(sprintf('%s (Status unverändert: %s).', $summary, $updated->post_status)) + return $change->result(sprintf('%s (Status unverändert: %s).', $summary, $updated->post_status), $updated) ->changed((string) $postId, $postType, 'updated', (string) $updated->post_title) - ->rev(Rev::ofPost($updated)) ->revisionId($revisionId) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) - ->warnings($warnings) ->toArray(); } diff --git a/src/Integrations/WordPress/PostChangeResult.php b/src/Integrations/WordPress/PostChangeResult.php new file mode 100644 index 0000000..b4e317a --- /dev/null +++ b/src/Integrations/WordPress/PostChangeResult.php @@ -0,0 +1,89 @@ + */ + private array $warnings; + + private ?int $logId = null; + + /** + * @param string $saved What is saved, for the warnings, e.g. "die Änderung", "der Entwurf". + * @param list $warnings Warnings collected before the write. + */ + public function __construct( + private string $ability, + private ChangeLog $log, + private int $postId, + private string $saved = 'die Änderung', + array $warnings = [] + ) { + $this->warnings = $warnings; + } + + /** + * @param array $input + * @param string $after The step just done, e.g. "Speichern", "Veröffentlichen". + */ + public function afterSave(array $input, string $after = 'Speichern'): self + { + FollowUp::run( + fn () => do_action('lit_mcp_after_save', $this->postId, $this->ability, $input), + $this->ability . ' lit_mcp_after_save', + sprintf( + 'Eine Erweiterung ist nach dem %s auf einen Fehler gestoßen; %s ist gespeichert.', + $after, + $this->saved + ), + $this->warnings + ); + + return $this; + } + + /** The saved post as stored now; $fallback when it cannot be read. */ + public function reread(?WP_Post $fallback = null): ?WP_Post + { + $post = get_post($this->postId); + + return $post instanceof WP_Post ? $post : $fallback; + } + + public function log(string $objectType, string $summary, ?int $revisionId = null): self + { + $logId = FollowUp::run( + fn () => $this->log->record($this->ability, $objectType, (string) $this->postId, $summary, $revisionId), + $this->ability . ' change log', + sprintf('Änderungsprotokoll konnte nicht geschrieben werden; %s ist gespeichert.', $this->saved), + $this->warnings + ); + $this->logId = is_int($logId) ? $logId : null; + + return $this; + } + + /** Result with the rev of $post, the log id and all warnings; callers add changed, urls, … */ + public function result(string $summary, ?WP_Post $post): ToolResult + { + return ToolResult::ok($summary) + ->rev($post instanceof WP_Post ? Rev::ofPost($post) : null) + ->logId($this->logId) + ->warnings($this->warnings); + } +} diff --git a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php index c6b9c88..32014a9 100644 --- a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php @@ -6,10 +6,10 @@ namespace LIT\MCP\Integrations\WordPress\Revisions; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\RevisionPoint; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -124,32 +124,16 @@ final class RevisionRestoreAbility implements Ability } // The restore is saved – failures from here on are logged and reported as warnings only. - $warnings = []; - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Wiederherstellen auf einen Fehler gestoßen; die Änderung ist gespeichert.', - $warnings - ); - - $updated = get_post($postId); - $updated = $updated instanceof WP_Post ? $updated : $post; + $change = (new PostChangeResult($this->name(), $this->log, $postId))->afterSave($input, 'Wiederherstellen'); + $updated = $change->reread($post) ?? $post; $summary = sprintf('"%s" auf Stand vom %s zurückgesetzt', $updated->post_title, $revision->post_date); $postType = (string) $post->post_type; - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), $postType, (string) $postId, $summary, $backupId), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', - $warnings - ); + $change->log($postType, $summary, $backupId); - return ToolResult::ok($summary . sprintf(' (Status unverändert: %s).', $updated->post_status)) + return $change->result($summary . sprintf(' (Status unverändert: %s).', $updated->post_status), $updated) ->changed((string) $postId, $postType, 'restored', (string) $updated->post_title) - ->rev(Rev::ofPost($updated)) ->revisionId($backupId) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) - ->warnings($warnings) ->toArray(); } diff --git a/src/Integrations/WordPress/Status/PostDuplicateAbility.php b/src/Integrations/WordPress/Status/PostDuplicateAbility.php index 704cadf..2a8406f 100644 --- a/src/Integrations/WordPress/Status/PostDuplicateAbility.php +++ b/src/Integrations/WordPress/Status/PostDuplicateAbility.php @@ -5,10 +5,9 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\Ability; -use LIT\MCP\Abilities\FollowUp; -use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use WP_Error; @@ -88,31 +87,17 @@ final class PostDuplicateAbility implements Ability $copyId = $this->cloner->duplicate($original); // The copy is saved – failures from here on are logged and reported as warnings only. - $warnings = []; - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $copyId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Anlegen der Kopie auf einen Fehler gestoßen; die Kopie ist gespeichert.', - $warnings - ); - - $copy = get_post($copyId); + $change = (new PostChangeResult($this->name(), $this->log, $copyId, 'die Kopie')) + ->afterSave($input, 'Anlegen der Kopie'); + $copy = $change->reread(); $copyTitle = $copy instanceof WP_Post ? (string) $copy->post_title : ''; $postType = (string) $original->post_type; $summary = sprintf('Kopie von "%s" (ID %d) angelegt', $original->post_title, $original->ID); - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), $postType, (string) $copyId, $summary), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Kopie ist gespeichert.', - $warnings - ); + $change->log($postType, $summary); - return ToolResult::ok(sprintf('%s: Entwurf ID %d.', $summary, $copyId)) + return $change->result(sprintf('%s: Entwurf ID %d.', $summary, $copyId), $copy) ->changed((string) $copyId, $postType, 'created', $copyTitle) - ->rev($copy instanceof WP_Post ? Rev::ofPost($copy) : null) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($copyId), (string) get_edit_post_link($copyId, 'raw')) - ->warnings($warnings) ->with('original_id', (int) $original->ID) ->toArray(); } diff --git a/src/Integrations/WordPress/Status/PostPublishAbility.php b/src/Integrations/WordPress/Status/PostPublishAbility.php index 67b0c46..3c31632 100644 --- a/src/Integrations/WordPress/Status/PostPublishAbility.php +++ b/src/Integrations/WordPress/Status/PostPublishAbility.php @@ -6,10 +6,10 @@ namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use WP_Error; @@ -114,31 +114,12 @@ final class PostPublishAbility implements Ability } // The status change is saved – failures from here on are logged and reported as warnings only. - $warnings = []; - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Veröffentlichen auf einen Fehler gestoßen; die Änderung ist gespeichert.', - $warnings - ); + $change = (new PostChangeResult($this->name(), $this->log, $postId))->afterSave($input, 'Veröffentlichen'); + $change->log((string) $post->post_type, sprintf('"%s" veröffentlicht', $title)); - $summary = sprintf('"%s" veröffentlicht', $title); - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', - $warnings - ); - - $updated = get_post($postId); - $updated = $updated instanceof WP_Post ? $updated : $post; - - return ToolResult::ok(sprintf('"%s" ist jetzt veröffentlicht.', $title)) + return $change->result(sprintf('"%s" ist jetzt veröffentlicht.', $title), $change->reread($post)) ->changed((string) $postId, (string) $post->post_type, 'published', $title) - ->rev(Rev::ofPost($updated)) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_permalink($postId), $editUrl) - ->warnings($warnings) ->toArray(); } diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 711e1f2..31675db 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -11,6 +11,7 @@ use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\RevisionPoint; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -221,24 +222,11 @@ final class PostReplaceWithCopyAbility implements Ability $warnings[] = $trashWarning; } - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $originalId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Speichern auf einen Fehler gestoßen; die Änderung ist gespeichert.', - $warnings - ); - + $change = (new PostChangeResult($this->name(), $this->log, $originalId, 'die Änderung', $warnings)) + ->afterSave($input); $title = PostCloner::withoutSuffix((string) $copy->post_title); $summary = sprintf('Kopie ID %d in "%s" (ID %d) übernommen', $copyId, $title, $originalId); - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), $postType, (string) $originalId, $summary, $revisionId), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', - $warnings - ); - - $updated = get_post($originalId); - $updated = $updated instanceof WP_Post ? $updated : $original; + $change->log($postType, $summary, $revisionId); $statusNote = sprintf( '; Status bleibt %s, %s, der vorherige Stand liegt als Sicherung ID %d im Papierkorb.', $original->post_status, @@ -246,18 +234,15 @@ final class PostReplaceWithCopyAbility implements Ability $backupId ); - $result = ToolResult::ok($summary . $statusNote) + $result = $change->result($summary . $statusNote, $change->reread($original)) ->changed((string) $originalId, $postType, 'replaced', $title); if ($trashed) { $result->changed((string) $copyId, $postType, 'trashed', (string) $copy->post_title); } return $result - ->rev(Rev::ofPost($updated)) ->revisionId($revisionId) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_permalink($originalId), (string) get_edit_post_link($originalId, 'raw')) - ->warnings($warnings) ->with('backup_id', $backupId) ->toArray(); } diff --git a/src/Integrations/WordPress/Status/PostScheduleAbility.php b/src/Integrations/WordPress/Status/PostScheduleAbility.php index d6ce674..986a82c 100644 --- a/src/Integrations/WordPress/Status/PostScheduleAbility.php +++ b/src/Integrations/WordPress/Status/PostScheduleAbility.php @@ -8,10 +8,9 @@ use DateTimeImmutable; use DateTimeZone; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Abilities\FollowUp; -use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use WP_Error; @@ -128,33 +127,16 @@ final class PostScheduleAbility implements Ability } // The status change is saved – failures from here on are logged and reported as warnings only. - $warnings = []; - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Planen auf einen Fehler gestoßen; die Änderung ist gespeichert.', - $warnings - ); - + $change = (new PostChangeResult($this->name(), $this->log, $postId))->afterSave($input, 'Planen'); $title = (string) $post->post_title; $summary = sprintf('"%s" geplant für %s', $title, $raw) . ($post->post_status === 'publish' ? ' (war veröffentlicht und ist bis dahin offline)' : ''); - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', - $warnings - ); + $change->log((string) $post->post_type, $summary); + $updated = $change->reread($post) ?? $post; - $updated = get_post($postId); - $updated = $updated instanceof WP_Post ? $updated : $post; - - return ToolResult::ok($summary . '.') + return $change->result($summary . '.', $updated) ->changed((string) $postId, (string) $post->post_type, 'scheduled', $title) - ->rev(Rev::ofPost($updated)) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) - ->warnings($warnings) ->toArray(); } diff --git a/src/Integrations/WordPress/Status/PostTrashAbility.php b/src/Integrations/WordPress/Status/PostTrashAbility.php index 4c91efb..5b0da32 100644 --- a/src/Integrations/WordPress/Status/PostTrashAbility.php +++ b/src/Integrations/WordPress/Status/PostTrashAbility.php @@ -6,10 +6,10 @@ namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use WP_Error; @@ -121,23 +121,13 @@ final class PostTrashAbility implements Ability } // The change is saved – a failure of the change log from here on is a warning only. - $summary = sprintf('"%s" in den Papierkorb gelegt', $title); - $warnings = []; - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', - $warnings - ); + $change = (new PostChangeResult($this->name(), $this->log, $postId)) + ->log((string) $post->post_type, sprintf('"%s" in den Papierkorb gelegt', $title)); - $updated = get_post($postId); - $updated = $updated instanceof WP_Post ? $updated : $post; + $summary = sprintf('"%s" liegt im Papierkorb und ist dort wiederherstellbar.', $title); - return ToolResult::ok(sprintf('"%s" liegt im Papierkorb und ist dort wiederherstellbar.', $title)) + return $change->result($summary, $change->reread($post)) ->changed((string) $postId, (string) $post->post_type, 'trashed', $title) - ->rev(Rev::ofPost($updated)) - ->logId(is_int($logId) ? $logId : null) - ->warnings($warnings) ->toArray(); } diff --git a/src/Integrations/WordPress/Status/PostUnpublishAbility.php b/src/Integrations/WordPress/Status/PostUnpublishAbility.php index d82ffa9..b16141e 100644 --- a/src/Integrations/WordPress/Status/PostUnpublishAbility.php +++ b/src/Integrations/WordPress/Status/PostUnpublishAbility.php @@ -6,14 +6,13 @@ namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; -use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use WP_Error; -use WP_Post; /** * lit/post-unpublish: withdraws a published, scheduled or private entry back to draft. @@ -98,31 +97,14 @@ final class PostUnpublishAbility implements Ability } // The status change is saved – failures from here on are logged and reported as warnings only. - $warnings = []; - FollowUp::run( - fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input), - $this->name() . ' lit_mcp_after_save', - 'Eine Erweiterung ist nach dem Zurückziehen auf einen Fehler gestoßen; die Änderung ist gespeichert.', - $warnings - ); - + $change = (new PostChangeResult($this->name(), $this->log, $postId))->afterSave($input, 'Zurückziehen'); $summary = sprintf('"%s" zurückgezogen (vorher %s, jetzt Entwurf)', $title, $post->post_status); - $logId = FollowUp::run( - fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary), - $this->name() . ' change log', - 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', - $warnings - ); + $change->log((string) $post->post_type, $summary); + $updated = $change->reread($post) ?? $post; - $updated = get_post($postId); - $updated = $updated instanceof WP_Post ? $updated : $post; - - return ToolResult::ok($summary . '.') + return $change->result($summary . '.', $updated) ->changed((string) $postId, (string) $post->post_type, 'unpublished', $title) - ->rev(Rev::ofPost($updated)) - ->logId(is_int($logId) ? $logId : null) ->urls((string) get_preview_post_link($updated), $editUrl) - ->warnings($warnings) ->toArray(); } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php index c104a55..e036260 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -96,7 +96,7 @@ final class ContentCreateAbilityTest extends TestCase Actions\expectDone('lit_mcp_after_save')->once()->with(42, 'lit/content-create', $input); $post = $this->stubResultLinks(); $log = Mockery::mock(ChangeLog::class); - $log->expects('record')->with('lit/content-create', 'lit_person', '42', 'Entwurf "Anna" angelegt')->andReturn(7); + $log->expects('record')->with('lit/content-create', 'lit_person', '42', 'Entwurf "Anna" angelegt', null)->andReturn(7); $result = (new ContentCreateAbility($schema, $writer, $log))->execute($input); diff --git a/tests/Unit/Integrations/WordPress/PostChangeResultTest.php b/tests/Unit/Integrations/WordPress/PostChangeResultTest.php new file mode 100644 index 0000000..f7463e1 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/PostChangeResultTest.php @@ -0,0 +1,91 @@ +errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $this->previousLog = ini_set('error_log', $this->errorLog); + } + + protected function tearDown(): void + { + ini_set('error_log', (string) $this->previousLog); + unlink($this->errorLog); + parent::tearDown(); + } + + public function testRunsTheHookLogsAndBuildsTheResult(): void + { + $post = new WP_Post(['ID' => 5, 'post_title' => 'Team']); + Actions\expectDone('lit_mcp_after_save')->once()->with(5, 'lit/content-update', ['post_id' => 5]); + Functions\when('get_post')->justReturn($post); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/content-update', 'page', '5', 'geändert', 811)->andReturn(9); + + $change = new PostChangeResult('lit/content-update', $log, 5, 'die Änderung', ['vorher']); + $change->afterSave(['post_id' => 5]); + $updated = $change->reread(); + $change->log('page', 'geändert', 811); + $result = $change->result('Fertig.', $updated)->toArray(); + + self::assertSame($post, $updated); + self::assertSame('Fertig.', $result['summary']); + self::assertSame(Rev::ofPost($post), $result['rev']); + self::assertSame(9, $result['log_id']); + self::assertSame(['vorher'], $result['warnings']); + } + + public function testFailuresAfterTheWriteBecomeWarnings(): void + { + Actions\expectDone('lit_mcp_after_save')->once()->whenHappen(static function (): void { + throw new RuntimeException('listener kaputt'); + }); + Functions\when('get_post')->justReturn(null); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andThrow(new RuntimeException('log kaputt')); + $fallback = new WP_Post(['ID' => 5]); + + $change = new PostChangeResult('lit/post-publish', $log, 5); + $change->afterSave([], 'Veröffentlichen'); + $updated = $change->reread($fallback); + $change->log('page', 'x'); + $result = $change->result('ok', $updated)->toArray(); + + self::assertSame($fallback, $updated); + self::assertNull($result['log_id']); + self::assertSame([ + 'Eine Erweiterung ist nach dem Veröffentlichen auf einen Fehler gestoßen; die Änderung ist gespeichert.', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.', + ], $result['warnings']); + self::assertStringContainsString('listener kaputt', (string) file_get_contents($this->errorLog)); + } + + public function testWithoutAReadablePostTheRevIsNull(): void + { + Functions\when('get_post')->justReturn(null); + + $change = new PostChangeResult('lit/content-create', Mockery::mock(ChangeLog::class), 5, 'der Entwurf'); + + self::assertNull($change->reread()); + self::assertNull($change->result('ok', null)->toArray()['rev']); + } +} diff --git a/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php index 1f1542e..7585c8a 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php @@ -46,7 +46,7 @@ final class PostDuplicateAbilityTest extends TestCase $cloner->expects('duplicate')->with($this->original)->andReturn(77); Actions\expectDone('lit_mcp_after_save')->once()->with(77, 'lit/post-duplicate', ['post_id' => 5]); $log = Mockery::mock(ChangeLog::class); - $log->expects('record')->with('lit/post-duplicate', 'page', '77', 'Kopie von "Team" (ID 5) angelegt')->andReturn(8); + $log->expects('record')->with('lit/post-duplicate', 'page', '77', 'Kopie von "Team" (ID 5) angelegt', null)->andReturn(8); $result = $this->ability($cloner, $log)->execute(['post_id' => 5]); diff --git a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php index 28d4efb..ca187ba 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php @@ -36,7 +36,7 @@ final class PostPublishAbilityTest extends TestCase Functions\expect('wp_update_post')->once()->with(['ID' => 5, 'post_status' => 'publish'], true)->andReturn(5); Functions\when('get_post_status')->justReturn('publish'); $log = Mockery::mock(ChangeLog::class); - $log->expects('record')->with('lit/post-publish', 'page', '5', '"Team" veröffentlicht')->andReturn(9); + $log->expects('record')->with('lit/post-publish', 'page', '5', '"Team" veröffentlicht', null)->andReturn(9); $result = $this->ability($log)->execute(['post_id' => 5]); diff --git a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php index c8029d6..ab438b7 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php @@ -38,7 +38,7 @@ final class PostTrashAbilityTest extends TestCase Functions\expect('wp_trash_post')->once()->with(5)->andReturn(new WP_Post(['ID' => 5])); Functions\expect('wp_delete_post')->never(); $log = Mockery::mock(ChangeLog::class); - $log->expects('record')->with('lit/post-trash', 'page', '5', '"Alt" in den Papierkorb gelegt')->andReturn(2); + $log->expects('record')->with('lit/post-trash', 'page', '5', '"Alt" in den Papierkorb gelegt', null)->andReturn(2); $result = $this->ability($log)->execute(['post_id' => 5, 'confirm' => true]); From 563d9e8d158a960446ad6a3744c839fd548e5188 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:23:09 +0200 Subject: [PATCH 050/185] fix: status tools report a hook throwing during the write as partial_write publish/unpublish/schedule/trash run their WordPress call through StatusWrite: a throwing hook becomes WriteFailed (rollback incomplete, with post_id) and is logged with result partial instead of surfacing as internal_error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Status/PostPublishAbility.php | 8 ++- .../WordPress/Status/PostScheduleAbility.php | 11 ++++- .../WordPress/Status/PostTrashAbility.php | 9 +++- .../WordPress/Status/PostUnpublishAbility.php | 8 ++- .../WordPress/Status/StatusWrite.php | 49 +++++++++++++++++++ .../Status/PostPublishAbilityTest.php | 22 +++++++++ .../Status/PostScheduleAbilityTest.php | 21 ++++++++ .../WordPress/Status/PostTrashAbilityTest.php | 21 ++++++++ .../Status/PostUnpublishAbilityTest.php | 21 ++++++++ 9 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/Integrations/WordPress/Status/StatusWrite.php diff --git a/src/Integrations/WordPress/Status/PostPublishAbility.php b/src/Integrations/WordPress/Status/PostPublishAbility.php index 3c31632..84c8124 100644 --- a/src/Integrations/WordPress/Status/PostPublishAbility.php +++ b/src/Integrations/WordPress/Status/PostPublishAbility.php @@ -98,7 +98,13 @@ final class PostPublishAbility implements Ability $data['edit_date'] = true; } - $result = wp_update_post($data, true); + $result = StatusWrite::run( + static fn () => wp_update_post($data, true), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Veröffentlichen einen Fehler gemeldet.' + ); if ($result instanceof WP_Error) { return Errors::invalidInput( 'WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message() diff --git a/src/Integrations/WordPress/Status/PostScheduleAbility.php b/src/Integrations/WordPress/Status/PostScheduleAbility.php index 986a82c..0f1048a 100644 --- a/src/Integrations/WordPress/Status/PostScheduleAbility.php +++ b/src/Integrations/WordPress/Status/PostScheduleAbility.php @@ -108,13 +108,20 @@ final class PostScheduleAbility implements Ability } $postId = (int) $post->ID; - $result = wp_update_post([ + $data = [ 'ID' => $postId, 'post_status' => 'future', 'post_date' => $date->format('Y-m-d H:i:s'), 'post_date_gmt' => $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'), 'edit_date' => true, - ], true); + ]; + $result = StatusWrite::run( + static fn () => wp_update_post($data, true), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Planen einen Fehler gemeldet.' + ); if ($result instanceof WP_Error) { return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message()); } diff --git a/src/Integrations/WordPress/Status/PostTrashAbility.php b/src/Integrations/WordPress/Status/PostTrashAbility.php index 5b0da32..b2aff09 100644 --- a/src/Integrations/WordPress/Status/PostTrashAbility.php +++ b/src/Integrations/WordPress/Status/PostTrashAbility.php @@ -113,7 +113,14 @@ final class PostTrashAbility implements Ability ); } - if (! wp_trash_post($postId)) { + $trashed = StatusWrite::run( + static fn () => wp_trash_post($postId), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Verschieben in den Papierkorb einen Fehler gemeldet.' + ); + if (! $trashed) { return Errors::unsupported( 'WordPress hat das Verschieben in den Papierkorb abgelehnt.', 'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).' diff --git a/src/Integrations/WordPress/Status/PostUnpublishAbility.php b/src/Integrations/WordPress/Status/PostUnpublishAbility.php index b16141e..c18307f 100644 --- a/src/Integrations/WordPress/Status/PostUnpublishAbility.php +++ b/src/Integrations/WordPress/Status/PostUnpublishAbility.php @@ -91,7 +91,13 @@ final class PostUnpublishAbility implements Ability ->toArray(); } - $result = wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true); + $result = StatusWrite::run( + static fn () => wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true), + $this->name(), + $this->log, + $post, + 'WordPress hat beim Zurückziehen einen Fehler gemeldet.' + ); if ($result instanceof WP_Error) { return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message()); } diff --git a/src/Integrations/WordPress/Status/StatusWrite.php b/src/Integrations/WordPress/Status/StatusWrite.php new file mode 100644 index 0000000..4f30e0a --- /dev/null +++ b/src/Integrations/WordPress/Status/StatusWrite.php @@ -0,0 +1,49 @@ +ID; + $log->recordPartial( + $ability, + (string) $post->post_type, + (string) $postId, + sprintf('"%s": Statusänderung abgebrochen, Stand unklar', $post->post_title) + ); + + throw new WriteFailed( + $reason, + false, + $postId, + null, + $e, + '', + sprintf('Status von Eintrag %d ist möglicherweise schon geändert worden.', $postId), + 'Den Aufruf nicht einfach wiederholen. Zuerst mit lit-content-get Status und Inhalt prüfen und dem ' + . 'Nutzer Bescheid geben.' + ); + } + } +} diff --git a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php index ca187ba..f59eeac 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostPublishAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostPublishAbilityTest extends TestCase @@ -74,6 +76,26 @@ final class PostPublishAbilityTest extends TestCase self::assertStringContainsString('bereits veröffentlicht', $result['summary']); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + $this->stubPost(['post_status' => 'draft', 'post_date_gmt' => '0000-00-00 00:00:00']); + Functions\when('wp_update_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-publish', 'page', '5', Mockery::type('string')); + + try { + $this->ability($log)->execute(['post_id' => 5]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testATrashedPostIsRefused(): void { $this->stubPost(['post_status' => 'trash']); diff --git a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php index 470eb11..d17e550 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php @@ -6,11 +6,13 @@ namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; use DateTimeZone; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostScheduleAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostScheduleAbilityTest extends TestCase @@ -52,6 +54,25 @@ final class PostScheduleAbilityTest extends TestCase self::assertStringContainsString('2099-06-01 09:30', $result['summary']); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + Functions\when('wp_update_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-schedule', 'post', '5', Mockery::type('string')); + + try { + $this->ability($log)->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testATrashedPostIsRefused(): void { Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'trash'])); diff --git a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php index ab438b7..4aefab5 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostTrashAbilityTest extends TestCase @@ -60,6 +62,25 @@ final class PostTrashAbilityTest extends TestCase self::assertSame('lit_unsupported', $result->get_error_code()); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + Functions\when('wp_trash_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-trash', 'page', '5', Mockery::type('string')); + + try { + $this->ability($log)->execute(['post_id' => 5, 'confirm' => true]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testAnAlreadyTrashedPostIsANoOp(): void { $ability = $this->ability(Mockery::mock(ChangeLog::class)); diff --git a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php index 9a0954a..7a23f68 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Status; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; use WP_Post; final class PostUnpublishAbilityTest extends TestCase @@ -37,6 +39,25 @@ final class PostUnpublishAbilityTest extends TestCase self::assertSame('unpublished', $result['changed'][0]['action']); } + public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void + { + Functions\when('wp_update_post')->alias(static function (): void { + throw new RuntimeException('save_post-Hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/post-unpublish', 'page', '5', Mockery::type('string')); + + try { + $this->ability($log, 'publish')->execute(['post_id' => 5]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(5, $e->postId); + self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + public function testATrashedPostIsRefusedInsteadOfBecomingADraft(): void { Functions\expect('wp_update_post')->never(); From 106a0994833b154d1c1bb135d1416f2a7ddd9cce Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:24:20 +0200 Subject: [PATCH 051/185] fix: check WordPress meta capabilities per field key Fields from the meta/sample sources need edit_post_meta (update, checked in PostWriter::prepare) or add_post_meta (create, checked right after the insert; a refusal deletes the new draft again) for their key; a refusal is a fields. validation error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Content/ContentCreateAbility.php | 36 ++++++++++++++++ .../WordPress/Content/PostWriter.php | 38 ++++++++++++++++ .../Content/ContentCreateAbilityTest.php | 43 +++++++++++++++++++ .../WordPress/Content/PostWriterTest.php | 43 +++++++++++++++++++ 4 files changed, 160 insertions(+) diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index e4a14e7..8bb622c 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -118,6 +118,11 @@ final class ContentCreateAbility implements Ability } $postId = (int) $postId; + $denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta'); + if ($denied !== []) { + return $this->refuse($postId, $postType, $denied); + } + try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { @@ -187,6 +192,37 @@ final class ContentCreateAbility implements Ability ); } + /** + * Meta rights can only be checked once the draft exists (add_post_meta needs its ID): a + * refused field removes the brand-new draft again and is reported like any invalid input. + * + * @param array $denied fields. => error + * @throws WriteFailed When the draft cannot be deleted. + */ + private function refuse(int $postId, string $postType, array $denied): WP_Error + { + try { + $deleted = (bool) wp_delete_post($postId, true); + } catch (Throwable $e) { + $deleted = false; + } + + if (! $deleted) { + $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); + $this->logPartial($postType, (string) $postId, $summary); + throw new WriteFailed( + 'Keine Berechtigung für ' . implode(', ', array_keys($denied)) . '.', + false, + $postId, + null, + null, + sprintf('Meta rights denied for new post %d; draft could not be deleted', $postId) + ); + } + + return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); + } + private function logPartial(string $postType, string $postId, string $summary): void { $this->log->recordPartial($this->name(), $postType, $postId, $summary); diff --git a/src/Integrations/WordPress/Content/PostWriter.php b/src/Integrations/WordPress/Content/PostWriter.php index 0a401dd..a2d1161 100644 --- a/src/Integrations/WordPress/Content/PostWriter.php +++ b/src/Integrations/WordPress/Content/PostWriter.php @@ -33,6 +33,11 @@ class PostWriter 'template' => 'page_template', ]; + /** Field sources that store plain post meta, guarded per key by WordPress' meta capabilities. */ + private const META_SOURCES = ['meta', 'sample']; + + private const META_DENIED = 'Keine Berechtigung, dieses Feld zu ändern.'; + /** wp_insert_post() ignores an empty page_template; this value selects the default template. */ public const DEFAULT_TEMPLATE = 'default'; @@ -91,6 +96,12 @@ class PostWriter $errors['fields.' . $key] = $message; } + // On create the entry does not exist yet; lit/content-create checks add_post_meta after the insert. + $postId = (int) ($input['post_id'] ?? 0); + if (! $isCreate && $postId > 0) { + $errors = array_merge($errors, $this->deniedMeta($postId, $postType, $fields['values'], 'edit_post_meta')); + } + $terms = $this->terms->resolve($postType, is_array($input['terms'] ?? null) ? $input['terms'] : []); foreach ($terms['errors'] as $taxonomy => $message) { $errors['terms.' . $taxonomy] = $message; @@ -110,6 +121,33 @@ class PostWriter return ['fields' => $fields['values'], 'terms' => $terms['terms'], 'warnings' => $fields['warnings']]; } + /** + * Meta fields (sources meta/sample) the current user may not write: WordPress decides per + * key (protected keys, register_meta auth callbacks) via edit_post_meta / add_post_meta. + * + * @param array $values Validated field values. + * @param string $cap edit_post_meta (update) or add_post_meta (create). + * @return array fields. => error + */ + public function deniedMeta(int $postId, string $postType, array $values, string $cap): array + { + $definitions = $this->schema->fields($postType); + $denied = []; + foreach (array_keys($values) as $key) { + $key = (string) $key; + $definition = $definitions[$key] ?? null; + if ( + $definition !== null + && in_array($definition->source, self::META_SOURCES, true) + && ! current_user_can($cap, $postId, $key) + ) { + $denied['fields.' . $key] = self::META_DENIED; + } + } + + return $denied; + } + /** * @param array $input * @return array diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php index e036260..f43a175 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -39,6 +39,7 @@ final class ContentCreateAbilityTest extends TestCase $writer = Mockery::mock(PostWriter::class); $writer->allows('prepare')->andReturn(['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => $warnings]); $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->allows('deniedMeta')->andReturn([]); Functions\when('get_current_user_id')->justReturn(3); return $writer; @@ -85,6 +86,7 @@ final class ContentCreateAbilityTest extends TestCase $writer = Mockery::mock(PostWriter::class); $writer->allows('prepare')->with('lit_person', $input, true)->andReturn($prepared); $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->expects('deniedMeta')->with(42, 'lit_person', ['role' => 'Leitung'], 'add_post_meta')->andReturn([]); $writer->expects('writeExtras')->with(42, 'lit_person', $prepared, $input); Functions\when('get_current_user_id')->justReturn(3); Functions\expect('wp_insert_post')->once()->with([ @@ -126,6 +128,47 @@ final class ContentCreateAbilityTest extends TestCase self::assertSame('lit_write_failed', $failure->toError()->get_error_code()); } + public function testMetaFieldsTheUserMayNotAddAreRefusedAndTheDraftIsRemoved(): void + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->expects('deniedMeta')->with(42, 'lit_person', ['city' => 'Trier'], 'add_post_meta') + ->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']); + $writer->expects('writeExtras')->never(); + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->never(); + + $result = (new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('fields.city', $result->get_error_data()['fields']); + } + + public function testARefusedDraftThatCannotBeDeletedIsAPartialWrite(): void + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->allows('deniedMeta')->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']); + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('wp_insert_post')->justReturn(42); + Functions\when('wp_delete_post')->justReturn(false); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial'); + + try { + (new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(42, $e->postId); + } + } + public function testADraftThatCannotBeDeletedIsAPartialWriteAndLogged(): void { $writer = $this->writer(); diff --git a/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php index 2ef9e04..8bd7146 100644 --- a/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php +++ b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php @@ -131,6 +131,49 @@ final class PostWriterTest extends TestCase self::assertCount(1, $result['warnings']); } + public function testMetaFieldsNeedTheRightToEditThatMetaKeyOnUpdate(): void + { + Functions\when('get_object_taxonomies')->justReturn([]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn([ + 'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'), + 'secret' => new FieldDefinition('secret', 'secret', 'string', 'meta', 'writable'), + 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), + ]); + $checked = []; + Functions\when('current_user_can')->alias(static function (string $cap, ...$args) use (&$checked): bool { + $checked[] = array_merge([$cap], $args); + return $args !== [5, 'secret']; + }); + + $result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->prepare( + 'page', + ['post_id' => 5, 'fields' => ['city' => 'Trier', 'secret' => 'x', 'role' => 'Leitung']], + false + ); + + self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], $result->get_error_data()['fields']); + self::assertSame([['edit_post_meta', 5, 'city'], ['edit_post_meta', 5, 'secret']], $checked); + } + + public function testCreateChecksMetaRightsOnlyOnceTheDraftExists(): void + { + Functions\when('get_object_taxonomies')->justReturn([]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn([ + 'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'), + 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), + ]); + Functions\expect('current_user_can')->once()->with('add_post_meta', 42, 'city')->andReturn(false); + $writer = new PostWriter($schema, new FieldValidator(), new TermAssigner()); + + self::assertIsArray($writer->prepare('page', ['fields' => ['city' => 'Trier', 'role' => 'x']], true)); + self::assertSame( + ['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.'], + $writer->deniedMeta(42, 'page', ['city' => 'Trier', 'role' => 'x'], 'add_post_meta') + ); + } + public function testPostDataMapsOnlyGivenKeys(): void { $writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner()); From d67a1d4fb37978075e9b64e2a9749afaa484c2f6 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:24:51 +0200 Subject: [PATCH 052/185] fix: relation fields require a readable target and hide its post type Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Schema/FieldValidator.php | 17 +++++++++------ .../WordPress/Schema/FieldValidatorTest.php | 21 ++++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/Integrations/WordPress/Schema/FieldValidator.php b/src/Integrations/WordPress/Schema/FieldValidator.php index 2120379..8bcbbcb 100644 --- a/src/Integrations/WordPress/Schema/FieldValidator.php +++ b/src/Integrations/WordPress/Schema/FieldValidator.php @@ -137,28 +137,33 @@ final class FieldValidator : sprintf('Erlaubte Werte: %s.', implode(', ', $allowed)); } + /** + * The target must exist and be readable for the current user; an unreadable target gets + * the same answer as a missing one and its actual post type is never named (no probing). + */ private function checkRelation(FieldDefinition $field, mixed $value): ?string { if (! is_int($value) || $value <= 0) { return 'ID (positive Ganzzahl) erwartet.'; } + $actual = get_post_type($value); + $readable = $actual !== false && current_user_can('read_post', $value); $objectType = $field->related['object_type'] ?? ''; if ($objectType === 'media') { - return get_post_type($value) === 'attachment' + return $readable && $actual === 'attachment' ? null - : sprintf('%d ist kein Medium der Mediathek.', $value); + : sprintf('%d ist kein für dich lesbares Medium der Mediathek.', $value); } if ($objectType === 'post_type') { - $actual = get_post_type($value); - if ($actual === false) { - return sprintf('Eintrag %d existiert nicht.', $value); + if (! $readable) { + return sprintf('Eintrag %d existiert nicht oder ist für dich nicht lesbar.', $value); } $expected = $field->related['name'] ?? null; if ($expected !== null && $actual !== $expected) { - return sprintf('Eintrag %d ist vom Typ %s, erwartet %s.', $value, $actual, $expected); + return sprintf('Eintrag %d ist kein Eintrag vom Typ %s.', $value, $expected); } } diff --git a/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php b/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php index 661489e..4887f32 100644 --- a/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php +++ b/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php @@ -38,6 +38,7 @@ final class FieldValidatorTest extends TestCase public function testValidValuesPassUnchanged(): void { Functions\when('get_post_type')->alias(static fn (int $id) => [12 => 'lit_person', 3 => 'attachment'][$id] ?? false); + Functions\when('current_user_can')->justReturn(true); $values = ['role' => 'Leitung', 'level' => 'senior', 'mentor' => 12, 'photos' => [3], 'start' => '2026-10-01']; $result = (new FieldValidator())->validate($this->definitions(), $values, true); @@ -61,6 +62,7 @@ final class FieldValidatorTest extends TestCase public function testUnknownReadonlyAndInvalidValuesAreReported(): void { Functions\when('get_post_type')->alias(static fn (int $id) => [7 => 'page', 3 => 'attachment'][$id] ?? false); + Functions\when('current_user_can')->justReturn(true); $errors = (new FieldValidator())->validate($this->definitions(), [ 'role' => 'x', @@ -75,7 +77,7 @@ final class FieldValidatorTest extends TestCase self::assertStringContainsString('lit-content-types', $errors['nope']); self::assertSame('Feld ist schreibgeschützt.', $errors['secret']); self::assertSame('Erlaubte Werte: junior, senior.', $errors['level']); - self::assertSame('Eintrag 7 ist vom Typ page, erwartet lit_person.', $errors['mentor']); + self::assertSame('Eintrag 7 ist kein Eintrag vom Typ lit_person.', $errors['mentor']); self::assertSame('Liste erwartet.', $errors['photos']); self::assertSame('Datum im Format JJJJ-MM-TT erwartet.', $errors['start']); self::assertArrayNotHasKey('role', $errors); @@ -84,11 +86,24 @@ final class FieldValidatorTest extends TestCase public function testMissingRelatedPostAndNonMediaAreRejected(): void { Functions\when('get_post_type')->alias(static fn (int $id) => [5 => 'page'][$id] ?? false); + Functions\when('current_user_can')->justReturn(true); $errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 99, 'photos' => [5]], false)['errors']; - self::assertSame('Eintrag 99 existiert nicht.', $errors['mentor']); - self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']); + self::assertSame('Eintrag 99 existiert nicht oder ist für dich nicht lesbar.', $errors['mentor']); + self::assertSame('5 ist kein für dich lesbares Medium der Mediathek.', $errors['photos']); + } + + public function testUnreadableTargetsLookLikeMissingOnesAndRevealNoType(): void + { + Functions\when('get_post_type')->alias(static fn (int $id) => [8 => 'lit_secret', 4 => 'attachment'][$id] ?? false); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && ! in_array($id, [8, 4], true)); + + $errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 8, 'photos' => [4]], false)['errors']; + + self::assertSame('Eintrag 8 existiert nicht oder ist für dich nicht lesbar.', $errors['mentor']); + self::assertStringNotContainsString('lit_secret', $errors['mentor']); + self::assertSame('4 ist kein für dich lesbares Medium der Mediathek.', $errors['photos']); } public function testPasswordFieldsAreValidatedAsStrings(): void From ff0da9f40638113b634d44cf6f7da7aef29c2492 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:26:11 +0200 Subject: [PATCH 053/185] fix: settings compare against the sanitized value, fail via WriteFailed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isStored() compares the stored option with sanitize_option() of the new value (core esc_html()s blogname/blogdescription): setting "Müller & Söhne" again no longer looks like a failed write; lit/wp-settings-get and previous return those two decoded, as the user would type them. - The change log runs as FollowUp (warning on failure); a failed write throws WriteFailed (write_failed / partial_write with keys), partial writes are logged with result partial. - permalink_structure needs a real %tag% (WordPress' own /%[^/%]+%/ rule). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Settings/SettingsWhitelist.php | 11 +- .../Settings/WpSettingsUpdateAbility.php | 73 ++++++---- .../Settings/SettingsWhitelistTest.php | 3 + .../Settings/WpSettingsAbilitiesTest.php | 131 +++++++++++++++--- 4 files changed, 170 insertions(+), 48 deletions(-) diff --git a/src/Integrations/WordPress/Settings/SettingsWhitelist.php b/src/Integrations/WordPress/Settings/SettingsWhitelist.php index 1a239b8..b806108 100644 --- a/src/Integrations/WordPress/Settings/SettingsWhitelist.php +++ b/src/Integrations/WordPress/Settings/SettingsWhitelist.php @@ -39,9 +39,17 @@ final class SettingsWhitelist private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon']; + /** Stored esc_html()'d by sanitize_option(); read back as the user would type them. */ + private const HTML_ESCAPED_KEYS = ['blogname', 'blogdescription']; + + /** The value as a client should see and send it (not WordPress' storage form). */ public function current(string $key): mixed { $value = get_option($key); + if (in_array($key, self::HTML_ESCAPED_KEYS, true)) { + return wp_specialchars_decode((string) $value, ENT_QUOTES); + } + if ($key === 'WPLANG') { return $value === '' || $value === false ? 'en_US' : (string) $value; } @@ -201,10 +209,11 @@ final class SettingsWhitelist return null; } + // WordPress' own rule for a structure tag (wp-admin/options-permalink.php): /%[^\/%]+%/. if ( is_string($value) && str_starts_with($value, '/') - && str_contains($value, '%') + && preg_match('/%[^\/%]+%/', $value) === 1 && mb_strlen($value) <= 200 && preg_match('/\s/', $value) !== 1 ) { diff --git a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php index 26451c4..3f998c3 100644 --- a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php +++ b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php @@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\WordPress\Settings; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Abilities\ToolResult; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use RuntimeException; use Throwable; @@ -126,8 +128,10 @@ final class WpSettingsUpdateAbility implements Ability * previous values so the call stays all-or-nothing. * * @param array $settings + * @return array + * @throws WriteFailed When a write fails (after restoring what it could). */ - private function write(array $settings): array|WP_Error + private function write(array $settings): array { $previous = []; foreach ($settings as $key => $value) { @@ -146,7 +150,7 @@ final class WpSettingsUpdateAbility implements Ability $changed[] = $key; } } catch (Throwable $e) { - return $this->fail($changed, $previous, $e); + $this->fail($changed, $previous, $e); } if ($changed === []) { @@ -155,15 +159,26 @@ final class WpSettingsUpdateAbility implements Ability ->toArray(); } + // The settings are saved – a failure of the change log from here on is a warning only. $summary = 'Einstellungen geändert: ' . implode(', ', $changed); - $logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary); + $warnings = []; + $logId = FollowUp::run( + fn () => $this->log->record($this->name(), 'option', implode(',', $changed), $summary), + $this->name() . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; die Einstellungen sind gespeichert.', + $warnings + ); $result = ToolResult::ok($summary . '.'); foreach ($changed as $key) { $result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]); } - return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray(); + return $result + ->logId(is_int($logId) ? $logId : null) + ->warnings($warnings) + ->with('previous', JsonMap::of($previous)) + ->toArray(); } private function writeOne(string $key, mixed $value): void @@ -185,26 +200,32 @@ final class WpSettingsUpdateAbility implements Ability } } - /** Whether option $key already holds $normalized, compared like WordPress compares scalars. */ + /** + * Whether option $key already holds $normalized in the form WordPress stores it: + * update_option() runs sanitize_option() first (blogname/blogdescription are esc_html()'d), + * so the stored value is compared with the sanitized one, like WordPress compares scalars. + */ private function isStored(string $key, mixed $normalized): bool { $stored = get_option($key); - if (is_scalar($stored) && is_scalar($normalized)) { - return (string) $stored === (string) $normalized; + $expected = sanitize_option($key, $normalized); + if (is_scalar($stored) && is_scalar($expected)) { + return (string) $stored === (string) $expected; } - return $stored == $normalized; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return $stored == $expected; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual } /** - * A write failed; restores the keys already written to their previous values and - * reports write_failed (fully restored) or partial_write (some keys could not be - * restored – the client is told which, so it can check and correct them). + * A write failed; restores the keys already written to their previous values and throws + * WriteFailed: write_failed (fully restored) or partial_write (some keys could not be + * restored – the client is told which, so it can check and correct them; logged as partial). * * @param list $changed Keys written so far, in write order. * @param array $previous Their values before this call. + * @throws WriteFailed Always. */ - private function fail(array $changed, array $previous, Throwable $cause): WP_Error + private function fail(array $changed, array $previous, Throwable $cause): never { $stuck = []; foreach (array_reverse($changed) as $key) { @@ -215,23 +236,27 @@ final class WpSettingsUpdateAbility implements Ability } } - error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log - sprintf('[wp-lit-mcp] wp-settings-update failed: %s', $cause) - ); - + $reason = 'Eine Einstellung konnte nicht gespeichert werden.'; if ($stuck === []) { - return Errors::writeFailed('Eine Einstellung konnte nicht gespeichert werden.'); + throw new WriteFailed($reason, true, null, null, $cause); } - return Errors::partialWrite( - 'Eine Einstellung konnte nicht gespeichert werden.', + $this->log->recordPartial( + $this->name(), + 'option', + implode(',', $stuck), + 'Einstellungen nur teilweise zurückgenommen: ' . implode(', ', $stuck) + ); + throw new WriteFailed( + $reason, + false, null, null, - sprintf( - 'Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', - implode(', ', $stuck) - ), - 'Aktuelle Werte über lit-wp-settings-get prüfen und ggf. erneut auf die gewünschten Werte setzen.' + $cause, + $cause->getMessage() . '; not restored: ' . implode(', ', $stuck), + sprintf('Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', implode(', ', $stuck)), + 'Aktuelle Werte über lit/wp-settings-get prüfen und ggf. erneut auf die gewünschten Werte setzen.', + ['keys' => $stuck] ); } diff --git a/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php index e1f55f2..c498952 100644 --- a/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php +++ b/tests/Unit/Integrations/WordPress/Settings/SettingsWhitelistTest.php @@ -64,6 +64,9 @@ final class SettingsWhitelistTest extends TestCase self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', [])); self::assertNull($whitelist->validate('permalink_structure', '', [])); self::assertNotNull($whitelist->validate('permalink_structure', 'postname', [])); + self::assertNull($whitelist->validate('permalink_structure', '/blog/%year%/%postname%/', [])); + self::assertNotNull($whitelist->validate('permalink_structure', '/%%/', [])); + self::assertNotNull($whitelist->validate('permalink_structure', '/%/', [])); self::assertNotNull($whitelist->validate('posts_per_page', 0, [])); self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', [])); self::assertNull($whitelist->validate('site_icon', 33, [])); diff --git a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php index f250978..f55ca31 100644 --- a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php +++ b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php @@ -5,15 +5,99 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings; use Brain\Monkey\Functions; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist; use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility; use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use RuntimeException; +use WP_Error; final class WpSettingsAbilitiesTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + // Like core: blogname/blogdescription are stored esc_html()'d, the rest as given here. + Functions\when('sanitize_option')->alias(static fn (string $key, $value) => in_array($key, ['blogname', 'blogdescription'], true) + ? htmlspecialchars((string) $value, ENT_QUOTES) + : $value); + Functions\when('wp_specialchars_decode')->alias(static fn (string $text, $quotes = ENT_NOQUOTES): string => htmlspecialchars_decode($text, ENT_QUOTES)); + } + + /** + * A stateful option store: update_option() sanitizes like core and returns false when + * nothing changes. + * + * @param array $options + */ + private function optionStore(array &$options): void + { + Functions\when('get_option')->alias(static function (string $key) use (&$options) { + return $options[$key]; + }); + Functions\when('update_option')->alias(static function (string $key, $value) use (&$options): bool { + $value = in_array($key, ['blogname', 'blogdescription'], true) ? htmlspecialchars((string) $value, ENT_QUOTES) : $value; + if ($options[$key] === $value) { + return false; + } + $options[$key] = $value; + + return true; + }); + } + + public function testTextWithAnAmpersandCanBeSetTwiceAlongsideAnotherKey(): void + { + $options = ['blogname' => 'Alt', 'blogdescription' => 'Praxis']; + $this->optionStore($options); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(40); + $ability = new WpSettingsUpdateAbility(new SettingsWhitelist(), $log); + $input = ['settings' => ['blogname' => 'Müller & Söhne', 'blogdescription' => 'Zahnärzte']]; + + $first = $ability->execute($input); + $second = $ability->execute($input); + + self::assertSame('Müller & Söhne', $options['blogname']); + self::assertSame(['blogname', 'blogdescription'], array_column($first['changed'], 'id')); + self::assertTrue($second['ok']); + self::assertSame([], $second['changed']); + self::assertSame(['blogname' => 'Müller & Söhne', 'blogdescription' => 'Zahnärzte'], $second['previous']); + } + + public function testGetReturnsTextAsTheUserWouldTypeIt(): void + { + Functions\when('get_option')->alias(static fn (string $key) => $key === 'blogname' ? 'Müller & Söhne "A"' : '1'); + + $settings = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'general'])['settings']; + + self::assertSame('Müller & Söhne "A"', $settings[0]['value']); + } + + public function testAFailingChangeLogIsOnlyAWarning(): void + { + $options = ['blogname' => 'Alt']; + $this->optionStore($options); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andThrow(new RuntimeException('log kaputt')); + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + + try { + $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => ['blogname' => 'Neu']]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertTrue($result['ok']); + self::assertNull($result['log_id']); + self::assertCount(1, $result['warnings']); + } + protected function tearDown(): void { unset($GLOBALS['wp_rewrite']); @@ -129,20 +213,14 @@ final class WpSettingsAbilitiesTest extends TestCase Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(true); $log = Mockery::mock(ChangeLog::class); $log->shouldNotReceive('record'); - $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); - $previous = ini_set('error_log', $file); + $log->shouldNotReceive('recordPartial'); - try { - $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ - 'blogname' => 'Neu', - 'blogdescription' => 'Auch neu', - ]]); - } finally { - ini_set('error_log', (string) $previous); - unlink($file); - } + $error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [ + 'blogname' => 'Neu', + 'blogdescription' => 'Auch neu', + ]); - self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertSame('lit_write_failed', $error->get_error_code()); } public function testReportsPartialWriteWhenRestoringAlsoFails(): void @@ -166,21 +244,28 @@ final class WpSettingsAbilitiesTest extends TestCase Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(false); $log = Mockery::mock(ChangeLog::class); $log->shouldNotReceive('record'); - $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); - $previous = ini_set('error_log', $file); + $log->expects('recordPartial')->with('lit/wp-settings-update', 'option', 'blogname', Mockery::type('string')); + $error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [ + 'blogname' => 'Neu', + 'blogdescription' => 'Auch neu', + ]); + + self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertStringContainsString('blogname', $error->get_error_message()); + self::assertStringContainsString('lit/wp-settings-get', $error->get_error_data()['hint']); + } + + /** @param array $settings */ + private function failure(WpSettingsUpdateAbility $ability, array $settings): WP_Error + { try { - $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [ - 'blogname' => 'Neu', - 'blogdescription' => 'Auch neu', - ]]); - } finally { - ini_set('error_log', (string) $previous); - unlink($file); + $ability->execute(['settings' => $settings]); + } catch (WriteFailed $e) { + return $e->toError(); } - self::assertSame('lit_partial_write', $result->get_error_code()); - self::assertStringContainsString('blogname', $result->get_error_message()); + self::fail('WriteFailed expected'); } public function testSettingsToolsNeedManageOptions(): void From b3c9109e4797cc5d72458cc13df970be84b141dd Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:26:40 +0200 Subject: [PATCH 054/185] fix: log partial menu writes; describe that the link gate covers new items only Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../WordPress/Menus/MenuSetItemsAbility.php | 18 ++++++++++++++++-- .../Menus/MenuSetItemsAbilityTest.php | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php index a78706d..6d11f12 100644 --- a/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php +++ b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php @@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\ToolResult; +use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use WP_Error; @@ -50,7 +51,8 @@ final class MenuSetItemsAbility implements Ability . '#anker, http(s), mailto, tel) und title, jeweils optional children. Verlinkt werden nur für dich ' . 'lesbare, veröffentlichte Inhalte und Begriffe öffentlicher Taxonomien; private Inhalte nie. ' . 'Entwürfe/ausstehende/geplante Inhalte nur mit allow_unpublished: true – WordPress zeigt sie sofort ' - . 'im Menü, Besucher erhalten bis zur Veröffentlichung einen toten Link. Das ' + . 'im Menü, Besucher erhalten bis zur Veröffentlichung einen toten Link. Diese Prüfung gilt nur für ' + . 'neu verlinkte Einträge; bestehende Einträge bleiben unabhängig vom Status ihres Ziels erhalten. Das ' . 'Ziel eines bestehenden Eintrags ändern: entfernen und neu anlegen. Alles wird vorab geprüft, dann ' . 'ganz oder gar nicht geschrieben. confirm: true ist nur nötig, wenn dabei Einträge wegfallen – ' . 'Menüeinträge haben keinen Papierkorb und werden endgültig gelöscht. Die Antwort enthält ' @@ -166,7 +168,19 @@ final class MenuSetItemsAbility implements Ability } // Throws WriteFailed (after its own rollback) – the registrar turns it into the error. - $stats = $this->writer->apply($menuId, $tree, $current); + try { + $stats = $this->writer->apply($menuId, $tree, $current); + } catch (WriteFailed $e) { + if (! $e->rolledBack) { + $this->log->recordPartial( + $this->name(), + 'nav_menu', + (string) $menuId, + sprintf('Menü "%s": Änderung nur teilweise zurückgenommen', $menu->name) + ); + } + throw $e; + } // The menu is saved – from here on failures are logged and reported as warnings only. $warnings = $plan['warnings']; diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php index c5a9b78..9888fc0 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php @@ -204,7 +204,22 @@ final class MenuSetItemsAbilityTest extends TestCase $writer = Mockery::mock(MenuWriter::class); $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); $writer->expects('apply')->andThrow(new WriteFailed('Menü nicht gespeichert.', true)); - $ability = new MenuSetItemsAbility($writer, new MenuOperations(), Mockery::mock(ChangeLog::class)); + $log = Mockery::mock(ChangeLog::class); + $log->shouldNotReceive('recordPartial'); + $ability = new MenuSetItemsAbility($writer, new MenuOperations(), $log); + + $this->expectException(WriteFailed::class); + $ability->execute(['menu_id' => 3, 'items' => [['id' => 10]]]); + } + + public function testAPartialWriteIsLoggedAsPartial(): void + { + $writer = Mockery::mock(MenuWriter::class); + $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); + $writer->expects('apply')->andThrow(new WriteFailed('Menü nicht gespeichert.', false)); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/menu-set-items', 'nav_menu', '3', Mockery::type('string')); + $ability = new MenuSetItemsAbility($writer, new MenuOperations(), $log); $this->expectException(WriteFailed::class); $ability->execute(['menu_id' => 3, 'items' => [['id' => 10]]]); @@ -249,5 +264,6 @@ final class MenuSetItemsAbilityTest extends TestCase self::assertStringContainsString('confirm: true', $ability->description()); self::assertStringContainsString('nur nötig', $ability->description()); self::assertStringContainsString('CSS-Klassen', $ability->description()); + self::assertStringContainsString('nur für neu verlinkte Einträge', $ability->description()); } } From fea75b03d6b8dd9a9d38294076a85f084242df76 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:28:18 +0200 Subject: [PATCH 055/185] docs: name tools and error codes the way the client sees them Indirect abilities are written lit/ (via mcp-adapter-execute-ability) in guides, descriptions and hints, direct tools lit-; guides use the client-visible codes (write_failed, partial_write) and say exactly which tools return a revision_id. ToolNamingTest keeps guides and sources consistent with Ability::isDirect() and checks the overview's direct-tool list. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/content-types.md | 40 ++++--- docs/guides/overview.md | 9 +- docs/guides/workflows.md | 8 +- src/Abilities/Errors.php | 5 +- .../Menus/MenuAssignLocationAbility.php | 4 +- .../WordPress/Menus/MenuCreateAbility.php | 6 +- .../WordPress/Menus/MenuGetAbility.php | 4 +- .../WordPress/Menus/MenuSetItemsAbility.php | 8 +- .../WordPress/Menus/MenuWriter.php | 7 +- .../Revisions/RevisionRestoreAbility.php | 2 +- .../Revisions/RevisionsListAbility.php | 2 +- .../Settings/WpSettingsGetAbility.php | 2 +- .../Settings/WpSettingsUpdateAbility.php | 7 +- .../WordPress/Status/PostDuplicateAbility.php | 2 +- .../Status/PostReplaceWithCopyAbility.php | 14 +-- tests/Unit/Abilities/ErrorsTest.php | 3 +- tests/Unit/Abilities/WriteFailedTest.php | 4 +- tests/Unit/Guides/ToolNamingTest.php | 111 ++++++++++++++++++ .../WordPress/Menus/MenuWriterTest.php | 3 +- 19 files changed, 186 insertions(+), 55 deletions(-) create mode 100644 tests/Unit/Guides/ToolNamingTest.php diff --git a/docs/guides/content-types.md b/docs/guides/content-types.md index da6fa91..4c38efa 100644 --- a/docs/guides/content-types.md +++ b/docs/guides/content-types.md @@ -1,5 +1,8 @@ # Inhalte, Felder und Status +Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities, die über +`mcp-adapter-execute-ability` (Name in `ability_name`, Eingaben in `parameters`) aufgerufen werden. + ## Inhaltstypen erkennen - `lit-content-types` ohne Parameter listet alle Inhaltstypen (Seiten, Beiträge, eigene Typen) mit @@ -32,20 +35,27 @@ - Begriffe: `terms: {"category": [4, "Neuer Begriff"]}` ersetzt die Zuordnung dieser Taxonomie (IDs oder Namen; unbekannte Namen werden angelegt, falls erlaubt). - Felder leeren: Wert `null`. Pflichtfelder können nicht geleert werden. -- Beziehungsfelder erwarten Eintrags-IDs, Datei-/Bildfelder Anhang-IDs aus der Mediathek. +- Beziehungsfelder erwarten IDs von Einträgen, die der Nutzer lesen darf; Datei-/Bildfelder Anhang-IDs aus der + Mediathek. +- Einträge im Papierkorb werden nicht geändert (auch nicht veröffentlicht, geplant oder wiederhergestellt); + zuerst in WordPress aus dem Papierkorb holen. +- Ist ein geplanter Eintrag über seinen Veröffentlichungszeitpunkt hinaus (verpasster Termin), würde jedes + Speichern ihn sofort veröffentlichen – `lit-content-update`, `lit/revision-restore` und + `lit/post-replace-with-copy` verweigern das dann. Mit dem Nutzer klären: jetzt veröffentlichen + (`lit-post-publish`) oder neu planen (`lit/post-schedule`). ## Status -- Veröffentlichen, Zurückziehen und Planen sind eigene Werkzeuge (`lit-post-publish`, `lit-post-unpublish`, - `lit-post-schedule` mit `date` = „JJJJ-MM-TT HH:MM“ in der Zeitzone der Website). Nur auf ausdrücklichen +- Veröffentlichen, Zurückziehen und Planen sind eigene Werkzeuge (`lit-post-publish`, `lit/post-unpublish`, + `lit/post-schedule` mit `date` = „JJJJ-MM-TT HH:MM“ in der Zeitzone der Website). Nur auf ausdrücklichen Wunsch des Nutzers verwenden. -- `lit-post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage. +- `lit/post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage. ## Sicher überarbeiten -1. `lit-post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. +1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. 2. Kopie mit `lit-content-update` (oder den Divi-Tools) bearbeiten und dem Nutzer die Vorschau zeigen. -3. `lit-post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). +3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). Voraussetzungen: die Kopie muss noch Entwurf oder ausstehend sein, `rev` des Originals aus `lit-content-get` ist Pflicht (sie deckt Titel, Inhalt, Auszug und Status ab – Felder/Meta, die direkt am Original geändert wurden, werden dabei überschrieben), und erst nach Rückfrage mit `confirm: true` @@ -53,14 +63,16 @@ Papierkorb (`backup_id` in der Antwort); danach wandert die Kopie ebenfalls in den Papierkorb. Ist der Papierkorb der Website deaktiviert, wird der Austausch verweigert, statt die Kopie endgültig zu löschen. -Jede Änderung liefert `revision_id` (Stand vorher) und `log_id`. Rückgängig: `lit-revisions-list` → -`lit-revision-restore` mit der `rev` des Eintrags (aus `lit-content-get`) und `confirm: true`. Revisionen -umfassen Titel, Inhalt und Auszug – Felder und Begriffe nicht. +Jede Änderung liefert `log_id`. Eine `revision_id` (Revision mit dem Stand vorher) liefern nur +`lit-content-update`, `lit/post-replace-with-copy` und `lit/revision-restore`; bei allen anderen Tools ist sie +`null`. Rückgängig: `lit/revisions-list` → `lit/revision-restore` mit der `rev` des Eintrags (aus +`lit-content-get`) und `confirm: true`. Revisionen umfassen Titel, Inhalt und Auszug – Felder und Begriffe +nicht (dafür bei `lit/post-replace-with-copy` die Sicherung `backup_id`). ## Wenn ein Schreibvorgang fehlschlägt -Schlägt ein Schreibvorgang nach ersten Änderungen fehl, meldet das Tool `lit_write_failed` (alles wurde -zurückgenommen, nichts wurde gespeichert) oder `lit_partial_write` (die Rücknahme war unvollständig). Beide -Fehler stecken als JSON (`code`, `message`, `hint`, ggf. weitere Felder) in der Fehlermeldung. Bei -`lit_partial_write` vor einem erneuten Versuch immer erst den aktuellen Stand prüfen (`lit-content-get`, -`lit-menu-get` o. Ä.) statt den Aufruf einfach zu wiederholen – dem `hint` folgen. +Fehler stecken als JSON (`code`, `message`, `hint`, ggf. weitere Felder) in der Fehlermeldung. Schlägt ein +Schreibvorgang nach ersten Änderungen fehl, ist `code` = `write_failed` (alles wurde zurückgenommen, nichts +wurde gespeichert) oder `partial_write` (die Rücknahme war unvollständig). Bei `partial_write` vor einem +erneuten Versuch immer erst den aktuellen Stand prüfen (`lit-content-get`, +`lit/menu-get` o. Ä.) statt den Aufruf einfach zu wiederholen – dem `hint` folgen. diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 2f40e7b..71ada6d 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -20,7 +20,8 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`. -Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`): +Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und +Hinweisen immer so geschrieben): Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), @@ -30,6 +31,8 @@ Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`. ## Rückmeldungen -- Erfolg: `ok`, `summary`, `changed`, `warnings`, `revision_id` (Stand vorher), `log_id`, URLs. -- Fehler: JSON mit `code`, `message`, `hint` (und ggf. `fields`). Dem `hint` folgen. +- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei + Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. +- 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/docs/guides/workflows.md b/docs/guides/workflows.md index 8b06d5e..25cd116 100644 --- a/docs/guides/workflows.md +++ b/docs/guides/workflows.md @@ -3,18 +3,18 @@ ## Neue Website aufsetzen 1. `lit-site-status` und `lit-content-types` lesen. -2. Grundeinstellungen: `lit-wp-settings-get`, dann `lit-wp-settings-update` (Titel, Untertitel, Sprache, +2. Grundeinstellungen: `lit/wp-settings-get`, dann `lit/wp-settings-update` (Titel, Untertitel, Sprache, Zeitzone, Datumsformat, Permalinks `/%postname%/`). `previous` in der Antwort enthält die alten Werte. 3. Seiten als Entwürfe anlegen (`lit-content-create`), Hierarchie über `parent`. 4. Startseite: Seite veröffentlichen (nach Rückfrage), dann `{"settings": {"page_on_front": , "show_on_front": "page"}}`. -5. Menü: `lit-menu-create` (optional mit `location` aus `lit-menu-list`), Einträge mit `lit-menu-set-items`. +5. Menü: `lit/menu-create` (optional mit `location` aus `lit/menu-list`), Einträge mit `lit/menu-set-items`. 6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. ## Menüs pflegen -- `lit-menu-get` liefert den Baum. Für größere Umbauten den Baum verändern und vollständig als `items` an - `lit-menu-set-items` senden (bestehende Einträge mit `id`, neue ohne `id`). Wird der Baum unverändert +- `lit/menu-get` liefert den Baum. Für größere Umbauten den Baum verändern und vollständig als `items` an + `lit/menu-set-items` senden (bestehende Einträge mit `id`, neue ohne `id`). Wird der Baum unverändert zurückgeschickt, meldet das Tool „keine Änderung“ und schreibt nichts. - Für kleine Änderungen `operations`: `{op: "add", item, parent_id?, position?}`, `{op: "move", id, parent_id?, position?}`, `{op: "remove", id}` (Kinder eines entfernten Eintrags rücken diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index b98e378..74d5724 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -144,8 +144,9 @@ final class Errors trim('Speichern fehlgeschlagen: ' . $reason) . ' Die Änderung konnte nicht vollständig zurückgenommen ' . 'werden. ' . $state, $hint ?? 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand prüfen (lit-content-get, bei ' - . 'einem neuen Entwurf lit-content-query) und ggf. über lit-revisions-list den vorherigen Stand ' - . 'wiederherstellen; dem Nutzer Bescheid geben.', + . 'einem neuen Entwurf lit-content-query) und ggf. mit lit/revisions-list und lit/revision-restore ' + . '(über mcp-adapter-execute-ability) den vorherigen Stand wiederherstellen; dem Nutzer Bescheid ' + . 'geben.', 500, $details ); diff --git a/src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php b/src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php index f09c734..1e969e7 100644 --- a/src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php +++ b/src/Integrations/WordPress/Menus/MenuAssignLocationAbility.php @@ -34,7 +34,7 @@ final class MenuAssignLocationAbility implements Ability public function description(): string { - return 'Weist einer Theme-Position (siehe lit-menu-list) ein Menü zu; menu_id 0 entfernt die Zuweisung. ' + return 'Weist einer Theme-Position (siehe lit/menu-list) ein Menü zu; menu_id 0 entfernt die Zuweisung. ' . 'Das vorher zugewiesene Menü steht in previous_menu_id.'; } @@ -86,7 +86,7 @@ final class MenuAssignLocationAbility implements Ability } if ($menuId > 0 && ! is_object(wp_get_nav_menu_object($menuId))) { - return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); + return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit/menu-list abrufen.'); } $previous = MenuLocations::assigned()[$location] ?? 0; diff --git a/src/Integrations/WordPress/Menus/MenuCreateAbility.php b/src/Integrations/WordPress/Menus/MenuCreateAbility.php index 5d713fc..996f8a5 100644 --- a/src/Integrations/WordPress/Menus/MenuCreateAbility.php +++ b/src/Integrations/WordPress/Menus/MenuCreateAbility.php @@ -34,7 +34,7 @@ final class MenuCreateAbility implements Ability public function description(): string { return 'Legt ein leeres Menü an und weist es optional einer Theme-Position zu. Einträge danach mit ' - . 'lit-menu-set-items setzen.'; + . 'lit/menu-set-items setzen.'; } public function inputSchema(): array @@ -43,7 +43,7 @@ final class MenuCreateAbility implements Ability 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string', 'minLength' => 1], - 'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit-menu-list.'], + 'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit/menu-list.'], ], 'required' => ['name'], 'additionalProperties' => false, @@ -88,7 +88,7 @@ final class MenuCreateAbility implements Ability return Errors::invalidInput( 'Menü konnte nicht angelegt werden: ' . $menuId->get_error_message(), ['name' => 'bereits vergeben oder ungültig'], - 'Vorhandene Menüs über lit-menu-list abrufen.' + 'Vorhandene Menüs über lit/menu-list abrufen.' ); } diff --git a/src/Integrations/WordPress/Menus/MenuGetAbility.php b/src/Integrations/WordPress/Menus/MenuGetAbility.php index 8cce38d..3efed74 100644 --- a/src/Integrations/WordPress/Menus/MenuGetAbility.php +++ b/src/Integrations/WordPress/Menus/MenuGetAbility.php @@ -29,7 +29,7 @@ final class MenuGetAbility implements Ability return 'Liefert ein Menü als Baum (id, title, type post/taxonomy/custom, object/object_id, url, ' . 'children). object_id und url sind bei post/taxonomy-Einträgen nur informativ (WordPress leitet ' . 'sie aus dem verlinkten Objekt ab); nur bei custom-Einträgen ist url maßgeblich. Der Baum kann ' - . 'bearbeitet an lit-menu-set-items zurückgegeben werden.'; + . 'bearbeitet an lit/menu-set-items zurückgegeben werden.'; } public function inputSchema(): array @@ -78,7 +78,7 @@ final class MenuGetAbility implements Ability $menuId = (int) ($input['menu_id'] ?? 0); $menu = wp_get_nav_menu_object($menuId); if (! is_object($menu)) { - return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); + return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit/menu-list abrufen.'); } $items = wp_get_nav_menu_items($menuId); diff --git a/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php index 6d11f12..eb01c5a 100644 --- a/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php +++ b/src/Integrations/WordPress/Menus/MenuSetItemsAbility.php @@ -44,7 +44,7 @@ final class MenuSetItemsAbility implements Ability return 'Setzt die Einträge eines Menüs. Entweder items = vollständiger Zielbaum oder operations = Liste aus ' . '{op: add, item, parent_id?, position?}, {op: move, id, parent_id?, position?}, {op: remove, id} ' . '(Kinder eines entfernten Eintrags rücken an seine Stelle; position 0-basiert, ohne = ans Ende). ' - . 'Der Baum aus lit-menu-get kann bearbeitet unverändert zurückgegeben werden: bestehende Einträge ' + . 'Der Baum aus lit/menu-get kann bearbeitet unverändert zurückgegeben werden: bestehende Einträge ' . 'werden über id erkannt, type/object/object_id und bei post/taxonomy auch url sind nur informativ ' . 'und werden ignoriert; title wird nur bei Änderung zur eigenen Beschriftung, url nur bei custom ' . 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id oder custom mit url (/pfad, ' @@ -70,7 +70,7 @@ final class MenuSetItemsAbility implements Ability 'items' => [ 'type' => 'array', 'items' => ['type' => 'object'], - 'description' => 'Vollständiger Zielbaum (Knoten wie in lit-menu-get, neue Einträge ohne id).', + 'description' => 'Vollständiger Zielbaum (Knoten wie in lit/menu-get, neue Einträge ohne id).', ], 'operations' => [ 'type' => 'array', @@ -132,7 +132,7 @@ final class MenuSetItemsAbility implements Ability $menuId = (int) ($input['menu_id'] ?? 0); $menu = wp_get_nav_menu_object($menuId); if (! is_object($menu)) { - return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.'); + return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit/menu-list abrufen.'); } if (array_key_exists('items', $input) === array_key_exists('operations', $input)) { @@ -203,7 +203,7 @@ final class MenuSetItemsAbility implements Ability $after = FollowUp::run( fn () => MenuTree::build(array_values($this->currentItems($menuId))), $this->name() . ' re-read', - 'Das Menü ist gespeichert, der neue Stand konnte aber nicht gelesen werden; mit lit-menu-get abrufen.', + 'Das Menü ist gespeichert, der neue Stand konnte aber nicht gelesen werden; mit lit/menu-get abrufen.', $warnings ); diff --git a/src/Integrations/WordPress/Menus/MenuWriter.php b/src/Integrations/WordPress/Menus/MenuWriter.php index d44073e..c0806b9 100644 --- a/src/Integrations/WordPress/Menus/MenuWriter.php +++ b/src/Integrations/WordPress/Menus/MenuWriter.php @@ -462,9 +462,10 @@ class MenuWriter $cause, $detail, sprintf('Menü %d ist möglicherweise teilweise geändert worden.', $menuId), - 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit-menu-get prüfen und ihn ggf. ' - . 'mit lit-menu-set-items anhand von previous_items (Stand vorher) wiederherstellen; entfernte ' - . 'Einträge müssen dabei neu angelegt werden. Dem Nutzer Bescheid geben.', + 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit/menu-get prüfen und ihn ggf. ' + . 'mit lit/menu-set-items (beide über mcp-adapter-execute-ability) anhand von previous_items (Stand ' + . 'vorher) wiederherstellen; entfernte Einträge müssen dabei neu angelegt werden. Dem Nutzer Bescheid ' + . 'geben.', ['menu_id' => $menuId, 'previous_items' => MenuTree::build(array_values($current))] ); } diff --git a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php index 32014a9..c5987a0 100644 --- a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php @@ -146,7 +146,7 @@ final class RevisionRestoreAbility implements Ability $revisionId = PostGuard::id($input['revision_id'] ?? null); $revision = $revisionId === null ? null : get_post($revisionId); if (! $revision instanceof WP_Post || $revision->post_type !== 'revision') { - return Errors::notFound('Revision nicht gefunden.', 'Revisionen über lit-revisions-list abrufen.'); + return Errors::notFound('Revision nicht gefunden.', 'Revisionen über lit/revisions-list abrufen.'); } // field 'revision_id': post_parent 0 (an orphaned revision) is the only id this diff --git a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php index 4bdd893..cf374ec 100644 --- a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php @@ -41,7 +41,7 @@ final class RevisionsListAbility implements Ability { return 'Listet die gespeicherten Revisionen eines Eintrags (neueste zuerst) mit Datum, Autor und ' . 'Textauszug. Revisionen von Divi-Inhalten sind mit divi: true markiert; ihr excerpt ist dann ' - . 'Shortcode-/Block-Markup, kein lesbarer Text. Wiederherstellen mit lit-revision-restore.'; + . 'Shortcode-/Block-Markup, kein lesbarer Text. Wiederherstellen mit lit/revision-restore.'; } public function inputSchema(): array diff --git a/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php b/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php index 71dd857..822c0f7 100644 --- a/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php +++ b/src/Integrations/WordPress/Settings/WpSettingsGetAbility.php @@ -27,7 +27,7 @@ final class WpSettingsGetAbility implements Ability public function description(): string { return 'Liest die freigegebenen WordPress-Einstellungen (Allgemein, Lesen, Diskussion, Permalinks, ' - . 'Website-Icon) mit Typ und Beschreibung. Ändern mit lit-wp-settings-update.'; + . 'Website-Icon) mit Typ und Beschreibung. Ändern mit lit/wp-settings-update.'; } public function inputSchema(): array diff --git a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php index 3f998c3..6ae6c92 100644 --- a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php +++ b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php @@ -44,7 +44,7 @@ final class WpSettingsUpdateAbility implements Ability public function description(): string { - return 'Ändert freigegebene WordPress-Einstellungen (Schlüssel siehe lit-wp-settings-get). Alle Werte ' + return 'Ändert freigegebene WordPress-Einstellungen (Schlüssel siehe lit/wp-settings-get). Alle Werte ' . 'werden vorher geprüft – bei einem Fehler wird nichts gespeichert. previous enthält die alten Werte. ' . 'Gesperrt: admin_email, users_can_register, default_role, siteurl, home.'; } @@ -98,7 +98,7 @@ final class WpSettingsUpdateAbility implements Ability return Errors::invalidInput( 'Einstellungen ungültig; nichts wurde gespeichert.', $errors, - 'Werte gemäß fields korrigieren; aktuelle Werte über lit-wp-settings-get.' + 'Werte gemäß fields korrigieren; aktuelle Werte über lit/wp-settings-get.' ); } @@ -255,7 +255,8 @@ final class WpSettingsUpdateAbility implements Ability $cause, $cause->getMessage() . '; not restored: ' . implode(', ', $stuck), sprintf('Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', implode(', ', $stuck)), - 'Aktuelle Werte über lit/wp-settings-get prüfen und ggf. erneut auf die gewünschten Werte setzen.', + 'Aktuelle Werte über lit/wp-settings-get (mcp-adapter-execute-ability) prüfen und ggf. erneut auf die ' + . 'gewünschten Werte setzen.', ['keys' => $stuck] ); } diff --git a/src/Integrations/WordPress/Status/PostDuplicateAbility.php b/src/Integrations/WordPress/Status/PostDuplicateAbility.php index 2a8406f..a1a2ff5 100644 --- a/src/Integrations/WordPress/Status/PostDuplicateAbility.php +++ b/src/Integrations/WordPress/Status/PostDuplicateAbility.php @@ -41,7 +41,7 @@ final class PostDuplicateAbility implements Ability { return 'Legt eine Entwurfskopie eines Eintrags an (Inhalt, Felder/Meta, Begriffe, Beitragsbild, Template, ' . 'Divi-Einstellungen). Typischer Ablauf: Kopie bearbeiten, prüfen, dann mit ' - . 'lit-post-replace-with-copy ins Original übernehmen – so bleibt die veröffentlichte Seite bis dahin ' + . 'lit/post-replace-with-copy ins Original übernehmen – so bleibt die veröffentlichte Seite bis dahin ' . 'unverändert. Die Antwort enthält die rev der Kopie für lit-content-update.'; } diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 31675db..268a8fb 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -60,7 +60,7 @@ final class PostReplaceWithCopyAbility implements Ability public function description(): string { - return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit-post-duplicate erstellten Kopie ' + return 'Übernimmt Titel, Inhalt, Felder/Meta und Begriffe einer mit lit/post-duplicate erstellten Kopie ' . 'ins Original. ID, URL, Status und Menüeinträge des Originals bleiben; vorher entsteht eine ' . 'Revision und eine vollständige Sicherung des Originals (im Papierkorb, backup_id), die Kopie kommt ' . 'danach in den Papierkorb. Die Kopie muss Entwurf oder ausstehend sein. rev des Originals aus ' @@ -165,8 +165,8 @@ final class PostReplaceWithCopyAbility implements Ability if ($copy->post_type !== $original->post_type || $copyOf !== $originalId || $copyId === $originalId) { return Errors::invalidInput( 'Die Kopie gehört nicht zu diesem Original.', - ['copy_id' => sprintf('keine mit lit-post-duplicate erstellte Kopie von %d', $originalId)], - 'Kopie mit lit-post-duplicate aus diesem Original erstellen.' + ['copy_id' => sprintf('keine mit lit/post-duplicate erstellte Kopie von %d', $originalId)], + 'Kopie mit lit/post-duplicate aus diesem Original erstellen.' ); } @@ -174,7 +174,7 @@ final class PostReplaceWithCopyAbility implements Ability return Errors::invalidInput( sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status), ['copy_id' => 'Kopie muss Entwurf oder ausstehend sein'], - 'Mit lit-post-duplicate eine neue Kopie erstellen (bzw. die Kopie in WordPress wiederherstellen).' + 'Mit lit/post-duplicate eine neue Kopie erstellen (bzw. die Kopie in WordPress wiederherstellen).' ); } @@ -290,9 +290,9 @@ final class PostReplaceWithCopyAbility implements Ability $backupId ), 'Den Aufruf nicht einfach wiederholen. Zuerst den Stand mit lit-content-get prüfen; Titel, Inhalt ' - . 'und Auszug lassen sich mit lit/revision-restore (revision_id) zurückholen, Felder/Meta und ' - . 'Begriffe stehen in der Sicherung backup_id (in WordPress aus dem Papierkorb holen). Dem ' - . 'Nutzer Bescheid geben.', + . 'und Auszug lassen sich mit lit/revision-restore (revision_id, über ' + . 'mcp-adapter-execute-ability) zurückholen, Felder/Meta und Begriffe stehen in der Sicherung ' + . 'backup_id (in WordPress aus dem Papierkorb holen). Dem Nutzer Bescheid geben.', $details ); } diff --git a/tests/Unit/Abilities/ErrorsTest.php b/tests/Unit/Abilities/ErrorsTest.php index 20fbd43..8a63e63 100644 --- a/tests/Unit/Abilities/ErrorsTest.php +++ b/tests/Unit/Abilities/ErrorsTest.php @@ -76,7 +76,8 @@ final class ErrorsTest extends TestCase self::assertStringContainsString('Eintrag 5', $payload['message']); self::assertStringContainsString('Revision 811', $payload['message']); self::assertStringContainsString('lit-content-get', $payload['hint']); - self::assertStringContainsString('lit-revisions-list', $payload['hint']); + self::assertStringContainsString('lit/revisions-list', $payload['hint']); + self::assertStringContainsString('mcp-adapter-execute-ability', $payload['hint']); self::assertStringContainsString('nicht einfach wiederholen', $payload['hint']); self::assertSame(5, $payload['post_id']); self::assertSame(811, $payload['revision_id']); diff --git a/tests/Unit/Abilities/WriteFailedTest.php b/tests/Unit/Abilities/WriteFailedTest.php index d0728ab..a42a209 100644 --- a/tests/Unit/Abilities/WriteFailedTest.php +++ b/tests/Unit/Abilities/WriteFailedTest.php @@ -36,14 +36,14 @@ final class WriteFailedTest extends TestCase 'Menü nicht gespeichert.', false, state: 'Das Menü ist teilweise geändert.', - hint: 'Mit lit-menu-get prüfen.', + hint: 'Mit lit/menu-get prüfen.', details: ['menu_id' => 3] ))->toError(); self::assertSame('lit_partial_write', $error->get_error_code()); self::assertStringContainsString('Das Menü ist teilweise geändert.', $error->get_error_message()); self::assertStringNotContainsString('Entwurf', $error->get_error_message()); - self::assertSame('Mit lit-menu-get prüfen.', $error->get_error_data()['hint']); + self::assertSame('Mit lit/menu-get prüfen.', $error->get_error_data()['hint']); self::assertSame(3, $error->get_error_data()['menu_id']); } diff --git a/tests/Unit/Guides/ToolNamingTest.php b/tests/Unit/Guides/ToolNamingTest.php new file mode 100644 index 0000000..e836747 --- /dev/null +++ b/tests/Unit/Guides/ToolNamingTest.php @@ -0,0 +1,111 @@ +, indirect ones as lit/ (reached via mcp-adapter-execute-ability), + * and error codes without the internal lit_ prefix of the WP_Error code. + */ +final class ToolNamingTest extends TestCase +{ + /** @return array Ability name without "lit/" => isDirect(). */ + private static function abilities(): array + { + $root = dirname(__DIR__, 3); + $abilities = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/src')); + foreach ($files as $file) { + if (! str_ends_with((string) $file, 'Ability.php')) { + continue; + } + + $class = 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], substr((string) $file, strlen($root . '/src/'))); + $reflection = new ReflectionClass($class); + if ($reflection->isInterface() || ! $reflection->implementsInterface(Ability::class)) { + continue; + } + + /** @var Ability $ability */ + $ability = $reflection->newInstanceWithoutConstructor(); + $abilities[substr($ability->name(), strlen('lit/'))] = $ability->isDirect(); + } + + ksort($abilities); + + return $abilities; + } + + /** @return array Path => content of the guides and all PHP sources. */ + private static function texts(): array + { + $root = dirname(__DIR__, 3); + $texts = []; + foreach (glob($root . '/docs/guides/*.md') ?: [] as $file) { + $texts[$file] = (string) file_get_contents($file); + } + + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/src')); + foreach ($files as $file) { + if (str_ends_with((string) $file, '.php')) { + $texts[(string) $file] = (string) file_get_contents((string) $file); + } + } + + return $texts; + } + + public function testIndirectToolsAreNeverNamedLikeDirectOnes(): void + { + $abilities = self::abilities(); + self::assertArrayHasKey('menu-set-items', $abilities); + + foreach (self::texts() as $path => $text) { + preg_match_all('/\blit-([a-z]+(?:-[a-z]+)*)/', $text, $matches); + foreach ($matches[1] as $name) { + self::assertFalse( + ($abilities[$name] ?? true) === false, + sprintf('%s names the indirect tool lit/%s as lit-%s', basename($path), $name, $name) + ); + } + + preg_match_all('#\blit/([a-z]+(?:-[a-z]+)*)#', $text, $matches); + foreach ($matches[1] as $name) { + self::assertFalse( + ($abilities[$name] ?? false) === true && str_ends_with($path, '.md'), + sprintf('%s names the direct tool lit-%s as lit/%s', basename($path), $name, $name) + ); + } + } + } + + public function testGuidesUseTheErrorCodesTheClientSees(): void + { + foreach (self::texts() as $path => $text) { + if (str_ends_with($path, '.md')) { + self::assertDoesNotMatchRegularExpression('/`lit_(?!mcp)[a-z_]+`/', $text, basename($path)); + } + } + } + + public function testOverviewListsExactlyTheDirectTools(): void + { + $overview = (string) file_get_contents(dirname(__DIR__, 3) . '/docs/guides/overview.md'); + preg_match('/Direkt sichtbar:(.*?)\n\n/s', $overview, $section); + preg_match_all('/`lit-([a-z-]+)`/', $section[1] ?? '', $listed); + + $direct = array_keys(array_filter(self::abilities())); + $listedNames = $listed[1]; + sort($listedNames); + + self::assertSame($direct, $listedNames); + } +} diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php index 207d8d6..b36fcb8 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php @@ -360,7 +360,8 @@ final class MenuWriterTest extends TestCase self::assertSame('lit_partial_write', $error->get_error_code()); self::assertSame(3, $error->get_error_data()['menu_id']); self::assertSame('Home', $error->get_error_data()['previous_items'][0]['title']); - self::assertStringContainsString('lit-menu-get', $error->get_error_data()['hint']); + self::assertStringContainsString('lit/menu-get', $error->get_error_data()['hint']); + self::assertStringContainsString('mcp-adapter-execute-ability', $error->get_error_data()['hint']); } } } From e4d666af8d56477320ede6e29d8b75a817ad2ee8 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:31:50 +0200 Subject: [PATCH 056/185] test: smoke checks for text with &, trashed entries and missed schedules Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- tests/Integration/wp-basis-smoke.sh | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/Integration/wp-basis-smoke.sh b/tests/Integration/wp-basis-smoke.sh index 4da025e..959c285 100644 --- a/tests/Integration/wp-basis-smoke.sh +++ b/tests/Integration/wp-basis-smoke.sh @@ -14,6 +14,7 @@ ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) PODS_WAS_ACTIVE=no wp plugin is-active pods && PODS_WAS_ACTIVE=yes ORIGINAL_DESCRIPTION=$(wp option get blogdescription) +ORIGINAL_BLOGNAME=$(wp eval 'echo get_option("blogname");') ORIGINAL_LOCATIONS=$(wp eval 'echo wp_json_encode(get_nav_menu_locations());') CREATED_POSTS=() MENU_ID="" @@ -24,6 +25,10 @@ cleanup() { done [ -n "$MENU_ID" ] && wp menu delete "$MENU_ID" >/dev/null 2>&1 || true wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1 || true + # Restore the stored (already escaped) form byte-exact, bypassing sanitize_option(). + LIT_BLOGNAME="$ORIGINAL_BLOGNAME" wp eval \ + 'global $wpdb; $wpdb->update($wpdb->options, ["option_value" => getenv("LIT_BLOGNAME")], ["option_name" => "blogname"]); wp_cache_delete("alloptions", "options"); wp_cache_delete("blogname", "options");' \ + >/dev/null 2>&1 || true LIT_LOCATIONS="$ORIGINAL_LOCATIONS" wp eval \ 'set_theme_mod("nav_menu_locations", (array) json_decode(getenv("LIT_LOCATIONS"), true));' >/dev/null 2>&1 || true wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" delete >/dev/null 2>&1 || true @@ -129,7 +134,24 @@ pass "revisions list and restore" ability lit/post-trash "{\"post_id\":$PERSON_ID,\"confirm\":true}" | jq -e '.changed[0].action == "trashed"' >/dev/null \ || fail "post-trash" [ "$(wp post get "$PERSON_ID" --field=post_status)" = "trash" ] || fail "entry not in trash" -pass "post-trash moves to trash" +PERSON_REV=$(tool lit-content-get "{\"post_id\":$PERSON_ID}" | jq -r '.rev') +TRASHED=$(tool lit-content-update "{\"post_id\":$PERSON_ID,\"rev\":\"$PERSON_REV\",\"title\":\"x\"}") +[ "$(error_code "$TRASHED")" = "validation_failed" ] || fail "update of a trashed entry accepted: $TRASHED" +pass "post-trash moves to trash; trashed entries are not updated" + +# 8b. A missed schedule is never published as a side effect of a write +MISSED=$(tool lit-content-create '{"post_type":"page","title":"LIT Smoke verpasst"}') +MISSED_ID=$(echo "$MISSED" | jq -r '.changed[0].id // empty') +[ -n "$MISSED_ID" ] || fail "content-create: $MISSED" +CREATED_POSTS+=("$MISSED_ID") +LIT_ID="$MISSED_ID" wp eval \ + 'global $wpdb; $wpdb->update($wpdb->posts, ["post_status" => "future", "post_date" => "2020-01-01 10:00:00", "post_date_gmt" => "2020-01-01 09:00:00"], ["ID" => (int) getenv("LIT_ID")]); clean_post_cache((int) getenv("LIT_ID"));' \ + >/dev/null +MISSED_REV=$(tool lit-content-get "{\"post_id\":$MISSED_ID}" | jq -r '.rev') +REFUSED=$(tool lit-content-update "{\"post_id\":$MISSED_ID,\"rev\":\"$MISSED_REV\",\"title\":\"x\"}") +[ "$(error_code "$REFUSED")" = "validation_failed" ] || fail "missed schedule updated: $REFUSED" +[ "$(wp post get "$MISSED_ID" --field=post_status)" = "future" ] || fail "missed schedule got published" +pass "content-update refuses a missed schedule instead of publishing it" # 9. Menus MENU=$(ability lit/menu-create '{"name":"LIT Smoke Menü"}') @@ -163,7 +185,14 @@ BLOCKED=$(ability lit/wp-settings-update '{"settings":{"admin_email":"x@example. ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT Smoke Test"}}' \ | jq -e '.previous.blogdescription != null' >/dev/null || fail "wp-settings-update" [ "$(wp option get blogdescription)" = "LIT Smoke Test" ] || fail "blogdescription not updated" -pass "WordPress settings whitelist" +AMP='{"settings":{"blogname":"LIT Müller & Söhne","blogdescription":"LIT Smoke & Test"}}' +ability lit/wp-settings-update "$AMP" | jq -e '.ok == true' >/dev/null || fail "settings with & (first call)" +AGAIN=$(ability lit/wp-settings-update "$AMP") +echo "$AGAIN" | jq -e '.ok == true and (.changed | length) == 0 and .previous.blogname == "LIT Müller & Söhne"' >/dev/null \ + || fail "settings with & (second call): $AGAIN" +ability lit/wp-settings-get '{"group":"general"}' | jq -e '.settings[] | select(.key == "blogname") | .value == "LIT Müller & Söhne"' >/dev/null \ + || fail "wp-settings-get does not return blogname as typed" +pass "WordPress settings whitelist; text with & can be set twice and reads back as typed" # 11. Change log with client name PREFIX=$(wp db prefix) From 51e8846e1fbe7cab517bd0a340a69f8af81256b8 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:35:44 +0200 Subject: [PATCH 057/185] docs: record stage 1a rulings and deferred items Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../notes/2026-09-26-etappe-1a-rulings.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/superpowers/notes/2026-09-26-etappe-1a-rulings.md diff --git a/docs/superpowers/notes/2026-09-26-etappe-1a-rulings.md b/docs/superpowers/notes/2026-09-26-etappe-1a-rulings.md new file mode 100644 index 0000000..246713d --- /dev/null +++ b/docs/superpowers/notes/2026-09-26-etappe-1a-rulings.md @@ -0,0 +1,35 @@ +# Etappe 1a – Entscheidungen (Rulings) und offene Kleinigkeiten + +Aus dem SDD-Ledger übernommen, 2026-09-26. + + - Ruling P1 (B1): T7 PostGuard::postType() return type `: object` (WP_Post_Type) instead of `object|WP_Error` (compile error); errors via exceptions/other method as plan logic allows — cost if wrong: none. + - Ruling P2 (B2): T1 adds `` to phpcs.xml.dist (WordPress.Security rule) — our exceptions are internal and become JSON WP_Error messages, never HTML output — cost if wrong: an exception message echoed into HTML unescaped (we never do this). + - Ruling P3 (B3): T7 FieldValidator::checkScalar and T15 SettingsWhitelist::validate — parenthesize nested ternaries / extract helper so PHPCompatibility passes — cost: none. + - Ruling P4 (R1): T14 MenuWriter must accept the exact tree shape lit/menu-get returns: for object-linked items (page/post/category/term) a `url` field is ignored (not rejected); add a unit test for the round trip menu-get → menu-set-items — cost: none. + - Ruling P5 (R2): spec principle 7 (never clobber parallel edits) binds: lit/content-update REQUIRES `rev` (from lit/content-get) for all content; mismatch → lit_rev_mismatch with hint to re-read. T6 instructions/T9 accordingly — cost if wrong: one extra read per update. + - Ruling P6 (R3): all-or-nothing (spec §8.2): T9 validates everything before writing; if writeExtras() fails after insert (create) → delete the just-created draft (force, it's our own new draft); after update → restore the pre-update revision via RevisionPoint; return the error — cost: none. + - Ruling P7 (R4): T11 check wp_trash_post($copyId) result; if it fails, return ok with a warning "Kopie konnte nicht in den Papierkorb gelegt werden" — cost: none. + - Ruling P8 (C4): T16 moves shared smoke helpers (fail/pass/mcp/cleanup/token) into tests/Integration/lib.sh sourced by both mcp-smoke.sh and wp-basis-smoke.sh — cost: none. + - Task 1: minor (deferred): Errors::make silent fallback on json encode failure w/o log; $details may overwrite reserved keys + - Task 2: minor (deferred): WP_Application_Passwords client-name branch untested; clientName not length-capped (sanitize when logging!); duplicated try/catch block in AbilityRegistrar + - Task 3: minor (deferred): insert format array not asserted in test; object_id/ability not truncated + - Task 4: minor (deferred): sampling ORDER BY ID vs index; array_merge numeric meta keys + - Task 5: minor (deferred): podFor call outside try/catch in write(); get_fields() null tolerance + - Task 6: Ruling P9: lit/content-types must only list/describe post types the user can edit (post type cap->edit_posts) — carried into Task 8 dispatch (small change in ContentTypesAbility) — cost if wrong: none (schema only) + - Task 6: minor (deferred): instructions "Inhaltstypen" line uncapped/untested length + - Task 7: minor (deferred): multi-value scalar fields validated as scalar; TermAssigner::assign trusts resolve(); PostGuard cap fallback to raw cap string + - Ruling P10: content rev must detect parallel edits of title/excerpt/content/status too, not only post_content: add Rev::ofPost(WP_Post) = Rev::of(post_content|post_title|post_excerpt|post_status|post_modified_gmt) used by PostPresenter (content-get) and content-update check/return (T9); Rev::of(string) stays for Divi (Etappe 2) — cost if wrong: occasional extra rev_mismatch → re-read + - Task 9: Ruling P11: rev does not cover field/term-only changes by others — accepted limitation (fields rarely edited in parallel; WP does not bump post_modified on meta change) — cost if wrong: a parallel field edit could be overwritten (revision/log still allow undo) + - Task 9: minor (deferred): assign() midway cleanup failure not reported; WP_Error after rowTouched relies on template pre-validation; Pods snapshot no "absent"; rollback via kses may alter admin markup + - Task 10: minor (deferred): permission()/execute() double load (repo-wide pattern) + - Task 11: Ruling P12: replace-with-copy loses original meta/terms (revision covers only title/content/excerpt) → before the swap, clone the original into a trashed backup post (PostCloner), return backup_id; if the backup can't be created → abort with lit_write_failed; with EMPTY_TRASH_DAYS=0 we already refuse — cost if wrong: one extra trashed post per swap + - Task 11: FINAL-FIX (important-ish, carry to final wave): PostCloner::ownMeta must check $wpdb->last_error and throw — a failed SELECT currently looks like "no meta" and replace would wipe original meta; also add create_posts guard before backup; include backup_id in WriteFailed details + - Task 11: minor (deferred): raw inserts skip meta hooks (intended); meta key with backslash edge case + - Task 14: Ruling P13: custom menu links may be /path, #anchor, http(s), mailto, tel (brief) — my 'http(s) only' was shorthand; javascript:/data: etc. must be rejected — cost if wrong: none + - Task 14: Ruling P14: private targets always refused; draft/pending/future only with allow_unpublished + correct warning; non-public taxonomy terms refused; status gate only for newly linked items (existing ones round-trip) — cost if wrong: Claude must set a flag for draft-based site setup + - Task 14: minor (deferred): description should state status gate applies only to new links; raw status string in message; no depth limit + - Task 16: minor (deferred): content-types guide "Jede Änderung liefert revision_id" also near duplicate (null there) + - Final review 1a: ready with fixes. Ruling P15: fix wave = I1 (PostCloner last_error, create_posts guard, backup_id in WriteFailed), I2 (settings isStored via sanitize_option), I3 (guard against future→publish on writes with missed schedule), I4 (edit_post_meta per key), I5 (guides/hints: codes without lit_ prefix, indirect tools via execute-ability as lit/, revision_id claim), M1 (settings FollowUp/WriteFailed/partial log; menu partial log), M2 (checkRelation read_post), M3 (PostGuard refuses trashed by default), M4 (status tools WriteFailed on throwing hook), M6 (permalink regex), M7 (ChangeLog truncate object_id/ability), Task14 description, consolidation: PostChangeResult helper, Rev::matches, ChangeLog::recordPartial. + - Ruling P16: rev naming — `rev` always means the post-level Rev::ofPost for post tools; Etappe 2 Divi tree lock will use a distinct field name (e.g. `tree_rev`) — spec §5.3 to be amended in Etappe 2 plan — cost if wrong: rename later + - Ruling P17: deferred to 1b: menu rev (M5), lit_mcp_guides filter, M8 warnings (front page trash, menu unassign confirm, private schedule note, divi hint) + - Final: parked (Ruling P18): missedSchedule 60s window & zero GMT date fallback; write_failed wording mentions backup — deferred to 1b minor fixes — cost if wrong: seconds-early publish on edits within last minute before schedule From a2a2da1ae7e3d6bfd29ac8df5badffbee201aa47 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:54:55 +0200 Subject: [PATCH 058/185] feat: media usage finder and media-get tool Usage search distinguishes certain (ID referenced) and possible (file URL only) usages, hides titles the user cannot read, and keeps queries bounded. MediaGuard checks per-attachment capabilities and refuses trashed media unless the caller opts in. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 2 +- src/Integrations/Media/MediaGetAbility.php | 76 ++++ src/Integrations/Media/MediaGuard.php | 56 +++ src/Integrations/Media/MediaIntegration.php | 41 ++ src/Integrations/Media/MediaPresenter.php | 109 +++++ src/Integrations/Media/MediaUsageFinder.php | 424 ++++++++++++++++++ .../WordPress/Schema/ContentSchema.php | 23 + src/Plugin.php | 7 +- .../Media/MediaGetAbilityTest.php | 55 +++ .../Integrations/Media/MediaGuardTest.php | 49 ++ .../Media/MediaIntegrationTest.php | 34 ++ .../Integrations/Media/MediaPresenterTest.php | 77 ++++ .../Media/MediaUsageFinderTest.php | 213 +++++++++ .../WordPress/Schema/ContentSchemaTest.php | 19 + 14 files changed, 1182 insertions(+), 3 deletions(-) create mode 100644 src/Integrations/Media/MediaGetAbility.php create mode 100644 src/Integrations/Media/MediaGuard.php create mode 100644 src/Integrations/Media/MediaIntegration.php create mode 100644 src/Integrations/Media/MediaPresenter.php create mode 100644 src/Integrations/Media/MediaUsageFinder.php create mode 100644 tests/Unit/Integrations/Media/MediaGetAbilityTest.php create mode 100644 tests/Unit/Integrations/Media/MediaGuardTest.php create mode 100644 tests/Unit/Integrations/Media/MediaIntegrationTest.php create mode 100644 tests/Unit/Integrations/Media/MediaPresenterTest.php create mode 100644 tests/Unit/Integrations/Media/MediaUsageFinderTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 71ada6d..de7fe76 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -25,7 +25,7 @@ Hinweisen immer so geschrieben): Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), -Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`). +Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`). Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`. diff --git a/src/Integrations/Media/MediaGetAbility.php b/src/Integrations/Media/MediaGetAbility.php new file mode 100644 index 0000000..2bf3c12 --- /dev/null +++ b/src/Integrations/Media/MediaGetAbility.php @@ -0,0 +1,76 @@ + 'object', + 'properties' => ['id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return MediaPresenter::fullSchema(); + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'edit_post', true); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'edit_post', true); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + return $this->presenter->full($attachment, $this->finder->usages((int) $attachment->ID)); + } +} diff --git a/src/Integrations/Media/MediaGuard.php b/src/Integrations/Media/MediaGuard.php new file mode 100644 index 0000000..51771fd --- /dev/null +++ b/src/Integrations/Media/MediaGuard.php @@ -0,0 +1,56 @@ + 'positive Ganzzahl erwartet']); + } + + $attachment = get_post($attachmentId); + if (! $attachment instanceof WP_Post || $attachment->post_type !== 'attachment') { + return Errors::notFound( + sprintf('Medium %d nicht gefunden.', $attachmentId), + 'ID prüfen; Medien über lit-media-search suchen.' + ); + } + + if (! current_user_can($capability, $attachmentId)) { + return Errors::forbidden(sprintf('Keine Berechtigung für Medium %d.', $attachmentId)); + } + + if (! $allowTrashed && $attachment->post_status === 'trash') { + return Errors::invalidInput( + sprintf('Medium %d liegt im Papierkorb.', $attachmentId), + ['id' => 'Medium liegt im Papierkorb'], + 'Medium zuerst in der WordPress-Mediathek wiederherstellen.' + ); + } + + return $attachment; + } +} diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php new file mode 100644 index 0000000..0632ea9 --- /dev/null +++ b/src/Integrations/Media/MediaIntegration.php @@ -0,0 +1,41 @@ + $this->schema->mediaFieldKeys()); + + return [ + new MediaGetAbility($presenter, $finder), + ]; + } +} diff --git a/src/Integrations/Media/MediaPresenter.php b/src/Integrations/Media/MediaPresenter.php new file mode 100644 index 0000000..22705e5 --- /dev/null +++ b/src/Integrations/Media/MediaPresenter.php @@ -0,0 +1,109 @@ + */ + public function summary(WP_Post $attachment): array + { + $id = (int) $attachment->ID; + $meta = $this->metadata($attachment); + + return [ + 'id' => $id, + 'title' => (string) $attachment->post_title, + 'filename' => wp_basename((string) get_attached_file($id)), + 'mime_type' => (string) $attachment->post_mime_type, + 'url' => (string) wp_get_attachment_url($id), + 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true), + 'width' => isset($meta['width']) ? (int) $meta['width'] : null, + 'height' => isset($meta['height']) ? (int) $meta['height'] : null, + 'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null, + 'date' => (string) $attachment->post_date, + ]; + } + + /** + * @param list> $usages From MediaUsageFinder::usages(). + * @return array + */ + public function full(WP_Post $attachment, array $usages): array + { + $id = (int) $attachment->ID; + $sizes = []; + foreach ((array) ($this->metadata($attachment)['sizes'] ?? []) as $name => $size) { + $source = wp_get_attachment_image_src($id, (string) $name); + if (is_array($source)) { + $sizes[(string) $name] = [ + 'url' => (string) $source[0], + 'width' => (int) $source[1], + 'height' => (int) $source[2], + ]; + } + } + + return array_merge($this->summary($attachment), [ + 'caption' => (string) $attachment->post_excerpt, + 'description' => (string) $attachment->post_content, + 'parent' => (int) $attachment->post_parent, + 'sizes' => JsonMap::of($sizes), + 'usages' => $usages, + 'edit_url' => (string) get_edit_post_link($id, 'raw'), + ]); + } + + /** @return array */ + public static function summarySchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'filename' => ['type' => 'string'], + 'mime_type' => ['type' => 'string'], + 'url' => ['type' => 'string'], + 'alt' => ['type' => 'string'], + 'width' => ['type' => ['integer', 'null']], + 'height' => ['type' => ['integer', 'null']], + 'filesize' => ['type' => ['integer', 'null']], + 'date' => ['type' => 'string'], + ], + 'required' => ['id', 'title', 'mime_type', 'url', 'alt'], + ]; + } + + /** @return array */ + public static function fullSchema(): array + { + $schema = self::summarySchema(); + $schema['properties'] = array_merge($schema['properties'], [ + 'caption' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'parent' => ['type' => 'integer'], + 'sizes' => ['type' => 'object'], + 'usages' => ['type' => 'array', 'items' => MediaUsageFinder::usageSchema()], + 'edit_url' => ['type' => 'string'], + ]); + $schema['required'] = array_merge($schema['required'], ['sizes', 'usages']); + + return $schema; + } + + /** @return array */ + private function metadata(WP_Post $attachment): array + { + $meta = wp_get_attachment_metadata((int) $attachment->ID); + + return is_array($meta) ? $meta : []; + } +} diff --git a/src/Integrations/Media/MediaUsageFinder.php b/src/Integrations/Media/MediaUsageFinder.php new file mode 100644 index 0000000..ccf3ff4 --- /dev/null +++ b/src/Integrations/Media/MediaUsageFinder.php @@ -0,0 +1,424 @@ + + * classes, [gallery ids="…"], Divi 4 gallery_ids="…", core media blocks ("id", "mediaId", + * "ids") and Divi 5 block attributes ("id", "galleryIds"). + * - possible ("möglicherweise"): only the file URL (original or a generated size) appears – + * in content, Divi 4/5 image URLs or the Divi logo; it may belong to another copy of the file. + * + * All queries are prepared and bounded; content is loaded in small batches and only until + * $max usages are found. Titles of entries the current user cannot read are hidden. + * Not final so abilities can be tested with a mock. + */ +class MediaUsageFinder +{ + public const CERTAIN = 'sicher'; + public const POSSIBLE = 'möglicherweise'; + public const UNREADABLE_TITLE = '(nicht lesbar)'; + public const WHERE = ['featured', 'field', 'content', 'site_icon', 'custom_logo', 'divi_logo']; + + /** Max. entries whose content is inspected per call. */ + private const CANDIDATE_LIMIT = 200; + + /** Entries whose content is loaded per query (keeps memory low with large Divi pages). */ + private const CONTENT_BATCH = 10; + + private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio']; + + /** Block delimiter pattern of WordPress' block parser (WP_Block_Parser::next_token()). */ + private const BLOCK_PATTERN = '/).)*+)?}\s+)?\/?-->/s'; + + /** [gallery ids="…"] and Divi 4 gallery_ids="…" – quotes also JSON-escaped inside Divi 5 blocks. */ + private const ID_LIST_PATTERN = '/(?:\[gallery\b[^\]]*?\bids|\bgallery_ids)=(?:"|\'|\\\\u0022)?([\d,\s]+)/i'; + + private const EXCLUDED_STATUSES = "'auto-draft', 'trash', 'inherit'"; + + /** + * @param Closure(): list $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()). + */ + public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null) + { + } + + /** + * @return list + * post_id 0 for site-wide usages (site icon, logos). + */ + public function usages(int $attachmentId, int $max = 20): array + { + $wpdb = $this->db ?? $GLOBALS['wpdb']; + $max = max(1, $max); + $stem = $this->fileStem($attachmentId); + $fileRegex = $stem === '' ? null : self::fileRegex($stem); + + $found = []; + $this->addRows($found, $this->featured($wpdb, $attachmentId, $max), 'featured'); + if (count($found) < $max) { + $this->addRows($found, $this->fields($wpdb, $attachmentId, $max), 'field'); + } + if (count($found) < $max) { + $this->addSiteUsages($found, $attachmentId, $fileRegex); + } + if (count($found) < $max) { + $this->addContentUsages($found, $wpdb, $attachmentId, $stem, $fileRegex, $max); + } + + return array_slice(array_values($found), 0, $max); + } + + /** Certain and possible usages both count – a possible one also blocks a delete without force. */ + public function isUsed(int $attachmentId): bool + { + return $this->usages($attachmentId, 1) !== []; + } + + /** @return array JSON Schema of one entry of usages(). */ + public static function usageSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'post_type' => ['type' => 'string'], + 'where' => ['type' => 'string', 'enum' => self::WHERE], + 'certainty' => ['type' => 'string', 'enum' => [self::CERTAIN, self::POSSIBLE]], + ], + 'required' => ['post_id', 'title', 'post_type', 'where', 'certainty'], + ]; + } + + /** @return list */ + private function featured(\wpdb $wpdb, int $attachmentId, int $max): array + { + $statuses = self::EXCLUDED_STATUSES; + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names and a constant. + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s + AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID LIMIT %d", + (string) $attachmentId, + $max + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** @return list */ + private function fields(\wpdb $wpdb, int $attachmentId, int $max): array + { + $keys = array_values(array_map('strval', ($this->mediaMetaKeys)())); + if ($keys === []) { + return []; + } + + $placeholders = implode(', ', array_fill(0, count($keys), '%s')); + $statuses = self::EXCLUDED_STATUSES; + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders holds one %s per key. + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT DISTINCT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value = %s + AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID LIMIT %d", + ...array_merge($keys, [(string) $attachmentId, $max]) + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + } + + /** + * @param array> $found + */ + private function addSiteUsages(array &$found, int $attachmentId, ?string $fileRegex): void + { + if ((int) get_option('site_icon') === $attachmentId) { + $found['site:site_icon'] = self::siteUsage('Website-Icon', 'option', 'site_icon', self::CERTAIN); + } + + if ((int) get_theme_mod('custom_logo') === $attachmentId) { + $found['site:custom_logo'] = self::siteUsage( + 'Logo (Customizer)', + 'theme_mod', + 'custom_logo', + self::CERTAIN + ); + } + + $divi = get_option('et_divi'); + $diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : ''; + if ($fileRegex !== null && $diviLogo !== '' && preg_match($fileRegex, $diviLogo) === 1) { + $found['site:divi_logo'] = self::siteUsage('Divi-Logo', 'option', 'divi_logo', self::POSSIBLE); + } + } + + /** + * @param array> $found + */ + private function addContentUsages( + array &$found, + \wpdb $wpdb, + int $attachmentId, + string $stem, + ?string $fileRegex, + int $max + ): void { + foreach (array_chunk($this->contentCandidates($wpdb, $attachmentId, $stem), self::CONTENT_BATCH) as $chunk) { + $contents = $this->contents($wpdb, array_map(static fn (object $row): int => (int) $row->ID, $chunk)); + foreach ($chunk as $row) { + $certainty = self::contentCertainty($contents[(int) $row->ID] ?? '', $attachmentId, $fileRegex); + if ($certainty !== null) { + $this->add($found, $row, 'content', $certainty); + } + if (count($found) >= $max) { + return; + } + } + } + } + + /** + * Coarse pre-filter in SQL (a superset of what contentCertainty() accepts); IDs and titles only. + * + * @return list + */ + private function contentCandidates(\wpdb $wpdb, int $attachmentId, string $stem): array + { + $id = (string) $attachmentId; + $likes = array_map( + static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%', + ['wp-image-' . $id, '"id":' . $id, '"id":"' . $id . '"', '"mediaId":' . $id] + ); + // ID lists: [gallery ids="…"], gallery_ids="…", "ids":[…], Divi 5 "galleryIds":{…}. + foreach (['ids=', '"ids":[', 'galleryIds":'] as $prefix) { + $likes[] = '%' . $wpdb->esc_like($prefix) . '%' . $id . '%'; + } + if ($stem !== '') { + // File URL, plain and with JSON-escaped slashes (foto.jpg, foto-300x200.jpg, foto-scaled.jpg). + $likes[] = '%' . $wpdb->esc_like('/' . $stem) . '%'; + $likes[] = '%' . $wpdb->esc_like('\/' . str_replace('/', '\/', $stem)) . '%'; + } + + $conditions = implode(' OR ', array_fill(0, count($likes), 'p.post_content LIKE %s')); + $statuses = self::EXCLUDED_STATUSES; + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only placeholders, table names, a constant. + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p + WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item') + AND p.post_status NOT IN ({$statuses}) + AND ({$conditions}) ORDER BY p.ID LIMIT %d", + ...array_merge($likes, [self::CANDIDATE_LIMIT]) + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * @param list $ids + * @return array ID => post_content + */ + private function contents(\wpdb $wpdb, array $ids): array + { + $placeholders = implode(', ', array_fill(0, count($ids), '%d')); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders holds one %d per ID. + $rows = (array) $wpdb->get_results($wpdb->prepare( + "SELECT p.ID, p.post_content FROM {$wpdb->posts} p WHERE p.ID IN ({$placeholders})", + ...$ids + )); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + + $contents = []; + foreach ($rows as $row) { + $contents[(int) $row->ID] = (string) $row->post_content; + } + + return $contents; + } + + private static function contentCertainty(string $content, int $attachmentId, ?string $fileRegex): ?string + { + if (self::referencesId($content, $attachmentId)) { + return self::CERTAIN; + } + + return $fileRegex !== null && preg_match($fileRegex, $content) === 1 ? self::POSSIBLE : null; + } + + private static function referencesId(string $content, int $attachmentId): bool + { + if (preg_match('/\bwp-image-' . $attachmentId . '\b/', $content) === 1) { + return true; + } + + if (preg_match_all(self::ID_LIST_PATTERN, $content, $lists) > 0 && self::inList($lists[1], $attachmentId)) { + return true; + } + + preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER); + foreach ($blocks as $block) { + $attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true); + if (! is_array($attrs)) { + continue; + } + + $namespace = rtrim((string) $block['namespace'], '/'); + $isCoreMedia = in_array($namespace, ['', 'core'], true) + && in_array($block['name'], self::CORE_MEDIA_BLOCKS, true); + if ( + ($isCoreMedia && self::coreBlockReferences($attrs, $attachmentId)) + || ($namespace === 'divi' && self::diviAttrsReference($attrs, $attachmentId)) + ) { + return true; + } + } + + return false; + } + + /** @param array $attrs */ + private static function coreBlockReferences(array $attrs, int $attachmentId): bool + { + foreach (['id', 'mediaId'] as $key) { + if (isset($attrs[$key]) && is_numeric($attrs[$key]) && (int) $attrs[$key] === $attachmentId) { + return true; + } + } + + $ids = is_array($attrs['ids'] ?? null) ? array_filter($attrs['ids'], 'is_numeric') : []; + + return in_array($attachmentId, array_map('intval', $ids), true); + } + + /** + * Divi 5 image modules store {"src": url, "id": ""}, galleries "galleryIds" as "1,2,3". + * + * @param array $attrs + */ + private static function diviAttrsReference(array $attrs, int $attachmentId): bool + { + foreach ($attrs as $key => $value) { + if ($key === 'id' && (is_int($value) || is_string($value)) && (string) $value === (string) $attachmentId) { + return true; + } + + if ($key === 'galleryIds' && self::inList(self::stringLeaves($value), $attachmentId)) { + return true; + } + + if (is_array($value) && self::diviAttrsReference($value, $attachmentId)) { + return true; + } + } + + return false; + } + + /** @return list */ + private static function stringLeaves(mixed $value): array + { + if (is_string($value)) { + return [$value]; + } + + $leaves = []; + if (is_array($value)) { + array_walk_recursive($value, static function ($leaf) use (&$leaves): void { + if (is_string($leaf)) { + $leaves[] = $leaf; + } + }); + } + + return $leaves; + } + + /** @param list $lists Comma-separated ID lists. */ + private static function inList(array $lists, int $attachmentId): bool + { + foreach ($lists as $list) { + foreach (explode(',', $list) as $item) { + $item = trim($item); + if ($item !== '' && ctype_digit($item) && (int) $item === $attachmentId) { + return true; + } + } + } + + return false; + } + + /** + * Upload-relative path without extension and without WordPress' suffixes for big, + * rotated or edited images: 2026/09/foto-scaled.jpg → 2026/09/foto. + */ + private function fileStem(int $attachmentId): string + { + $file = ltrim((string) get_post_meta($attachmentId, '_wp_attached_file', true), '/'); + $stem = (string) preg_replace('/\.[^.\/]+$/', '', $file); + + return (string) preg_replace('/(?:-scaled|-rotated|-e\d{10,})+$/', '', $stem); + } + + /** Original or a generated size of the file, with plain or JSON-escaped slashes. */ + private static function fileRegex(string $stem): string + { + $separator = '(?:/|\\\\/)'; + $parts = array_map(static fn (string $part): string => preg_quote($part, '~'), explode('/', $stem)); + + return '~' . $separator . implode($separator, $parts) + . '(?:-\d+x\d+|-scaled|-rotated|-e\d{10,})*\.[a-z0-9]{2,5}(?![a-z0-9])~i'; + } + + /** + * @param array> $found + * @param list $rows + */ + private function addRows(array &$found, array $rows, string $where): void + { + foreach ($rows as $row) { + $this->add($found, $row, $where, self::CERTAIN); + } + } + + /** + * Adds one usage per entry. Only content matches can be "possible" and content is inspected + * last, so the first usage found for an entry is always the most certain one. + * + * @param array> $found + */ + private function add(array &$found, object $row, string $where, string $certainty): void + { + $postId = (int) $row->ID; + $key = 'post:' . $postId; + if (isset($found[$key])) { + return; + } + + $found[$key] = [ + 'post_id' => $postId, + 'title' => current_user_can('read_post', $postId) ? (string) $row->post_title : self::UNREADABLE_TITLE, + 'post_type' => (string) $row->post_type, + 'where' => $where, + 'certainty' => $certainty, + ]; + } + + /** @return array{post_id: int, title: string, post_type: string, where: string, certainty: string} */ + private static function siteUsage(string $title, string $type, string $where, string $certainty): array + { + return ['post_id' => 0, 'title' => $title, 'post_type' => $type, 'where' => $where, 'certainty' => $certainty]; + } +} diff --git a/src/Integrations/WordPress/Schema/ContentSchema.php b/src/Integrations/WordPress/Schema/ContentSchema.php index 5741a96..506e49f 100644 --- a/src/Integrations/WordPress/Schema/ContentSchema.php +++ b/src/Integrations/WordPress/Schema/ContentSchema.php @@ -130,6 +130,29 @@ class ContentSchema throw new LogicException(sprintf('No field source "%s" registered.', $field->source)); } + /** + * Keys of fields that store attachment IDs (file fields, relations to media) over all + * managed post types, sorted – used by the media usage search. + * + * @return list + */ + public function mediaFieldKeys(): array + { + $keys = []; + foreach ($this->postTypeNames() as $postType) { + foreach ($this->fields($postType) as $field) { + if ($field->type === 'file' || ($field->related['object_type'] ?? null) === 'media') { + $keys[$field->key] = true; + } + } + } + + $keys = array_map('strval', array_keys($keys)); + sort($keys); + + return $keys; + } + /** @return list */ public function taxonomies(string $postType): array { diff --git a/src/Plugin.php b/src/Plugin.php index 96b9aaf..f515a99 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,6 +11,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Pods\PodsFieldSource; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource; @@ -41,14 +42,16 @@ final class Plugin (new Access())->register(); (new RequestContext())->register(); + $limits = new Limits(); $changeLog = new ChangeLog(); (new ChangeLogInstaller($changeLog))->maybeInstall(); - (new ChangeLogCleanup($changeLog, new Limits()))->register(); + (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), - new WordPressIntegration($schema, $changeLog) + new WordPressIntegration($schema, $changeLog), + new MediaIntegration($schema, $changeLog, $limits) ); (new AbilityRegistrar($registry))->register(); diff --git a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php new file mode 100644 index 0000000..0710046 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php @@ -0,0 +1,55 @@ + 33, 'post_type' => 'attachment', 'post_status' => 'inherit']); + Functions\when('get_post')->justReturn($attachment); + Functions\when('current_user_can')->justReturn(true); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->expects('usages')->with(33)->andReturn([]); + $presenter = Mockery::mock(MediaPresenter::class); + $presenter->expects('full')->with($attachment, [])->andReturn(['id' => 33]); + + self::assertSame(['id' => 33], (new MediaGetAbility($presenter, $finder))->execute(['id' => 33])); + } + + public function testTrashedAttachmentsCanBeRead(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash'])); + Functions\when('current_user_can')->justReturn(true); + $ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class)); + + self::assertTrue($ability->permission(['id' => 33])); + } + + public function testNonAttachmentIsNotFound(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page'])); + $ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class)); + + self::assertSame('lit_not_found', $ability->permission(['id' => 5])->get_error_code()); + } + + public function testIsIndirectAndReadonly(): void + { + $ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class)); + + self::assertSame('lit/media-get', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } +} diff --git a/tests/Unit/Integrations/Media/MediaGuardTest.php b/tests/Unit/Integrations/Media/MediaGuardTest.php new file mode 100644 index 0000000..e8cacdc --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaGuardTest.php @@ -0,0 +1,49 @@ +get_error_code()); + self::assertSame('lit_validation_failed', MediaGuard::attachment(0)->get_error_code()); + } + + public function testMissingOrNonAttachmentIsNotFound(): void + { + Functions\when('get_post')->justReturn(null); + self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code()); + + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'page'])); + self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code()); + } + + public function testChecksTheRequestedCapabilityOnTheAttachment(): void + { + $attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit']); + Functions\when('get_post')->justReturn($attachment); + Functions\expect('current_user_can')->once()->with('delete_post', 33)->andReturn(false); + + self::assertSame('lit_forbidden', MediaGuard::attachment('33', 'delete_post')->get_error_code()); + } + + public function testRefusesTrashedAttachmentsUnlessAllowed(): void + { + $attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash']); + Functions\when('get_post')->justReturn($attachment); + Functions\when('current_user_can')->justReturn(true); + + $error = MediaGuard::attachment(33); + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertStringContainsString('Papierkorb', $error->get_error_message()); + self::assertSame($attachment, MediaGuard::attachment(33, 'read_post', true)); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php new file mode 100644 index 0000000..eeb70b5 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -0,0 +1,34 @@ +id()); + self::assertTrue($integration->isAvailable()); + self::assertSame(self::EXPECTED, array_map(static fn (Ability $a): string => $a->name(), $integration->abilities())); + + foreach ($integration->abilities() as $ability) { + self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name()); + } + } +} diff --git a/tests/Unit/Integrations/Media/MediaPresenterTest.php b/tests/Unit/Integrations/Media/MediaPresenterTest.php new file mode 100644 index 0000000..1be6b89 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaPresenterTest.php @@ -0,0 +1,77 @@ + 33, + 'post_type' => 'attachment', + 'post_title' => 'Foto', + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'Bildunterschrift', + 'post_content' => 'Beschreibung', + 'post_parent' => 5, + ]); + } + + private function stubCommon(array $meta): void + { + Functions\when('wp_get_attachment_metadata')->justReturn($meta); + Functions\when('get_attached_file')->justReturn('/var/www/html/wp-content/uploads/2026/09/foto.jpg'); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/2026/09/foto.jpg'); + Functions\when('get_post_meta')->justReturn('Porträt'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=33&action=edit'); + } + + public function testSummaryHasFileDataAndAlt(): void + { + $this->stubCommon(['width' => 1600, 'height' => 900, 'filesize' => 123456]); + + $summary = (new MediaPresenter())->summary($this->attachment()); + + self::assertSame('foto.jpg', $summary['filename']); + self::assertSame('Porträt', $summary['alt']); + self::assertSame(1600, $summary['width']); + self::assertSame(123456, $summary['filesize']); + } + + public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void + { + $this->stubCommon(['sizes' => ['medium' => ['width' => 300, 'height' => 169]]]); + Functions\when('wp_get_attachment_image_src')->justReturn(['https://kunde.de/foto-300x169.jpg', 300, 169, true]); + $usages = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher']]; + + $full = (new MediaPresenter())->full($this->attachment(), $usages); + + self::assertSame(['medium' => ['url' => 'https://kunde.de/foto-300x169.jpg', 'width' => 300, 'height' => 169]], $full['sizes']); + self::assertSame($usages, $full['usages']); + self::assertSame('Bildunterschrift', $full['caption']); + self::assertSame(5, $full['parent']); + self::assertSame('https://kunde.de/wp-admin/post.php?post=33&action=edit', $full['edit_url']); + self::assertNull($full['width']); + + Functions\when('wp_get_attachment_metadata')->justReturn(false); + self::assertInstanceOf(stdClass::class, (new MediaPresenter())->full($this->attachment(), [])['sizes']); + } + + public function testFullSchemaRequiresSizesAndUsagesAndDescribesUsages(): void + { + $schema = MediaPresenter::fullSchema(); + + self::assertContains('sizes', $schema['required']); + self::assertContains('usages', $schema['required']); + self::assertSame(['sicher', 'möglicherweise'], $schema['properties']['usages']['items']['properties']['certainty']['enum']); + } +} diff --git a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php new file mode 100644 index 0000000..763a340 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php @@ -0,0 +1,213 @@ + */ + private array $queries = []; + + /** + * @param array $options + * @param array $contents Candidate post ID => post_content. + * @param list $featured + * @param list $fields + */ + private function finder( + array $contents = [], + array $featured = [], + array $fields = [], + array $options = [], + int $customLogo = 0, + array $mediaKeys = ['photo'], + string $file = '2026/09/foto-scaled.jpg' + ): MediaUsageFinder { + Functions\when('get_post_meta')->justReturn($file); + Functions\when('get_option')->alias(static fn (string $name) => $options[$name] ?? false); + Functions\when('get_theme_mod')->justReturn($customLogo); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 13); + + /** @var MockInterface&\wpdb $db */ + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('get_results')->andReturnUsing(function (string $sql) use ($contents, $featured, $fields): array { + $this->queries[] = $sql; + if (str_contains($sql, "_thumbnail_id")) { + return $featured; + } + if (str_contains($sql, 'pm.meta_key IN')) { + return $fields; + } + if (str_contains($sql, 'post_content LIKE')) { + return array_map( + static fn (int $id): object => (object) ['ID' => $id, 'post_title' => 'Seite ' . $id, 'post_type' => 'page'], + array_keys($contents) + ); + } + if (str_contains($sql, 'p.ID IN')) { + $rows = []; + foreach ($contents as $id => $content) { + if (preg_match('/\b' . $id . '\b/', substr($sql, (int) strpos($sql, 'p.ID IN')))) { + $rows[] = (object) ['ID' => $id, 'post_content' => $content]; + } + } + + return $rows; + } + + return []; + }); + + return new MediaUsageFinder(static fn (): array => $mediaKeys, $db); + } + + /** @return array post_id => certainty */ + private static function certainties(array $usages): array + { + $result = []; + foreach ($usages as $usage) { + $result[$usage['post_id']] = $usage['certainty']; + } + + return $result; + } + + public function testCombinesFeaturedFieldContentAndSiteUsages(): void + { + $usages = $this->finder( + [5 => '', 9 => ''], + [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']], + [(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']], + ['site_icon' => '33'], + 33 + )->usages(33); + + self::assertSame( + [ + ['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher'], + ['post_id' => 12, 'title' => 'Anna', 'post_type' => 'lit_person', 'where' => 'field', 'certainty' => 'sicher'], + ['post_id' => 0, 'title' => 'Website-Icon', 'post_type' => 'option', 'where' => 'site_icon', 'certainty' => 'sicher'], + ['post_id' => 0, 'title' => 'Logo (Customizer)', 'post_type' => 'theme_mod', 'where' => 'custom_logo', 'certainty' => 'sicher'], + ['post_id' => 9, 'title' => 'Seite 9', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'], + ], + $usages + ); + } + + public function testQueriesArePreparedAndBounded(): void + { + $this->finder([5 => 'x'])->usages(33, 7); + + [$featured, $fields, $candidates] = $this->queries; + self::assertStringContainsString("pm.meta_value = '33'", $featured); + self::assertStringContainsString('LIMIT 7', $featured); + self::assertStringContainsString("pm.meta_key IN ('photo')", $fields); + self::assertStringContainsString('LIMIT 7', $fields); + foreach (['%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"mediaId":33%', '%ids=%33%', '%"ids":[%33%', '%galleryIds":%33%', '%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%'] as $like) { + self::assertStringContainsString("'" . $like . "'", $candidates, $like); + } + self::assertStringContainsString('LIMIT 200', $candidates); + self::assertStringNotContainsString('post_content,', $candidates, 'candidates must not load content'); + } + + public function testCertainContentReferencesByAttachmentId(): void + { + $contents = [ + 1 => '[gallery ids="4,33,7"]', + 2 => '[et_pb_gallery gallery_ids="33,8"][/et_pb_gallery]', + 3 => '
', + 4 => '', + 5 => '
', + 6 => '
', + 7 => '', + 8 => '', + 9 => '', + ]; + + $usages = $this->finder($contents, [], [], [], 0, [])->usages(33); + + self::assertSame(array_fill_keys(range(1, 9), 'sicher'), self::certainties($usages)); + } + + public function testFileUrlMatchesAreOnlyPossibleUsages(): void + { + $contents = [ + 1 => '', + 2 => '[et_pb_image src="https://k.de/wp-content/uploads/2026/09/foto-scaled.jpg"]', + 3 => '', + ]; + + self::assertSame( + [1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'], + self::certainties($this->finder($contents, [], [], [], 0, [])->usages(33)) + ); + } + + public function testLookalikesAreNoUsage(): void + { + $contents = [ + 1 => '', + 2 => ' ', + 3 => '[gallery ids="330,1334"] ', + 4 => ' /2025/09/foto.jpg', + ]; + + self::assertSame([], $this->finder($contents, [], [], [], 0, [])->usages(33)); + } + + public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void + { + $usages = $this->finder( + [13 => ''], + [], + [], + ['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']], + 0, + [] + )->usages(33); + + self::assertSame( + [ + ['post_id' => 0, 'title' => 'Divi-Logo', 'post_type' => 'option', 'where' => 'divi_logo', 'certainty' => 'möglicherweise'], + ['post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'], + ], + $usages + ); + } + + public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void + { + $usages = $this->finder([5 => '/2026/09/foto.jpg '], [], [], [], 0, [])->usages(33); + + self::assertSame([5 => 'sicher'], self::certainties($usages)); + } + + public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void + { + self::assertFalse($this->finder([], [], [], [], 0, [])->isUsed(33)); + self::assertCount(2, $this->queries); + } + + public function testIsUsedStopsBeforeTheContentScanWhenAlreadyUsed(): void + { + $finder = $this->finder([5 => 'wp-image-33'], [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]); + + self::assertTrue($finder->isUsed(33)); + self::assertCount(1, $this->queries); + } + + public function testWithoutAttachedFileNoFilePatterns(): void + { + $this->finder([], [], [], [], 0, [], '')->usages(33); + + self::assertStringNotContainsString('foto', $this->queries[1]); + self::assertStringNotContainsString("'%/%'", $this->queries[1]); + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php index 3cd768c..00fd25f 100644 --- a/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php +++ b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php @@ -94,4 +94,23 @@ final class ContentSchemaTest extends TestCase self::assertSame('footnotes', $withFields['fields'][0]['key']); self::assertNull($schema->describe('et_pb_layout', false)); } + + public function testMediaFieldKeysCoverFileFieldsAndMediaRelations(): void + { + Functions\when('get_post_types')->justReturn(['page' => 'page', 'lit_person' => 'lit_person']); + $source = Mockery::mock(FieldSource::class); + $source->allows('id')->andReturn('pods'); + $source->allows('fieldsFor')->andReturnUsing(static fn (string $type): array => $type === 'lit_person' + ? [ + new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable'), + new FieldDefinition('logo', 'Logo', 'relation', 'pods', 'writable', false, false, null, [ + 'object_type' => 'media', + 'name' => null, + ]), + new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'), + ] + : [new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable')]); + + self::assertSame(['logo', 'photo'], (new ContentSchema([$source]))->mediaFieldKeys()); + } } From b197d8171f352a3d76c92db224e527a06af86074 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:02:21 +0200 Subject: [PATCH 059/185] fix: media usage search never reports a capped scan as unused Adds widgets and header/background theme mods, integer galleryIds, wider pre-filter, regex failures as possible usages, newest-first candidates, and usages_complete/usages_truncated in media-get. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Media/MediaGetAbility.php | 16 +- src/Integrations/Media/MediaUsageFinder.php | 275 ++++++++++++++---- .../Media/MediaGetAbilityTest.php | 12 +- .../Media/MediaUsageFinderTest.php | 182 +++++++++--- 4 files changed, 382 insertions(+), 103 deletions(-) diff --git a/src/Integrations/Media/MediaGetAbility.php b/src/Integrations/Media/MediaGetAbility.php index 2bf3c12..2bbd10a 100644 --- a/src/Integrations/Media/MediaGetAbility.php +++ b/src/Integrations/Media/MediaGetAbility.php @@ -29,6 +29,8 @@ final class MediaGetAbility implements Ability . 'Bildunterschrift, Beschreibung und die Verwendungsorte (Beitragsbild, Inhalte inkl. Divi, ' . 'Bildfelder, Website-Icon, Logo). certainty "sicher" = per ID eingebunden, "möglicherweise" = nur ' . 'die Datei-URL gefunden. Einträge ohne Leserecht erscheinen als "(nicht lesbar)". ' + . 'usages_truncated: true = es gibt mehr Verwendungen als gelistet; usages_complete: false = die ' + . 'Inhaltssuche wurde begrenzt, weitere Verwendungen sind möglich. ' . 'Vorschaubild über lit-media-preview.'; } @@ -44,7 +46,12 @@ final class MediaGetAbility implements Ability public function outputSchema(): ?array { - return MediaPresenter::fullSchema(); + $schema = MediaPresenter::fullSchema(); + $schema['properties']['usages_complete'] = ['type' => 'boolean']; + $schema['properties']['usages_truncated'] = ['type' => 'boolean']; + $schema['required'] = array_merge($schema['required'], ['usages_complete', 'usages_truncated']); + + return $schema; } public function annotations(): array @@ -71,6 +78,11 @@ final class MediaGetAbility implements Ability return $attachment; } - return $this->presenter->full($attachment, $this->finder->usages((int) $attachment->ID)); + $scan = $this->finder->scan((int) $attachment->ID); + + return array_merge($this->presenter->full($attachment, $scan['usages']), [ + 'usages_complete' => $scan['complete'], + 'usages_truncated' => $scan['truncated'], + ]); } } diff --git a/src/Integrations/Media/MediaUsageFinder.php b/src/Integrations/Media/MediaUsageFinder.php index ccf3ff4..1042351 100644 --- a/src/Integrations/Media/MediaUsageFinder.php +++ b/src/Integrations/Media/MediaUsageFinder.php @@ -11,14 +11,21 @@ use Closure; * a false positive only asks for force: true, a false negative would break a page. * * - certain ("sicher"): the attachment ID is referenced – featured image, file/image fields - * (ContentSchema::mediaFieldKeys()), site icon, custom logo, and in content: wp-image- - * classes, [gallery ids="…"], Divi 4 gallery_ids="…", core media blocks ("id", "mediaId", - * "ids") and Divi 5 block attributes ("id", "galleryIds"). + * (ContentSchema::mediaFieldKeys()), site icon, custom logo, header image, media widgets + * (image/video/audio attachment_id, gallery ids), and in content and block widgets: + * wp-image- classes, [gallery ids="…"], Divi 4 gallery_ids="…", core media blocks + * ("id", "mediaId", "ids") and Divi 5 block attributes ("id", "galleryIds"). * - possible ("möglicherweise"): only the file URL (original or a generated size) appears – - * in content, Divi 4/5 image URLs or the Divi logo; it may belong to another copy of the file. + * in content, block widgets, Divi 4/5 image URLs, the Divi logo or the background image; it + * may belong to another copy of the file. Also possible: content that could not be checked + * (regex limit) and an incomplete content search (candidate cap reached). + * + * NOT covered: term meta, user meta, Pods table storage (pods_* tables, podsrel), ACF fields + * (unless registered as schema media fields and stored as a plain ID per meta row), serialized + * or comma-separated ID lists in meta, and options of other plugins. * * All queries are prepared and bounded; content is loaded in small batches and only until - * $max usages are found. Titles of entries the current user cannot read are hidden. + * enough usages are found. Titles of entries the current user cannot read are hidden. * Not final so abilities can be tested with a mock. */ class MediaUsageFinder @@ -26,7 +33,11 @@ class MediaUsageFinder public const CERTAIN = 'sicher'; public const POSSIBLE = 'möglicherweise'; public const UNREADABLE_TITLE = '(nicht lesbar)'; - public const WHERE = ['featured', 'field', 'content', 'site_icon', 'custom_logo', 'divi_logo']; + public const INCOMPLETE_TITLE = 'Inhaltssuche unvollständig – weitere Verwendungen möglich'; + public const WHERE = [ + 'featured', 'field', 'content', 'site_icon', 'custom_logo', 'divi_logo', 'header_image', + 'background_image', 'widget', + ]; /** Max. entries whose content is inspected per call. */ private const CANDIDATE_LIMIT = 200; @@ -36,6 +47,9 @@ class MediaUsageFinder private const CORE_MEDIA_BLOCKS = ['image', 'gallery', 'cover', 'media-text', 'file', 'video', 'audio']; + /** Media widgets that store one attachment ID. */ + private const SINGLE_MEDIA_WIDGETS = ['media_image', 'media_video', 'media_audio']; + /** Block delimiter pattern of WordPress' block parser (WP_Block_Parser::next_token()). */ private const BLOCK_PATTERN = '/).)*+)?}\s+)?\/?-->/s'; @@ -54,34 +68,63 @@ class MediaUsageFinder /** * @return list - * post_id 0 for site-wide usages (site icon, logos). + * post_id 0 for site-wide usages (site icon, logos, widgets, incomplete search). */ public function usages(int $attachmentId, int $max = 20): array { - $wpdb = $this->db ?? $GLOBALS['wpdb']; + return $this->scan($attachmentId, $max)['usages']; + } + + /** + * usages() plus whether the content search covered every candidate (complete) and whether + * more than $max usages exist (truncated). + * + * @return array{usages: list, complete: bool, truncated: bool} + */ + public function scan(int $attachmentId, int $max = 20): array + { $max = max(1, $max); - $stem = $this->fileStem($attachmentId); - $fileRegex = $stem === '' ? null : self::fileRegex($stem); - $found = []; - $this->addRows($found, $this->featured($wpdb, $attachmentId, $max), 'featured'); - if (count($found) < $max) { - $this->addRows($found, $this->fields($wpdb, $attachmentId, $max), 'field'); - } - if (count($found) < $max) { - $this->addSiteUsages($found, $attachmentId, $fileRegex); - } - if (count($found) < $max) { - $this->addContentUsages($found, $wpdb, $attachmentId, $stem, $fileRegex, $max); - } - - return array_slice(array_values($found), 0, $max); + // One more than requested, to tell whether the list is truncated. + return $this->collect($attachmentId, $max, $max + 1); } /** Certain and possible usages both count – a possible one also blocks a delete without force. */ public function isUsed(int $attachmentId): bool { - return $this->usages($attachmentId, 1) !== []; + return $this->collect($attachmentId, 1, 1)['usages'] !== []; + } + + /** + * @param int $limit Stop searching once this many usages are found (>= $max). + * @return array{usages: list, complete: bool, truncated: bool} + */ + private function collect(int $attachmentId, int $max, int $limit): array + { + $wpdb = $this->db ?? $GLOBALS['wpdb']; + $stem = $this->fileStem($attachmentId); + $fileRegex = $stem === '' ? null : self::fileRegex($stem); + + $found = []; + $complete = true; + $this->addRows($found, $this->featured($wpdb, $attachmentId, $limit), 'featured'); + if (count($found) < $limit) { + $this->addRows($found, $this->fields($wpdb, $attachmentId, $limit), 'field'); + } + if (count($found) < $limit) { + $this->addSiteUsages($found, $attachmentId, $fileRegex); + } + if (count($found) < $limit) { + $complete = $this->addContentUsages($found, $wpdb, $attachmentId, $stem, $fileRegex, $limit); + } + + return [ + 'usages' => array_slice(array_values($found), 0, $max), + 'complete' => $complete, + 'truncated' => count($found) > $max, + ]; } /** @return array JSON Schema of one entry of usages(). */ @@ -101,7 +144,7 @@ class MediaUsageFinder } /** @return list */ - private function featured(\wpdb $wpdb, int $attachmentId, int $max): array + private function featured(\wpdb $wpdb, int $attachmentId, int $limit): array { $statuses = self::EXCLUDED_STATUSES; @@ -110,15 +153,15 @@ class MediaUsageFinder "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s - AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID LIMIT %d", + AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID DESC LIMIT %d", (string) $attachmentId, - $max + $limit )); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** @return list */ - private function fields(\wpdb $wpdb, int $attachmentId, int $max): array + private function fields(\wpdb $wpdb, int $attachmentId, int $limit): array { $keys = array_values(array_map('strval', ($this->mediaMetaKeys)())); if ($keys === []) { @@ -133,13 +176,15 @@ class MediaUsageFinder "SELECT DISTINCT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value = %s - AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID LIMIT %d", - ...array_merge($keys, [(string) $attachmentId, $max]) + AND p.post_status NOT IN ({$statuses}) ORDER BY p.ID DESC LIMIT %d", + ...array_merge($keys, [(string) $attachmentId, $limit]) )); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber } /** + * Options and theme mods: site icon, logos, header/background image, widgets. + * * @param array> $found */ private function addSiteUsages(array &$found, int $attachmentId, ?string $fileRegex): void @@ -157,52 +202,132 @@ class MediaUsageFinder ); } + $header = get_theme_mod('header_image_data'); + $header = is_object($header) ? get_object_vars($header) : $header; + if (is_array($header) && (int) ($header['attachment_id'] ?? 0) === $attachmentId) { + $found['site:header_image'] = self::siteUsage('Header-Bild', 'theme_mod', 'header_image', self::CERTAIN); + } + + $background = get_theme_mod('background_image'); + if (is_string($background) && $background !== '' && self::matches($fileRegex, $background) !== false) { + $found['site:background_image'] = self::siteUsage( + 'Hintergrundbild', + 'theme_mod', + 'background_image', + self::POSSIBLE + ); + } + $divi = get_option('et_divi'); $diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : ''; - if ($fileRegex !== null && $diviLogo !== '' && preg_match($fileRegex, $diviLogo) === 1) { + if ($diviLogo !== '' && self::matches($fileRegex, $diviLogo) !== false) { $found['site:divi_logo'] = self::siteUsage('Divi-Logo', 'option', 'divi_logo', self::POSSIBLE); } + + $this->addWidgetUsages($found, $attachmentId, $fileRegex); } /** * @param array> $found */ + private function addWidgetUsages(array &$found, int $attachmentId, ?string $fileRegex): void + { + $widgets = []; + foreach (self::SINGLE_MEDIA_WIDGETS as $base) { + foreach (self::widgetInstances($base) as $number => $instance) { + if ((int) ($instance['attachment_id'] ?? 0) === $attachmentId) { + $widgets[$base . '-' . $number] = self::CERTAIN; + } + } + } + + foreach (self::widgetInstances('media_gallery') as $number => $instance) { + if (self::inList(self::stringLeaves($instance['ids'] ?? []), $attachmentId)) { + $widgets['media_gallery-' . $number] = self::CERTAIN; + } + } + + foreach (self::widgetInstances('block') as $number => $instance) { + $content = is_string($instance['content'] ?? null) ? $instance['content'] : ''; + $certainty = $content === '' ? null : self::contentCertainty($content, $attachmentId, $fileRegex); + if ($certainty !== null) { + $widgets['block-' . $number] = $certainty; + } + } + + foreach ($widgets as $widgetId => $certainty) { + $found['widget:' . $widgetId] = self::siteUsage('Widget ' . $widgetId, 'widget', 'widget', $certainty); + } + } + + /** @return array> Widget number => instance settings. */ + private static function widgetInstances(string $base): array + { + $option = get_option('widget_' . $base); + if (! is_array($option)) { + return []; + } + + unset($option['_multiwidget']); + + return array_filter($option, 'is_array'); + } + + /** + * @param array> $found + * @return bool False when the candidate cap cut the content search short. + */ private function addContentUsages( array &$found, \wpdb $wpdb, int $attachmentId, string $stem, ?string $fileRegex, - int $max - ): void { - foreach (array_chunk($this->contentCandidates($wpdb, $attachmentId, $stem), self::CONTENT_BATCH) as $chunk) { + int $limit + ): bool { + $candidates = $this->contentCandidates($wpdb, $attachmentId, $stem); + foreach (array_chunk($candidates, self::CONTENT_BATCH) as $chunk) { $contents = $this->contents($wpdb, array_map(static fn (object $row): int => (int) $row->ID, $chunk)); foreach ($chunk as $row) { $certainty = self::contentCertainty($contents[(int) $row->ID] ?? '', $attachmentId, $fileRegex); if ($certainty !== null) { $this->add($found, $row, 'content', $certainty); } - if (count($found) >= $max) { - return; + if (count($found) >= $limit) { + return true; } } } + + if (count($candidates) < self::CANDIDATE_LIMIT) { + return true; + } + + // Older entries beyond the cap were not checked: never report "unused" in that case. + $found['content:incomplete'] = self::siteUsage(self::INCOMPLETE_TITLE, 'content', 'content', self::POSSIBLE); + + return false; } /** - * Coarse pre-filter in SQL (a superset of what contentCertainty() accepts); IDs and titles only. + * Coarse pre-filter in SQL (a superset of what contentCertainty() accepts); IDs and titles + * only, newest first. * * @return list */ private function contentCandidates(\wpdb $wpdb, int $attachmentId, string $stem): array { $id = (string) $attachmentId; - $likes = array_map( - static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%', - ['wp-image-' . $id, '"id":' . $id, '"id":"' . $id . '"', '"mediaId":' . $id] - ); + $patterns = ['wp-image-' . $id]; + foreach (['"id":', '"mediaId":'] as $key) { + foreach (['', ' '] as $space) { + $patterns[] = $key . $space . $id; + $patterns[] = $key . $space . '"' . $id . '"'; + } + } + $likes = array_map(static fn (string $pattern): string => '%' . $wpdb->esc_like($pattern) . '%', $patterns); // ID lists: [gallery ids="…"], gallery_ids="…", "ids":[…], Divi 5 "galleryIds":{…}. - foreach (['ids=', '"ids":[', 'galleryIds":'] as $prefix) { + foreach (['ids=', '"ids":', 'galleryIds":'] as $prefix) { $likes[] = '%' . $wpdb->esc_like($prefix) . '%' . $id . '%'; } if ($stem !== '') { @@ -219,7 +344,7 @@ class MediaUsageFinder "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item') AND p.post_status NOT IN ({$statuses}) - AND ({$conditions}) ORDER BY p.ID LIMIT %d", + AND ({$conditions}) ORDER BY p.ID DESC LIMIT %d", ...array_merge($likes, [self::CANDIDATE_LIMIT]) )); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -248,26 +373,36 @@ class MediaUsageFinder return $contents; } + /** Content a regex could not check (PCRE limit) counts as a possible usage. */ private static function contentCertainty(string $content, int $attachmentId, ?string $fileRegex): ?string { - if (self::referencesId($content, $attachmentId)) { - return self::CERTAIN; + $references = self::referencesId($content, $attachmentId); + if ($references !== false) { + return $references === true ? self::CERTAIN : self::POSSIBLE; } - return $fileRegex !== null && preg_match($fileRegex, $content) === 1 ? self::POSSIBLE : null; + return self::matches($fileRegex, $content) === false ? null : self::POSSIBLE; } - private static function referencesId(string $content, int $attachmentId): bool + /** @return bool|null Null when a regex failed (e.g. backtrack limit) – undecided. */ + private static function referencesId(string $content, int $attachmentId): ?bool { - if (preg_match('/\bwp-image-' . $attachmentId . '\b/', $content) === 1) { + $wpImage = self::matches('/\bwp-image-' . $attachmentId . '\b/', $content); + if ($wpImage !== false) { + return $wpImage; + } + + $listCount = preg_match_all(self::ID_LIST_PATTERN, $content, $lists); + if ($listCount === false) { + return null; + } + if ($listCount > 0 && self::inList($lists[1], $attachmentId)) { return true; } - if (preg_match_all(self::ID_LIST_PATTERN, $content, $lists) > 0 && self::inList($lists[1], $attachmentId)) { - return true; + if (preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER) === false) { + return null; } - - preg_match_all(self::BLOCK_PATTERN, $content, $blocks, PREG_SET_ORDER); foreach ($blocks as $block) { $attrs = json_decode(trim((string) ($block['attrs'] ?? '')), true); if (! is_array($attrs)) { @@ -288,6 +423,21 @@ class MediaUsageFinder return false; } + /** + * preg_match() as ?bool: null when the regex failed (backtrack/recursion limit), false + * without a pattern. + */ + private static function matches(?string $pattern, string $subject): ?bool + { + if ($pattern === null) { + return false; + } + + $result = preg_match($pattern, $subject); + + return $result === false ? null : $result === 1; + } + /** @param array $attrs */ private static function coreBlockReferences(array $attrs, int $attachmentId): bool { @@ -303,7 +453,8 @@ class MediaUsageFinder } /** - * Divi 5 image modules store {"src": url, "id": ""}, galleries "galleryIds" as "1,2,3". + * Divi 5 image modules store {"src": url, "id": ""}, galleries "galleryIds" as "1,2,3" + * or as an array of IDs. * * @param array $attrs */ @@ -326,18 +477,18 @@ class MediaUsageFinder return false; } - /** @return list */ + /** @return list String and integer leaves, integers as strings. */ private static function stringLeaves(mixed $value): array { - if (is_string($value)) { - return [$value]; + if (is_string($value) || is_int($value)) { + return [(string) $value]; } $leaves = []; if (is_array($value)) { array_walk_recursive($value, static function ($leaf) use (&$leaves): void { - if (is_string($leaf)) { - $leaves[] = $leaf; + if (is_string($leaf) || is_int($leaf)) { + $leaves[] = (string) $leaf; } }); } @@ -407,10 +558,14 @@ class MediaUsageFinder return; } + $postType = (string) $row->post_type; + // read_post on an unregistered post type triggers _doing_it_wrong – treat as unreadable. + $readable = post_type_exists($postType) && current_user_can('read_post', $postId); + $found[$key] = [ 'post_id' => $postId, - 'title' => current_user_can('read_post', $postId) ? (string) $row->post_title : self::UNREADABLE_TITLE, - 'post_type' => (string) $row->post_type, + 'title' => $readable ? (string) $row->post_title : self::UNREADABLE_TITLE, + 'post_type' => $postType, 'where' => $where, 'certainty' => $certainty, ]; diff --git a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php index 0710046..91cfa1b 100644 --- a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php @@ -20,11 +20,15 @@ final class MediaGetAbilityTest extends TestCase Functions\when('get_post')->justReturn($attachment); Functions\when('current_user_can')->justReturn(true); $finder = Mockery::mock(MediaUsageFinder::class); - $finder->expects('usages')->with(33)->andReturn([]); + $usages = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher']]; + $finder->expects('scan')->with(33)->andReturn(['usages' => $usages, 'complete' => false, 'truncated' => true]); $presenter = Mockery::mock(MediaPresenter::class); - $presenter->expects('full')->with($attachment, [])->andReturn(['id' => 33]); + $presenter->expects('full')->with($attachment, $usages)->andReturn(['id' => 33, 'usages' => $usages]); - self::assertSame(['id' => 33], (new MediaGetAbility($presenter, $finder))->execute(['id' => 33])); + self::assertSame( + ['id' => 33, 'usages' => $usages, 'usages_complete' => false, 'usages_truncated' => true], + (new MediaGetAbility($presenter, $finder))->execute(['id' => 33]) + ); } public function testTrashedAttachmentsCanBeRead(): void @@ -51,5 +55,7 @@ final class MediaGetAbilityTest extends TestCase self::assertSame('lit/media-get', $ability->name()); self::assertFalse($ability->isDirect()); self::assertTrue($ability->annotations()['readonly']); + self::assertContains('usages_complete', $ability->outputSchema()['required']); + self::assertContains('usages_truncated', $ability->outputSchema()['required']); } } diff --git a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php index 763a340..979b388 100644 --- a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php +++ b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php @@ -16,30 +16,32 @@ final class MediaUsageFinderTest extends TestCase private array $queries = []; /** - * @param array $options - * @param array $contents Candidate post ID => post_content. - * @param list $featured - * @param list $fields + * @param array $contents Candidate post ID => post_content. + * @param list $featured + * @param list $fields + * @param array $options Option name => value. + * @param array $themeMods Theme mod name => value. */ private function finder( array $contents = [], array $featured = [], array $fields = [], array $options = [], - int $customLogo = 0, - array $mediaKeys = ['photo'], + array $themeMods = [], + array $mediaKeys = [], string $file = '2026/09/foto-scaled.jpg' ): MediaUsageFinder { Functions\when('get_post_meta')->justReturn($file); Functions\when('get_option')->alias(static fn (string $name) => $options[$name] ?? false); - Functions\when('get_theme_mod')->justReturn($customLogo); + Functions\when('get_theme_mod')->alias(static fn (string $name) => $themeMods[$name] ?? false); + Functions\when('post_type_exists')->alias(static fn (string $type): bool => $type !== 'ghost'); Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 13); /** @var MockInterface&\wpdb $db */ $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('get_results')->andReturnUsing(function (string $sql) use ($contents, $featured, $fields): array { $this->queries[] = $sql; - if (str_contains($sql, "_thumbnail_id")) { + if (str_contains($sql, '_thumbnail_id')) { return $featured; } if (str_contains($sql, 'pm.meta_key IN')) { @@ -52,10 +54,13 @@ final class MediaUsageFinderTest extends TestCase ); } if (str_contains($sql, 'p.ID IN')) { + // No regex here: the PCRE limit test runs this with a backtrack limit of 1. + $list = substr($sql, (int) strpos($sql, 'p.ID IN (') + strlen('p.ID IN (')); + $ids = array_map('intval', explode(',', substr($list, 0, (int) strpos($list, ')')))); $rows = []; - foreach ($contents as $id => $content) { - if (preg_match('/\b' . $id . '\b/', substr($sql, (int) strpos($sql, 'p.ID IN')))) { - $rows[] = (object) ['ID' => $id, 'post_content' => $content]; + foreach ($ids as $id) { + if (isset($contents[$id])) { + $rows[] = (object) ['ID' => $id, 'post_content' => $contents[$id]]; } } @@ -68,25 +73,31 @@ final class MediaUsageFinderTest extends TestCase return new MediaUsageFinder(static fn (): array => $mediaKeys, $db); } - /** @return array post_id => certainty */ + /** @return array post_id (or title for site usages) => certainty */ private static function certainties(array $usages): array { $result = []; foreach ($usages as $usage) { - $result[$usage['post_id']] = $usage['certainty']; + $result[$usage['post_id'] === 0 ? $usage['title'] : $usage['post_id']] = $usage['certainty']; } return $result; } - public function testCombinesFeaturedFieldContentAndSiteUsages(): void + private static function post(int $id, string $title, string $type = 'page'): object + { + return (object) ['ID' => $id, 'post_title' => $title, 'post_type' => $type]; + } + + public function testCombinesFeaturedFieldSiteAndContentUsages(): void { $usages = $this->finder( [5 => '', 9 => ''], - [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']], - [(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']], + [self::post(5, 'Team')], + [self::post(12, 'Anna', 'lit_person')], ['site_icon' => '33'], - 33 + ['custom_logo' => 33], + ['photo'] )->usages(33); self::assertSame( @@ -101,19 +112,24 @@ final class MediaUsageFinderTest extends TestCase ); } - public function testQueriesArePreparedAndBounded(): void + public function testQueriesArePreparedBoundedAndNewestFirst(): void { - $this->finder([5 => 'x'])->usages(33, 7); + $this->finder([5 => 'x'], [], [], [], [], ['photo'])->usages(33, 7); [$featured, $fields, $candidates] = $this->queries; self::assertStringContainsString("pm.meta_value = '33'", $featured); - self::assertStringContainsString('LIMIT 7', $featured); + self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 8', $featured); self::assertStringContainsString("pm.meta_key IN ('photo')", $fields); - self::assertStringContainsString('LIMIT 7', $fields); - foreach (['%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"mediaId":33%', '%ids=%33%', '%"ids":[%33%', '%galleryIds":%33%', '%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%'] as $like) { + self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 8', $fields); + $likes = [ + '%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"id": 33%', '%"id": "33"%', '%"mediaId":33%', + '%"mediaId":"33"%', '%"mediaId": 33%', '%ids=%33%', '%"ids":%33%', '%galleryIds":%33%', + '%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%', + ]; + foreach ($likes as $like) { self::assertStringContainsString("'" . $like . "'", $candidates, $like); } - self::assertStringContainsString('LIMIT 200', $candidates); + self::assertStringContainsString('ORDER BY p.ID DESC LIMIT 200', $candidates); self::assertStringNotContainsString('post_content,', $candidates, 'candidates must not load content'); } @@ -124,16 +140,17 @@ final class MediaUsageFinderTest extends TestCase 2 => '[et_pb_gallery gallery_ids="33,8"][/et_pb_gallery]', 3 => '
', 4 => '', - 5 => '
', - 6 => '
', + 5 => '
', + 6 => '
', 7 => '', 8 => '', 9 => '', + 10 => '', ]; - $usages = $this->finder($contents, [], [], [], 0, [])->usages(33); + $usages = $this->finder($contents)->usages(33); - self::assertSame(array_fill_keys(range(1, 9), 'sicher'), self::certainties($usages)); + self::assertSame(array_fill_keys(range(1, 10), 'sicher'), self::certainties($usages)); } public function testFileUrlMatchesAreOnlyPossibleUsages(): void @@ -146,7 +163,7 @@ final class MediaUsageFinderTest extends TestCase self::assertSame( [1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'], - self::certainties($this->finder($contents, [], [], [], 0, [])->usages(33)) + self::certainties($this->finder($contents)->usages(33)) ); } @@ -157,24 +174,113 @@ final class MediaUsageFinderTest extends TestCase 2 => ' ', 3 => '[gallery ids="330,1334"] ', 4 => ' /2025/09/foto.jpg', + 5 => '', ]; - self::assertSame([], $this->finder($contents, [], [], [], 0, [])->usages(33)); + $finder = $this->finder($contents); + + self::assertSame([], $finder->usages(33)); + self::assertFalse($finder->isUsed(33)); + } + + public function testCappedContentSearchIsNeverReportedAsUnused(): void + { + $finder = $this->finder(array_fill_keys(range(1, 200), 'nichts')); + + self::assertTrue($finder->isUsed(33)); + + $scan = $finder->scan(33); + self::assertFalse($scan['complete']); + self::assertFalse($scan['truncated']); + self::assertSame( + [[ + 'post_id' => 0, + 'title' => 'Inhaltssuche unvollständig – weitere Verwendungen möglich', + 'post_type' => 'content', + 'where' => 'content', + 'certainty' => 'möglicherweise', + ]], + $scan['usages'] + ); + } + + public function testScanReportsTruncationBeyondMax(): void + { + $finder = $this->finder([], [self::post(1, 'A'), self::post(2, 'B'), self::post(3, 'C')]); + + $scan = $finder->scan(33, 2); + + self::assertCount(2, $scan['usages']); + self::assertTrue($scan['truncated']); + self::assertTrue($scan['complete']); + self::assertFalse($finder->scan(33, 3)['truncated']); + } + + public function testRegexFailureCountsAsPossibleUsage(): void + { + $jit = ini_get('pcre.jit'); + $backtrack = ini_get('pcre.backtrack_limit'); + $finder = $this->finder([7 => str_repeat('wp-image-3 ', 200)]); + ini_set('pcre.jit', '0'); + ini_set('pcre.backtrack_limit', '1'); + try { + $usages = $finder->usages(33); + } finally { + ini_set('pcre.jit', (string) $jit); + ini_set('pcre.backtrack_limit', (string) $backtrack); + } + + self::assertSame([7 => 'möglicherweise'], self::certainties($usages)); + } + + public function testWidgetsAndThemeMods(): void + { + $usages = $this->finder( + [], + [], + [], + [ + 'widget_media_image' => [3 => ['attachment_id' => 33, 'url' => 'x'], 4 => ['attachment_id' => 330], '_multiwidget' => 1], + 'widget_media_gallery' => [2 => ['ids' => [5, 33]], 3 => ['ids' => [330]], '_multiwidget' => 1], + 'widget_block' => [ + 4 => ['content' => '
'], + 5 => ['content' => ''], + 6 => ['content' => '

nichts

'], + '_multiwidget' => 1, + ], + ], + [ + 'header_image_data' => (object) ['attachment_id' => 33, 'url' => 'x'], + 'background_image' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg', + ] + )->usages(33); + + self::assertSame( + [ + 'Header-Bild' => 'sicher', + 'Hintergrundbild' => 'möglicherweise', + 'Widget media_image-3' => 'sicher', + 'Widget media_gallery-2' => 'sicher', + 'Widget block-4' => 'sicher', + 'Widget block-5' => 'möglicherweise', + ], + self::certainties($usages) + ); + self::assertSame(['header_image', 'background_image', 'widget', 'widget', 'widget', 'widget'], array_column($usages, 'where')); } public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void { $usages = $this->finder( [13 => ''], + [self::post(20, 'Geist', 'ghost')], [], - [], - ['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']], - 0, - [] + ['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']] )->usages(33); self::assertSame( [ + ['post_id' => 20, 'title' => '(nicht lesbar)', 'post_type' => 'ghost', 'where' => 'featured', 'certainty' => 'sicher'], ['post_id' => 0, 'title' => 'Divi-Logo', 'post_type' => 'option', 'where' => 'divi_logo', 'certainty' => 'möglicherweise'], ['post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'], ], @@ -184,20 +290,20 @@ final class MediaUsageFinderTest extends TestCase public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void { - $usages = $this->finder([5 => '/2026/09/foto.jpg '], [], [], [], 0, [])->usages(33); + $usages = $this->finder([5 => '/2026/09/foto.jpg '])->usages(33); self::assertSame([5 => 'sicher'], self::certainties($usages)); } public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void { - self::assertFalse($this->finder([], [], [], [], 0, [])->isUsed(33)); + self::assertFalse($this->finder()->isUsed(33)); self::assertCount(2, $this->queries); } - public function testIsUsedStopsBeforeTheContentScanWhenAlreadyUsed(): void + public function testIsUsedStopsAfterTheFirstHit(): void { - $finder = $this->finder([5 => 'wp-image-33'], [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]); + $finder = $this->finder([5 => 'wp-image-33'], [self::post(5, 'Team')], [], [], [], ['photo']); self::assertTrue($finder->isUsed(33)); self::assertCount(1, $this->queries); @@ -205,7 +311,7 @@ final class MediaUsageFinderTest extends TestCase public function testWithoutAttachedFileNoFilePatterns(): void { - $this->finder([], [], [], [], 0, [], '')->usages(33); + $this->finder([], [], [], [], [], [], '')->usages(33); self::assertStringNotContainsString('foto', $this->queries[1]); self::assertStringNotContainsString("'%/%'", $this->queries[1]); From 5099c9fc894e3fbc3bdc4613b8aec0593a384e14 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:11:30 +0200 Subject: [PATCH 060/185] feat: media-search with missing_alt and unused filters Ruling Q8 caps the unused scan at 20 candidates per call (not the brief's 500/50), and each result (search and unused alike) is filtered to attachments the current user may edit; overview.md lists media-search as a direct tool (ruling Q2). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 2 +- src/Integrations/Media/MediaIntegration.php | 1 + src/Integrations/Media/MediaSearchAbility.php | 236 ++++++++++++++++++ .../Media/MediaIntegrationTest.php | 1 + .../Media/MediaSearchAbilityTest.php | 128 ++++++++++ 5 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Media/MediaSearchAbility.php create mode 100644 tests/Unit/Integrations/Media/MediaSearchAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index de7fe76..7e421c8 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -18,7 +18,7 @@ des verbundenen WordPress-Benutzers. ## Werkzeuge Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, -`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`. +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`. Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Hinweisen immer so geschrieben): diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php index 0632ea9..ed73209 100644 --- a/src/Integrations/Media/MediaIntegration.php +++ b/src/Integrations/Media/MediaIntegration.php @@ -35,6 +35,7 @@ final class MediaIntegration implements Integration $finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys()); return [ + new MediaSearchAbility($presenter, $finder), new MediaGetAbility($presenter, $finder), ]; } diff --git a/src/Integrations/Media/MediaSearchAbility.php b/src/Integrations/Media/MediaSearchAbility.php new file mode 100644 index 0000000..b71081f --- /dev/null +++ b/src/Integrations/Media/MediaSearchAbility.php @@ -0,0 +1,236 @@ + 'object', + 'properties' => array_merge([ + 'search' => ['type' => 'string', 'maxLength' => 200], + 'mime' => ['type' => 'string', 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'], + 'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], + 'missing_alt' => ['type' => 'boolean'], + 'unused' => ['type' => 'boolean'], + ], Cursor::inputProperties()), + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => ['type' => 'array', 'items' => MediaPresenter::summarySchema()], + 'next_cursor' => ['type' => ['string', 'null']], + 'warnings' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['items', 'next_cursor', 'warnings'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('upload_files') + ? true + : Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).'); + } + + public function execute(array $input): mixed + { + $offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null); + if ($offset === null) { + return Cursor::invalid(); + } + + $limit = Cursor::limit($input['limit'] ?? null); + $args = $this->queryArgs($input); + + $page = ($input['unused'] ?? false) === true + ? $this->unusedPage($args, $limit, $offset) + : $this->searchPage($args, $limit, $offset); + + return [ + 'items' => array_map( + fn (WP_Post $attachment): array => $this->presenter->summary($attachment), + $page['items'] + ), + 'next_cursor' => $page['next_cursor'], + 'warnings' => $page['warnings'], + ]; + } + + /** + * @param array $input + * @return array + */ + private function queryArgs(array $input): array + { + $args = [ + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + 'suppress_filters' => false, + ]; + + if (isset($input['search']) && $input['search'] !== '') { + $args['s'] = (string) $input['search']; + } + + if (isset($input['mime']) && $input['mime'] !== '') { + $args['post_mime_type'] = (string) $input['mime']; + } + + if (($input['missing_alt'] ?? false) === true) { + $args['post_mime_type'] ??= 'image'; + $args['meta_query'] = [ + 'relation' => 'OR', + ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], + ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], + ]; + } + + $date = []; + if (isset($input['date_after'])) { + $date['after'] = (string) $input['date_after']; + } + if (isset($input['date_before'])) { + $date['before'] = (string) $input['date_before']; + } + if ($date !== []) { + $args['date_query'] = [array_merge($date, ['inclusive' => true])]; + } + + return $args; + } + + /** + * @param array $args + * @return array{items: list, next_cursor: ?string, warnings: list} + */ + private function searchPage(array $args, int $limit, int $offset): array + { + $rows = get_posts(array_merge($args, ['posts_per_page' => $limit + 1, 'offset' => $offset])); + $page = Cursor::page(array_values($rows), $limit, $offset); + + return [ + 'items' => array_values(array_filter($page['items'], [self::class, 'editable'])), + 'next_cursor' => $page['next_cursor'], + 'warnings' => [], + ]; + } + + /** + * Fetches at most MAX_SCAN + 1 candidates (the extra one only tells us whether more exist, + * it is never passed to isUsed()) so at most MAX_SCAN expensive isUsed() checks happen here. + * + * @param array $args + * @return array{items: list, next_cursor: ?string, warnings: list} + */ + private function unusedPage(array $args, int $limit, int $offset): array + { + $candidates = array_values(get_posts(array_merge($args, [ + 'posts_per_page' => self::MAX_SCAN + 1, + 'offset' => $offset, + ]))); + + $items = []; + $position = $offset; + $examined = 0; + + foreach ($candidates as $attachment) { + if ($examined >= self::MAX_SCAN) { + break; + } + + $position++; + $examined++; + + if (self::editable($attachment) && ! $this->finder->isUsed((int) $attachment->ID)) { + $items[] = $attachment; + if (count($items) === $limit) { + return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []]; + } + } + } + + if (count($candidates) <= self::MAX_SCAN) { + return ['items' => $items, 'next_cursor' => null, 'warnings' => []]; + } + + return [ + 'items' => $items, + 'next_cursor' => Cursor::encode($position), + 'warnings' => [sprintf( + 'Suche nach unbenutzten Medien nach %d geprüften Medien unterbrochen; mit next_cursor fortsetzen.', + self::MAX_SCAN + )], + ]; + } + + /** Only attachments the current user may edit are ever listed; trashed ones never reach here. */ + private static function editable(WP_Post $attachment): bool + { + return current_user_can('edit_post', (int) $attachment->ID); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index eeb70b5..76a8cd4 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -16,6 +16,7 @@ final class MediaIntegrationTest extends TestCase { /** Grows with Tasks 2–5 – keep in the order of abilities(). */ private const EXPECTED = [ + 'lit/media-search', 'lit/media-get', ]; diff --git a/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php new file mode 100644 index 0000000..ee667b4 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php @@ -0,0 +1,128 @@ +allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]); + + return $presenter; + } + + /** @return list */ + private function attachments(int ...$ids): array + { + return array_map(static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'attachment']), $ids); + } + + public function testMissingAltLimitsToImagesWithoutAltText(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool => + $args['post_type'] === 'attachment' + && $args['post_mime_type'] === 'image' + && $args['s'] === 'team' + && $args['meta_query'] === [ + 'relation' => 'OR', + ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], + ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], + ] + && $args['posts_per_page'] === 21 + && $args['offset'] === 0))->andReturn($this->attachments(1, 2)); + + $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['search' => 'team', 'missing_alt' => true]); + + self::assertSame([['id' => 1], ['id' => 2]], $result['items']); + self::assertNull($result['next_cursor']); + self::assertSame([], $result['warnings']); + } + + public function testExcludesAttachmentsTheUserCannotEdit(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2)); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 2); + + $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute([]); + + self::assertSame([['id' => 1]], $result['items']); + } + + public function testUnusedFiltersThroughTheUsageFinder(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); + Functions\when('current_user_can')->justReturn(true); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturnUsing(static fn (int $id): bool => $id === 2); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); + + self::assertSame([['id' => 1], ['id' => 3]], $result['items']); + self::assertNull($result['next_cursor']); + self::assertSame([], $result['warnings']); + } + + public function testUnusedStopsAtTheLimitWithACursor(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); + Functions\when('current_user_can')->justReturn(true); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturn(false); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]); + + self::assertSame([['id' => 1]], $result['items']); + self::assertSame(1, Cursor::decode($result['next_cursor'])); + } + + public function testUnusedSkipsAttachmentsTheUserCannotEdit(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 2); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturn(false); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); + + self::assertSame([['id' => 1], ['id' => 3]], $result['items']); + self::assertNull($result['next_cursor']); + } + + public function testUnusedWarnsAndPagesWhenTheScanCapIsReached(): void + { + Functions\when('get_posts')->justReturn($this->attachments(...range(1, 21))); + Functions\when('current_user_can')->justReturn(true); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturn(true); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); + + self::assertSame([], $result['items']); + self::assertSame(20, Cursor::decode($result['next_cursor'])); + self::assertNotSame([], $result['warnings']); + } + + public function testRequiresUploadRights(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)); + + self::assertSame('lit_forbidden', $ability->permission([])->get_error_code()); + self::assertTrue($ability->isDirect()); + } +} From b0da2cdeccf40f6d168d2619b792c5097b0ffbdd Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:19:13 +0200 Subject: [PATCH 061/185] fix: restrict media-search to the query, not just editable() after it I1: query the attachments themselves author-restricted when the user lacks edit_others_posts (PostGuard::ownPostsOnly, extracted from ContentQueryAbility so content-query reuses the exact same rule) instead of relying solely on a post-query editable() filter, which could leak the existence of other users' attachments via an empty page plus next_cursor. editable() stays as a backstop. Also: M1 document that only status=inherit attachments are listed; M2 tie-break ordering by ID (content-query and media-search) for stable pagination; M3 no next_cursor once the last candidate was examined; M4 reject an explicit non-image mime with missing_alt; M5 additional coverage (posts_per_page, cursor offset pass-through, invalid cursor, post_status/date/mime args); mime maxLength 100. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Media/MediaSearchAbility.php | 73 +++++++-- .../WordPress/Content/ContentQueryAbility.php | 11 +- src/Integrations/WordPress/PostGuard.php | 22 +++ .../Media/MediaSearchAbilityTest.php | 153 +++++++++++++++++- .../Content/ContentQueryAbilityTest.php | 3 +- .../Integrations/WordPress/PostGuardTest.php | 21 +++ 6 files changed, 262 insertions(+), 21 deletions(-) diff --git a/src/Integrations/Media/MediaSearchAbility.php b/src/Integrations/Media/MediaSearchAbility.php index b71081f..ce1c7f3 100644 --- a/src/Integrations/Media/MediaSearchAbility.php +++ b/src/Integrations/Media/MediaSearchAbility.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Media; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Cursor; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\WordPress\PostGuard; use WP_Error; use WP_Post; @@ -40,12 +41,13 @@ final class MediaSearchAbility implements Ability public function description(): string { return 'Sucht in der Mediathek nach Text (Titel, Dateiname, Beschreibung), MIME-Typ (z. B. image oder ' - . 'application/pdf) und Zeitraum. Nur Medien, die der Nutzer bearbeiten darf; Papierkorb ausgeschlossen. ' - . 'missing_alt: Bilder ohne Alt-Text. unused: Medien ohne gefundene Verwendung – "keine Verwendung ' - . 'gefunden" ist keine Garantie (Details je Medium über lit/media-get); als "möglicherweise verwendet" ' - . 'erkannte Medien zählen bereits als verwendet und werden nicht gelistet. Prüft dafür je Aufruf ' - . 'höchstens ' . self::MAX_SCAN . ' Medien; wird die Prüfung dadurch unterbrochen, folgt eine Warnung ' - . 'und mit next_cursor kann fortgesetzt werden.'; + . 'application/pdf) und Zeitraum. Nur Medien mit Status inherit (reguläre Mediathek-Einträge, kein ' + . 'Papierkorb), die der Nutzer bearbeiten darf. missing_alt: Bilder ohne Alt-Text (mime muss dabei leer ' + . 'oder ein Bild-Typ sein). unused: Medien ohne gefundene Verwendung – "keine Verwendung gefunden" ist ' + . 'keine Garantie (Details je Medium über lit/media-get); als "möglicherweise verwendet" erkannte ' + . 'Medien zählen bereits als verwendet und werden nicht gelistet. Prüft dafür je Aufruf höchstens ' + . self::MAX_SCAN . ' Medien; wird die Prüfung dadurch unterbrochen, folgt eine Warnung und mit ' + . 'next_cursor kann fortgesetzt werden.'; } public function inputSchema(): array @@ -54,7 +56,7 @@ final class MediaSearchAbility implements Ability 'type' => 'object', 'properties' => array_merge([ 'search' => ['type' => 'string', 'maxLength' => 200], - 'mime' => ['type' => 'string', 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'], + 'mime' => ['type' => 'string', 'maxLength' => 100, 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'], 'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], 'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN], 'missing_alt' => ['type' => 'boolean'], @@ -101,6 +103,11 @@ final class MediaSearchAbility implements Ability return Cursor::invalid(); } + $mimeError = $this->rejectNonImageMimeForMissingAlt($input); + if ($mimeError !== null) { + return $mimeError; + } + $limit = Cursor::limit($input['limit'] ?? null); $args = $this->queryArgs($input); @@ -118,6 +125,27 @@ final class MediaSearchAbility implements Ability ]; } + /** + * missing_alt only makes sense for images; an explicit non-image mime would otherwise + * be silently ignored (post_mime_type overwritten) and quietly return nothing. + * + * @param array $input + */ + private function rejectNonImageMimeForMissingAlt(array $input): ?WP_Error + { + $mime = isset($input['mime']) ? (string) $input['mime'] : ''; + $isImage = $mime === '' || $mime === 'image' || str_starts_with($mime, 'image/'); + if (($input['missing_alt'] ?? false) !== true || $isImage) { + return null; + } + + return Errors::invalidInput( + 'missing_alt gilt nur für Bilder.', + ['mime' => 'kein Bild-MIME-Typ'], + 'mime weglassen, auf "image" setzen oder einen "image/…"-Subtyp verwenden.' + ); + } + /** * @param array $input * @return array @@ -127,12 +155,22 @@ final class MediaSearchAbility implements Ability $args = [ 'post_type' => 'attachment', 'post_status' => 'inherit', - 'orderby' => 'date', - 'order' => 'DESC', + // A secondary ID tie-breaker keeps paging stable when several attachments share + // the exact same date (common with bulk uploads). + 'orderby' => ['date' => 'DESC', 'ID' => 'DESC'], 'no_found_rows' => true, 'suppress_filters' => false, ]; + // Without edit_others_posts, restrict the query itself to the user's own uploads – + // filtering with editable() afterwards is not enough on its own: an unrestricted + // query could otherwise come back empty (or short) with a next_cursor that quietly + // reveals other users' attachments exist without ever showing them. + $ownerId = PostGuard::ownPostsOnly('attachment'); + if ($ownerId !== null) { + $args['author'] = $ownerId; + } + if (isset($input['search']) && $input['search'] !== '') { $args['s'] = (string) $input['search']; } @@ -174,6 +212,9 @@ final class MediaSearchAbility implements Ability $page = Cursor::page(array_values($rows), $limit, $offset); return [ + // editable() stays as a backstop even though the query is already author-restricted + // above: e.g. a per-attachment cap denial (a filter, or the author losing rights + // after upload) must still drop the row instead of exposing it. 'items' => array_values(array_filter($page['items'], [self::class, 'editable'])), 'next_cursor' => $page['next_cursor'], 'warnings' => [], @@ -197,6 +238,7 @@ final class MediaSearchAbility implements Ability $items = []; $position = $offset; $examined = 0; + $reachedLimit = false; foreach ($candidates as $attachment) { if ($examined >= self::MAX_SCAN) { @@ -209,15 +251,22 @@ final class MediaSearchAbility implements Ability if (self::editable($attachment) && ! $this->finder->isUsed((int) $attachment->ID)) { $items[] = $attachment; if (count($items) === $limit) { - return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []]; + $reachedLimit = true; + break; } } } - if (count($candidates) <= self::MAX_SCAN) { + // The extra (MAX_SCAN + 1)th candidate, when present, was deliberately never examined – + // its mere presence is enough to know more candidates remain either way. + if ($examined >= count($candidates)) { return ['items' => $items, 'next_cursor' => null, 'warnings' => []]; } + if ($reachedLimit) { + return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []]; + } + return [ 'items' => $items, 'next_cursor' => Cursor::encode($position), @@ -228,7 +277,7 @@ final class MediaSearchAbility implements Ability ]; } - /** Only attachments the current user may edit are ever listed; trashed ones never reach here. */ + /** Backstop: editable() is checked even though the query is already author-restricted. */ private static function editable(WP_Post $attachment): bool { return current_user_can('edit_post', (int) $attachment->ID); diff --git a/src/Integrations/WordPress/Content/ContentQueryAbility.php b/src/Integrations/WordPress/Content/ContentQueryAbility.php index a99c284..464511e 100644 --- a/src/Integrations/WordPress/Content/ContentQueryAbility.php +++ b/src/Integrations/WordPress/Content/ContentQueryAbility.php @@ -176,8 +176,10 @@ final class ContentQueryAbility implements Ability // Without edit_others_posts, even a status the user may edit in principle must // stay limited to their own posts – otherwise the query would leak drafts etc. // of other authors that happen to pass the per-status gate below. - $restrictedToOwnPosts = ! current_user_can($this->capability($caps, 'edit_others_posts')); + $ownerId = PostGuard::ownPostsOnly($postType); + $restrictedToOwnPosts = $ownerId !== null; + $order = strtoupper((string) ($input['order'] ?? 'desc')); $args = [ 'post_type' => $postType, 'post_status' => $this->allowedStatuses( @@ -185,15 +187,16 @@ final class ContentQueryAbility implements Ability $caps, $restrictedToOwnPosts ), - 'orderby' => (string) ($input['orderby'] ?? 'date'), - 'order' => strtoupper((string) ($input['order'] ?? 'desc')), + // A secondary ID tie-breaker keeps paging stable when several entries share the + // exact same orderby value (e.g. bulk-imported posts with an identical date). + 'orderby' => [(string) ($input['orderby'] ?? 'date') => $order, 'ID' => $order], 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'suppress_filters' => false, ]; if ($restrictedToOwnPosts) { - $args['author'] = get_current_user_id(); + $args['author'] = $ownerId; } if (isset($input['search']) && $input['search'] !== '') { diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index 3aa842e..b711ad9 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -119,6 +119,28 @@ final class PostGuard return $object; } + /** + * Whether a list/search query for this post type must stay restricted to the current + * user's own entries because they lack edit_others_posts. Filtering per row after the + * query (e.g. current_user_can('edit_post', $id)) is not enough on its own: without this, + * a restricted user's query still runs unrestricted, so a page can come back empty (or + * short) with a next_cursor that quietly reveals other users' entries exist without ever + * showing them – the same defect ContentQueryAbility guards against for content-query. + * + * @return int|null The current user id when restricted, null when the query may run + * unrestricted (author filter must then be left out entirely). + */ + public static function ownPostsOnly(string $postType): ?int + { + $typeObject = get_post_type_object($postType); + $caps = is_object($typeObject) && isset($typeObject->cap) ? $typeObject->cap : null; + $capability = $caps !== null && isset($caps->edit_others_posts) + ? (string) $caps->edit_others_posts + : 'edit_others_posts'; + + return current_user_can($capability) ? null : get_current_user_id(); + } + public static function id(mixed $value): ?int { if (is_int($value)) { diff --git a/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php index ee667b4..13651f7 100644 --- a/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php @@ -29,9 +29,16 @@ final class MediaSearchAbilityTest extends TestCase return array_map(static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'attachment']), $ids); } + /** No edit_others_posts cap mapped: PostGuard::ownPostsOnly() falls back to the raw name. */ + private function unrestrictedPostType(): void + { + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]); + } + public function testMissingAltLimitsToImagesWithoutAltText(): void { Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool => $args['post_type'] === 'attachment' && $args['post_mime_type'] === 'image' @@ -52,10 +59,62 @@ final class MediaSearchAbilityTest extends TestCase self::assertSame([], $result['warnings']); } + public function testMissingAltRejectsNonImageMime(): void + { + Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); + + $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['missing_alt' => true, 'mime' => 'application/pdf']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testMissingAltAllowsAnImageSubtypeMime(): void + { + Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); + Functions\when('get_posts')->justReturn([]); + + $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['missing_alt' => true, 'mime' => 'image/png']); + + self::assertSame([], $result['items']); + } + + public function testQueryArgsCoverStatusDateAndMime(): void + { + Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); + Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool => + $args['post_status'] === 'inherit' + && $args['post_mime_type'] === 'application/pdf' + && $args['date_query'] === [['after' => '2026-01-01', 'before' => '2026-02-01', 'inclusive' => true]] + && $args['posts_per_page'] === 6 + && $args['offset'] === 3))->andReturn([]); + + (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([ + 'mime' => 'application/pdf', + 'date_after' => '2026-01-01', + 'date_before' => '2026-02-01', + 'limit' => 5, + 'cursor' => Cursor::encode(3), + ]); + } + + public function testInvalidCursorIsRejected(): void + { + $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['cursor' => '???']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testExcludesAttachmentsTheUserCannotEdit(): void { Functions\when('get_posts')->justReturn($this->attachments(1, 2)); - Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 2); + $this->unrestrictedPostType(); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2); $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute([]); @@ -63,10 +122,38 @@ final class MediaSearchAbilityTest extends TestCase self::assertSame([['id' => 1]], $result['items']); } + public function testSearchIsRestrictedToOwnAttachmentsWithoutEditOthersPosts(): void + { + Functions\when('get_post_type_object')->justReturn( + (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] + ); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); + Functions\when('get_current_user_id')->justReturn(7); + Functions\expect('get_posts')->once()->with( + Mockery::on(static fn (array $args): bool => ($args['author'] ?? null) === 7) + )->andReturn([]); + + (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([]); + } + + public function testSearchIsNotRestrictedToAuthorWithEditOthersPosts(): void + { + Functions\when('get_post_type_object')->justReturn( + (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] + ); + Functions\when('current_user_can')->justReturn(true); + Functions\expect('get_posts')->once()->with( + Mockery::on(static fn (array $args): bool => ! array_key_exists('author', $args)) + )->andReturn([]); + + (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([]); + } + public function testUnusedFiltersThroughTheUsageFinder(): void { Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturnUsing(static fn (int $id): bool => $id === 2); @@ -81,6 +168,7 @@ final class MediaSearchAbilityTest extends TestCase { Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturn(false); @@ -90,10 +178,25 @@ final class MediaSearchAbilityTest extends TestCase self::assertSame(1, Cursor::decode($result['next_cursor'])); } + public function testUnusedHasNoCursorWhenTheLastCandidateWasExaminedAtTheLimit(): void + { + Functions\when('get_posts')->justReturn($this->attachments(1)); + Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('isUsed')->andReturn(false); + + $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]); + + self::assertSame([['id' => 1]], $result['items']); + self::assertNull($result['next_cursor']); + } + public function testUnusedSkipsAttachmentsTheUserCannotEdit(): void { Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); - Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 2); + $this->unrestrictedPostType(); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturn(false); @@ -105,8 +208,11 @@ final class MediaSearchAbilityTest extends TestCase public function testUnusedWarnsAndPagesWhenTheScanCapIsReached(): void { - Functions\when('get_posts')->justReturn($this->attachments(...range(1, 21))); Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); + Functions\expect('get_posts')->once()->with( + Mockery::on(static fn (array $args): bool => $args['posts_per_page'] === 21 && $args['offset'] === 0) + )->andReturn($this->attachments(...range(1, 21))); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturn(true); @@ -117,6 +223,47 @@ final class MediaSearchAbilityTest extends TestCase self::assertNotSame([], $result['warnings']); } + public function testUnusedCursorOffsetIsPassedThrough(): void + { + Functions\when('current_user_can')->justReturn(true); + $this->unrestrictedPostType(); + Functions\expect('get_posts')->once()->with( + Mockery::on(static fn (array $args): bool => $args['offset'] === 5) + )->andReturn([]); + $finder = Mockery::mock(MediaUsageFinder::class); + + (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'cursor' => Cursor::encode(5)]); + } + + public function testUnusedIsRestrictedToOwnAttachmentsWithoutEditOthersPosts(): void + { + Functions\when('get_post_type_object')->justReturn( + (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] + ); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); + Functions\when('get_current_user_id')->justReturn(7); + Functions\expect('get_posts')->once()->with( + Mockery::on(static fn (array $args): bool => ($args['author'] ?? null) === 7) + )->andReturn([]); + + (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['unused' => true]); + } + + public function testUnusedIsNotRestrictedToAuthorWithEditOthersPosts(): void + { + Functions\when('get_post_type_object')->justReturn( + (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] + ); + Functions\when('current_user_can')->justReturn(true); + Functions\expect('get_posts')->once()->with( + Mockery::on(static fn (array $args): bool => ! array_key_exists('author', $args)) + )->andReturn([]); + + (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) + ->execute(['unused' => true]); + } + public function testRequiresUploadRights(): void { Functions\when('current_user_can')->justReturn(false); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php index 6eccf66..e610bf2 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php @@ -37,8 +37,7 @@ final class ContentQueryAbilityTest extends TestCase && $args['s'] === 'Anna' && $args['posts_per_page'] === 3 && $args['offset'] === 4 - && $args['orderby'] === 'title' - && $args['order'] === 'ASC' + && $args['orderby'] === ['title' => 'ASC', 'ID' => 'ASC'] && $args['tax_query'] === [ 'relation' => 'OR', ['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]], diff --git a/tests/Unit/Integrations/WordPress/PostGuardTest.php b/tests/Unit/Integrations/WordPress/PostGuardTest.php index a06d948..f36fd87 100644 --- a/tests/Unit/Integrations/WordPress/PostGuardTest.php +++ b/tests/Unit/Integrations/WordPress/PostGuardTest.php @@ -85,6 +85,27 @@ final class PostGuardTest extends TestCase self::assertNull(PostGuard::statusStable(new WP_Post(['post_status' => 'publish']))); } + public function testOwnPostsOnlyReturnsTheCurrentUserWithoutEditOthersPosts(): void + { + Functions\when('get_post_type_object')->justReturn( + (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] + ); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); + Functions\when('get_current_user_id')->justReturn(7); + + self::assertSame(7, PostGuard::ownPostsOnly('page')); + } + + public function testOwnPostsOnlyReturnsNullWithEditOthersPosts(): void + { + Functions\when('get_post_type_object')->justReturn( + (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] + ); + Functions\when('current_user_can')->justReturn(true); + + self::assertNull(PostGuard::ownPostsOnly('page')); + } + public function testPostTypeChecksAvailabilityAndCapability(): void { $object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']]; From f027da1785cd5d755a7c11f39f90eb610928bd61 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:29:02 +0200 Subject: [PATCH 062/185] feat: media preview as image content and download as embedded resource lit/media-preview downscales an image attachment via wp_get_image_editor and returns it as ImageContent; lit/media-download returns a file (full or a named size) as an embedded resource, rejecting files above the upload limit. Both are direct tools (ruling Q6: embedded resources, like ImageContent, only become content blocks for tools called directly). Adds MediaFiles::isWithinUploads() so neither tool ever reads a file outside wp_upload_dir()'s basedir. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 3 +- src/Integrations/Media/AdminIncludes.php | 27 ++++ .../Media/MediaDownloadAbility.php | 133 ++++++++++++++++ src/Integrations/Media/MediaFiles.php | 26 ++++ src/Integrations/Media/MediaIntegration.php | 2 + .../Media/MediaPreviewAbility.php | 142 ++++++++++++++++++ .../Media/MediaDownloadAbilityTest.php | 85 +++++++++++ .../Media/MediaIntegrationTest.php | 2 + .../Media/MediaPreviewAbilityTest.php | 92 ++++++++++++ 9 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Media/AdminIncludes.php create mode 100644 src/Integrations/Media/MediaDownloadAbility.php create mode 100644 src/Integrations/Media/MediaFiles.php create mode 100644 src/Integrations/Media/MediaPreviewAbility.php create mode 100644 tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php create mode 100644 tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 7e421c8..ea7ccf0 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -18,7 +18,8 @@ des verbundenen WordPress-Benutzers. ## Werkzeuge Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, -`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`. +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`. Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Hinweisen immer so geschrieben): diff --git a/src/Integrations/Media/AdminIncludes.php b/src/Integrations/Media/AdminIncludes.php new file mode 100644 index 0000000..50ccdc6 --- /dev/null +++ b/src/Integrations/Media/AdminIncludes.php @@ -0,0 +1,27 @@ + 'wp-admin/includes/file.php', + 'media_handle_sideload' => 'wp-admin/includes/media.php', + 'wp_generate_attachment_metadata' => 'wp-admin/includes/image.php', + ]; + + public static function load(): void + { + foreach (self::FILES as $function => $file) { + if (! function_exists($function) && is_readable(ABSPATH . $file)) { + require_once ABSPATH . $file; + } + } + } +} diff --git a/src/Integrations/Media/MediaDownloadAbility.php b/src/Integrations/Media/MediaDownloadAbility.php new file mode 100644 index 0000000..8033945 --- /dev/null +++ b/src/Integrations/Media/MediaDownloadAbility.php @@ -0,0 +1,133 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'size' => ['type' => 'string', 'description' => 'full (Standard) oder Name einer Bildgröße.'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return null; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + $size = (string) ($input['size'] ?? 'full'); + if ($size === 'full') { + $path = get_attached_file($id); + $url = (string) wp_get_attachment_url($id); + } else { + $data = image_get_intermediate_size($id, $size); + if (! is_array($data) || ! isset($data['path'])) { + return Errors::notFound( + sprintf('Die Größe "%s" gibt es für dieses Medium nicht.', $size), + 'Verfügbare Größen stehen unter sizes in lit/media-get.' + ); + } + $uploads = wp_upload_dir(); + $path = (is_array($uploads) ? (string) ($uploads['basedir'] ?? '') : '') . '/' . $data['path']; + $url = (string) ($data['url'] ?? ''); + } + + if (! is_string($path) || ! is_readable($path) || ! MediaFiles::isWithinUploads($path)) { + return Errors::notFound('Die Datei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.'); + } + + $bytesOnDisk = (int) filesize($path); + if ($bytesOnDisk > $this->limits->uploadBytes()) { + return Errors::unsupported( + sprintf( + 'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).', + $bytesOnDisk / 1048576, + $this->limits->uploadMegabytes() + ), + 'Kleinere Bildgröße (size) wählen oder die URL aus lit/media-get verwenden.' + ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $bytes = file_get_contents($path); + if ($bytes === false) { + return Errors::internal(); + } + + $type = wp_check_filetype(wp_basename($path)); + $mime = is_string($type['type'] ?? null) && $type['type'] !== '' + ? $type['type'] + : (string) $attachment->post_mime_type; + + return [ + 'type' => 'resource', + 'resource' => ['uri' => $url, 'mimeType' => $mime, 'blob' => base64_encode($bytes)], + ]; + } +} diff --git a/src/Integrations/Media/MediaFiles.php b/src/Integrations/Media/MediaFiles.php new file mode 100644 index 0000000..ca5bdc1 --- /dev/null +++ b/src/Integrations/Media/MediaFiles.php @@ -0,0 +1,26 @@ +limits), ]; } } diff --git a/src/Integrations/Media/MediaPreviewAbility.php b/src/Integrations/Media/MediaPreviewAbility.php new file mode 100644 index 0000000..68ad717 --- /dev/null +++ b/src/Integrations/Media/MediaPreviewAbility.php @@ -0,0 +1,142 @@ + 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + ]; + + public function name(): string + { + return 'lit/media-preview'; + } + + public function label(): string + { + return 'Bildvorschau'; + } + + public function description(): string + { + return 'Zeigt ein Bild der Mediathek als verkleinerte Vorschau (längste Kante max_size, Standard 512 px), ' + . 'damit der Inhalt beurteilt werden kann, z. B. für Alt-Texte.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'max_size' => ['type' => 'integer', 'minimum' => 64, 'maximum' => 1024], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return null; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + if (! wp_attachment_is_image($id)) { + return Errors::unsupported( + 'Das Medium ist kein Bild.', + 'Metadaten über lit/media-get abrufen; Dateien über lit-media-download.' + ); + } + + $file = get_attached_file($id); + if (! is_string($file) || ! is_readable($file) || ! MediaFiles::isWithinUploads($file)) { + return Errors::notFound('Die Bilddatei fehlt auf dem Server.', 'Medium in der Mediathek prüfen.'); + } + + AdminIncludes::load(); + $editor = wp_get_image_editor($file); + if ($editor instanceof WP_Error) { + return Errors::unsupported( + 'Das Bild kann nicht verarbeitet werden: ' . $editor->get_error_message(), + 'Metadaten über lit/media-get abrufen.' + ); + } + + $max = max(64, min(1024, (int) ($input['max_size'] ?? self::DEFAULT_SIZE))); + $size = (array) $editor->get_size(); + if (max((int) ($size['width'] ?? 0), (int) ($size['height'] ?? 0)) > $max) { + $resized = $editor->resize($max, $max, false); + if ($resized instanceof WP_Error) { + return Errors::unsupported( + 'Vorschau konnte nicht verkleinert werden.', + 'Metadaten über lit/media-get abrufen.' + ); + } + } + + $mime = isset(self::EXTENSIONS[$attachment->post_mime_type]) + ? (string) $attachment->post_mime_type + : 'image/jpeg'; + $target = wp_tempnam('lit-mcp-preview'); + $saved = $editor->save($target . '.' . self::EXTENSIONS[$mime], $mime); + wp_delete_file($target); + if (! is_array($saved) || ! isset($saved['path'])) { + return Errors::unsupported( + 'Vorschau konnte nicht erzeugt werden.', + 'Metadaten über lit/media-get abrufen.' + ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $bytes = file_get_contents((string) $saved['path']); + wp_delete_file((string) $saved['path']); + if ($bytes === false) { + return Errors::internal(); + } + + return ['type' => 'image', 'results' => $bytes, 'mimeType' => (string) ($saved['mime-type'] ?? $mime)]; + } +} diff --git a/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php new file mode 100644 index 0000000..1d97609 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php @@ -0,0 +1,85 @@ +file = (string) tempnam(sys_get_temp_dir(), 'lit-download'); + file_put_contents($this->file, 'PDFDATA'); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 40, + 'post_type' => 'attachment', + 'post_mime_type' => 'application/pdf', + ])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_attached_file')->justReturn($this->file); + Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/preise.pdf'); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_check_filetype')->justReturn(['ext' => 'pdf', 'type' => 'application/pdf']); + Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir()]); + } + + protected function tearDown(): void + { + if (is_file($this->file)) { + unlink($this->file); + } + parent::tearDown(); + } + + public function testReturnsTheFileAsEmbeddedBlobResource(): void + { + Functions\when('get_option')->justReturn([]); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('resource', $result['type']); + self::assertSame('https://kunde.de/wp-content/uploads/preise.pdf', $result['resource']['uri']); + self::assertSame('application/pdf', $result['resource']['mimeType']); + self::assertSame(base64_encode('PDFDATA'), $result['resource']['blob']); + } + + public function testFilesAboveTheLimitAreRefused(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 1]); + file_put_contents($this->file, str_repeat('x', 1024 * 1024 + 1)); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testUnknownSizeIsNotFound(): void + { + Functions\when('get_option')->justReturn([]); + Functions\when('image_get_intermediate_size')->justReturn(false); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40, 'size' => 'huge']); + + self::assertSame('lit_not_found', $result->get_error_code()); + self::assertNull((new MediaDownloadAbility(new Limits()))->outputSchema()); + } + + public function testFilesOutsideTheUploadsDirectoryAreRefused(): void + { + Functions\when('get_option')->justReturn([]); + Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir() . '/lit-elsewhere']); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('lit_not_found', $result->get_error_code()); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index 76a8cd4..c635092 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -18,6 +18,8 @@ final class MediaIntegrationTest extends TestCase private const EXPECTED = [ 'lit/media-search', 'lit/media-get', + 'lit/media-preview', + 'lit/media-download', ]; public function testProvidesTheMediaAbilities(): void diff --git a/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php b/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php new file mode 100644 index 0000000..40138f8 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php @@ -0,0 +1,92 @@ +source = (string) tempnam(sys_get_temp_dir(), 'lit-src'); + file_put_contents($this->source, 'original'); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 33, + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + ])); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_attached_file')->justReturn($this->source); + Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir()]); + Functions\when('wp_tempnam')->alias(static fn (): string => (string) tempnam(sys_get_temp_dir(), 'lit-prev')); + Functions\when('wp_delete_file')->alias(static function (string $file): void { + if (is_file($file)) { + unlink($file); + } + }); + } + + protected function tearDown(): void + { + if (is_file($this->source)) { + unlink($this->source); + } + parent::tearDown(); + } + + public function testReturnsADownscaledImageResult(): void + { + Functions\when('wp_attachment_is_image')->justReturn(true); + $editor = Mockery::mock(); + $editor->allows('get_size')->andReturn(['width' => 2000, 'height' => 1000]); + $editor->expects('resize')->with(256, 256, false)->andReturn(true); + $editor->expects('save')->andReturnUsing(static function (string $path, string $mime): array { + file_put_contents($path, 'JPEGDATA'); + return ['path' => $path, 'mime-type' => $mime]; + }); + Functions\when('wp_get_image_editor')->justReturn($editor); + + $result = (new MediaPreviewAbility())->execute(['id' => 33, 'max_size' => 256]); + + self::assertSame('image', $result['type']); + self::assertSame('JPEGDATA', $result['results']); + self::assertSame('image/jpeg', $result['mimeType']); + } + + public function testNonImagesAreUnsupported(): void + { + Functions\when('wp_attachment_is_image')->justReturn(false); + + $result = (new MediaPreviewAbility())->execute(['id' => 33]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } + + public function testFilesOutsideTheUploadsDirectoryAreRefused(): void + { + Functions\when('wp_attachment_is_image')->justReturn(true); + Functions\when('wp_upload_dir')->justReturn(['basedir' => sys_get_temp_dir() . '/lit-elsewhere']); + + $result = (new MediaPreviewAbility())->execute(['id' => 33]); + + self::assertSame('lit_not_found', $result->get_error_code()); + } + + public function testIsADirectToolWithoutOutputSchema(): void + { + $ability = new MediaPreviewAbility(); + + self::assertNull($ability->outputSchema()); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } +} From 2e29a6cd4d87c9f4217abb63433cc8ac04612886 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:37:43 +0200 Subject: [PATCH 063/185] feat: media upload by URL and base64 with whitelist and size limit MediaImporter streams URL imports via wp_safe_remote_get with limit_response_size, checks Content-Length, intersects the MIME whitelist with get_allowed_mime_types and validates real content; base64 is size-checked before decoding. Server-side failures are write_failed; a failed alt text deletes the new attachment again (or partial_write). MediaUploads is the shared permission/result helper of both upload abilities. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 4 +- src/Integrations/Media/MediaImporter.php | 279 ++++++++++++++++ src/Integrations/Media/MediaIntegration.php | 8 +- src/Integrations/Media/MediaUploadAbility.php | 96 ++++++ .../Media/MediaUploadFromUrlAbility.php | 88 +++++ src/Integrations/Media/MediaUploads.php | 154 +++++++++ .../Integrations/Media/MediaImporterTest.php | 302 ++++++++++++++++++ .../Media/MediaIntegrationTest.php | 2 + .../Media/MediaUploadAbilitiesTest.php | 157 +++++++++ 9 files changed, 1086 insertions(+), 4 deletions(-) create mode 100644 src/Integrations/Media/MediaImporter.php create mode 100644 src/Integrations/Media/MediaUploadAbility.php create mode 100644 src/Integrations/Media/MediaUploadFromUrlAbility.php create mode 100644 src/Integrations/Media/MediaUploads.php create mode 100644 tests/Unit/Integrations/Media/MediaImporterTest.php create mode 100644 tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index ea7ccf0..b54f0f6 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,14 +19,14 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`. Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Hinweisen immer so geschrieben): Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), -Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`). +Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`). Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`. diff --git a/src/Integrations/Media/MediaImporter.php b/src/Integrations/Media/MediaImporter.php new file mode 100644 index 0000000..59312b6 --- /dev/null +++ b/src/Integrations/Media/MediaImporter.php @@ -0,0 +1,279 @@ + 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'pdf' => 'application/pdf', + 'mp4|m4v' => 'video/mp4', + 'mp3' => 'audio/mpeg', + ]; + + private const TIMEOUT = 30; + private const ALT_KEY = '_wp_attachment_image_alt'; + + public function __construct(private Limits $limits) + { + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromUrl(string $url, ?string $filename, array $meta): int|WP_Error + { + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); + if (! in_array($scheme, ['http', 'https'], true) || ! wp_http_validate_url($url)) { + return Errors::invalidInput( + 'Die URL ist nicht erlaubt.', + ['url' => 'nur öffentlich erreichbare http(s)-Adressen'], + 'Eine öffentlich erreichbare Datei-URL angeben (keine internen oder lokalen Adressen).' + ); + } + + AdminIncludes::load(); + $tmp = (string) wp_tempnam('lit-mcp-import'); + try { + $response = $this->download($url, $tmp); + if ($response instanceof WP_Error) { + return $response; + } + + $name = $filename ?? wp_basename((string) wp_parse_url($url, PHP_URL_PATH)); + + return $this->import($tmp, $this->withExtension($name, $response), $meta); + } finally { + $this->removeTemp($tmp); + } + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromBase64(string $data, string $filename, array $meta): int|WP_Error + { + $estimated = intdiv(strlen($data) * 3, 4); + if ($estimated > $this->limits->uploadBytes() + 2) { + return $this->tooLarge($estimated); + } + + $bytes = base64_decode($data, true); + if ($bytes === false || $bytes === '') { + return Errors::invalidInput( + 'data ist kein gültiges Base64.', + ['data' => 'Base64-kodierter Dateiinhalt erwartet (ohne data:-Präfix)'] + ); + } + + if (strlen($bytes) > $this->limits->uploadBytes()) { + return $this->tooLarge(strlen($bytes)); + } + + AdminIncludes::load(); + $tmp = (string) wp_tempnam('lit-mcp-upload'); + try { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file. + if ($tmp === '' || file_put_contents($tmp, $bytes) === false) { + return Errors::writeFailed('Die temporäre Datei konnte nicht geschrieben werden.'); + } + + return $this->import($tmp, $filename, $meta); + } finally { + $this->removeTemp($tmp); + } + } + + /** @return array|WP_Error The response (body streamed into $tmp). */ + private function download(string $url, string $tmp): array|WP_Error + { + $response = wp_safe_remote_get($url, [ + 'timeout' => self::TIMEOUT, + 'stream' => true, + 'filename' => $tmp, + 'limit_response_size' => $this->limits->uploadBytes() + 1, + ]); + if ($response instanceof WP_Error) { + return $this->downloadFailed($response->get_error_message()); + } + + $status = (int) wp_remote_retrieve_response_code($response); + if ($status !== 200) { + return $this->downloadFailed(sprintf('HTTP-Status %d', $status)); + } + + $length = (string) wp_remote_retrieve_header($response, 'content-length'); + if (ctype_digit($length) && (int) $length > $this->limits->uploadBytes()) { + return $this->tooLarge((int) $length, 'url'); + } + + return $response; + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + private function import(string $tmp, string $filename, array $meta): int|WP_Error + { + clearstatcache(true, $tmp); + $size = (int) filesize($tmp); + if ($size > $this->limits->uploadBytes()) { + return $this->tooLarge($size); + } + + $mimes = $this->allowedMimes(); + $name = sanitize_file_name($filename !== '' ? $filename : 'datei'); + $check = wp_check_filetype_and_ext($tmp, $name, $mimes); + if (empty($check['type']) || empty($check['ext'])) { + return Errors::invalidInput( + 'Dateityp nicht erlaubt oder Inhalt passt nicht zur Endung.', + ['filename' => 'erlaubt: ' . str_replace('|', ', ', implode(', ', array_keys($mimes)))], + 'Nur erlaubte Dateitypen hochladen (Bilder, PDF, MP4, MP3) und die passende Endung verwenden.' + ); + } + + if (! empty($check['proper_filename'])) { + $name = (string) $check['proper_filename']; + } + + $title = isset($meta['title']) ? sanitize_text_field((string) $meta['title']) : null; + $attachmentId = media_handle_sideload( + ['name' => $name, 'tmp_name' => $tmp], + (int) ($meta['post_id'] ?? 0), + $title + ); + if ($attachmentId instanceof WP_Error) { + $reason = 'WordPress hat die Datei nicht übernommen: ' . $attachmentId->get_error_message(); + + return Errors::writeFailed($reason); + } + + $attachmentId = (int) $attachmentId; + if (isset($meta['alt']) && $meta['alt'] !== '') { + $this->saveAlt($attachmentId, sanitize_text_field((string) $meta['alt'])); + } + + return $attachmentId; + } + + /** @throws WriteFailed When the alt text cannot be saved (attachment deleted again if possible). */ + private function saveAlt(int $attachmentId, string $alt): void + { + // update_post_meta() is also false when the value is already stored (alt from IPTC data). + $stored = update_post_meta($attachmentId, self::ALT_KEY, $alt) !== false + || (string) get_post_meta($attachmentId, self::ALT_KEY, true) === $alt; + if ($stored) { + return; + } + + $reason = 'Der Alt-Text konnte nicht gespeichert werden.'; + $detail = sprintf('alt text of new attachment %d could not be saved', $attachmentId); + try { + $deleted = (bool) wp_delete_attachment($attachmentId, true); + } catch (Throwable $e) { + $deleted = false; + $detail .= '; delete failed: ' . $e->getMessage(); + } + + if ($deleted) { + throw new WriteFailed($reason, true, null, null, null, $detail); + } + + throw new WriteFailed( + $reason, + false, + $attachmentId, + null, + null, + $detail . '; attachment could not be deleted', + sprintf('Medium %d wurde trotzdem angelegt, aber ohne Alt-Text.', $attachmentId), + 'Den Aufruf nicht wiederholen (sonst liegt die Datei doppelt vor). Medium mit lit/media-get prüfen, den ' + . 'Alt-Text in der Mediathek nachtragen und dem Nutzer Bescheid geben.', + ['attachment_id' => $attachmentId] + ); + } + + /** + * Whitelist (filterable) restricted to what WordPress allows the current user. + * + * @return array + */ + private function allowedMimes(): array + { + $mimes = apply_filters('lit_mcp_upload_mimes', self::MIMES); + $mimes = is_array($mimes) ? $mimes : self::MIMES; + $allowed = get_allowed_mime_types(); + + return array_intersect_key($mimes, is_array($allowed) ? $allowed : []); + } + + /** + * URLs without an extension (CDNs, image services): take it from the Content-Type + * header, if that type is allowed. The real content is checked afterwards anyway. + * + * @param array $response + */ + private function withExtension(string $name, array $response): string + { + if ($name !== '' && pathinfo($name, PATHINFO_EXTENSION) !== '') { + return $name; + } + + $type = strtolower(trim(explode(';', (string) wp_remote_retrieve_header($response, 'content-type'))[0])); + foreach ($this->allowedMimes() as $extensions => $mime) { + if ($mime === $type) { + return ($name !== '' ? $name : 'datei') . '.' . explode('|', $extensions)[0]; + } + } + + return $name; + } + + private function removeTemp(string $tmp): void + { + if ($tmp !== '' && file_exists($tmp)) { + wp_delete_file($tmp); + } + } + + private function downloadFailed(string $reason): WP_Error + { + return Errors::invalidInput( + 'Download fehlgeschlagen: ' . $reason, + ['url' => 'nicht abrufbar'], + 'URL prüfen; nur öffentlich erreichbare Dateien können importiert werden.' + ); + } + + private function tooLarge(int $bytes, string $field = 'data'): WP_Error + { + return Errors::invalidInput( + sprintf('Datei zu groß (%.1f MB, Limit %d MB).', $bytes / 1048576, $this->limits->uploadMegabytes()), + [$field => 'über dem Upload-Limit'], + 'Kleinere Datei verwenden oder das Limit unter Einstellungen → LIT MCP → Limits erhöhen lassen.' + ); + } +} diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php index 9a6ebd6..5bb34f1 100644 --- a/src/Integrations/Media/MediaIntegration.php +++ b/src/Integrations/Media/MediaIntegration.php @@ -10,8 +10,8 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Limits; /** - * Media library tools (spec §6.3). Always available. $log and $limits are used by the - * write tools that follow (upload, update, delete). + * Media library tools (spec §6.3). Always available. $log and $limits serve the write + * tools (upload now, update and delete to follow). */ final class MediaIntegration implements Integration { @@ -33,11 +33,15 @@ final class MediaIntegration implements Integration { $presenter = new MediaPresenter(); $finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys()); + $importer = new MediaImporter($this->limits); + $uploads = new MediaUploads($presenter, $this->log, $this->schema); return [ new MediaSearchAbility($presenter, $finder), new MediaGetAbility($presenter, $finder), new MediaPreviewAbility(), + new MediaUploadFromUrlAbility($importer, $uploads), + new MediaUploadAbility($importer, $uploads, $this->limits), new MediaDownloadAbility($this->limits), ]; } diff --git a/src/Integrations/Media/MediaUploadAbility.php b/src/Integrations/Media/MediaUploadAbility.php new file mode 100644 index 0000000..1e88eae --- /dev/null +++ b/src/Integrations/Media/MediaUploadAbility.php @@ -0,0 +1,96 @@ +limits->uploadMegabytes() + ); + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => array_merge( + [ + 'filename' => ['type' => 'string', 'minLength' => 1, 'description' => 'Dateiname mit Endung.'], + 'data' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Dateiinhalt als Base64 (ohne data:-Präfix).', + ], + ], + MediaUploads::metaProperties() + ), + 'required' => ['filename', 'data'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return MediaUploads::outputSchema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return $this->uploads->permission($input); + } + + public function execute(array $input): mixed + { + return $this->uploads->run( + $this->name(), + fn (): int|WP_Error => $this->importer->fromBase64( + (string) $input['data'], + (string) $input['filename'], + MediaUploads::meta($input) + ), + 'Datei "%s" hochgeladen (ID %d)' + ); + } +} diff --git a/src/Integrations/Media/MediaUploadFromUrlAbility.php b/src/Integrations/Media/MediaUploadFromUrlAbility.php new file mode 100644 index 0000000..7bfe6dd --- /dev/null +++ b/src/Integrations/Media/MediaUploadFromUrlAbility.php @@ -0,0 +1,88 @@ + 'object', + 'properties' => array_merge( + [ + 'url' => ['type' => 'string', 'minLength' => 1, 'description' => 'http(s)-URL der Datei.'], + 'filename' => [ + 'type' => 'string', + 'description' => 'Optional: Dateiname mit Endung (Standard: aus der URL).', + ], + ], + MediaUploads::metaProperties() + ), + 'required' => ['url'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return MediaUploads::outputSchema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return $this->uploads->permission($input); + } + + public function execute(array $input): mixed + { + $filename = isset($input['filename']) && $input['filename'] !== '' ? (string) $input['filename'] : null; + $url = (string) $input['url']; + + return $this->uploads->run( + $this->name(), + fn (): int|WP_Error => $this->importer->fromUrl($url, $filename, MediaUploads::meta($input)), + 'Datei "%s" von URL importiert (ID %d)' + ); + } +} diff --git a/src/Integrations/Media/MediaUploads.php b/src/Integrations/Media/MediaUploads.php new file mode 100644 index 0000000..e3f3080 --- /dev/null +++ b/src/Integrations/Media/MediaUploads.php @@ -0,0 +1,154 @@ + $input + */ + public function permission(array $input): bool|WP_Error + { + if (! current_user_can('upload_files')) { + return Errors::forbidden('Keine Berechtigung zum Hochladen (upload_files).'); + } + + if (! isset($input['post_id'])) { + return true; + } + + $parent = PostGuard::editable($input['post_id'], $this->schema); + + return $parent instanceof WP_Error ? $parent : true; + } + + /** The meta fields the importer takes from the input. */ + public static function meta(array $input): array + { + return array_intersect_key($input, ['title' => true, 'alt' => true, 'post_id' => true]); + } + + /** + * Runs $import (returns the new attachment id or an error) and builds the result. + * + * @param callable(): (int|WP_Error) $import + * @param string $done Summary with "%s" = title and "%d" = id, + * e.g. 'Datei "%s" hochgeladen (ID %d)'. + * @return array|WP_Error + * @throws WriteFailed From the importer; a partial one is logged as partial first. + */ + public function run(string $ability, callable $import, string $done): array|WP_Error + { + try { + $attachmentId = $import(); + } catch (WriteFailed $e) { + if (! $e->rolledBack && $e->postId !== null) { + $summary = sprintf('Medium %d: Upload nur teilweise gespeichert', $e->postId); + $this->log->recordPartial($ability, 'attachment', (string) $e->postId, $summary); + } + throw $e; + } + + if ($attachmentId instanceof WP_Error) { + return $attachmentId; + } + + return $this->result($ability, (int) $attachmentId, $done); + } + + /** @return array */ + private function result(string $ability, int $attachmentId, string $done): array + { + $warnings = []; + $saved = sprintf('Medium %d ist gespeichert.', $attachmentId); + $attachment = get_post($attachmentId); + $title = $attachment instanceof WP_Post ? (string) $attachment->post_title : ''; + $details = $attachment instanceof WP_Post + ? FollowUp::run( + fn (): array => $this->presenter->summary($attachment), + $ability . ' summary', + 'Die Angaben zum Medium konnten nicht gelesen werden; ' . $saved, + $warnings + ) + : null; + + $summary = sprintf($done, $title, $attachmentId); + $logId = FollowUp::run( + fn (): ?int => $this->log->record($ability, 'attachment', (string) $attachmentId, $summary), + $ability . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; ' . $saved, + $warnings + ); + + return ToolResult::ok($summary . '.') + ->changed((string) $attachmentId, 'attachment', 'created', $title) + ->logId(is_int($logId) ? $logId : null) + ->warnings($warnings) + ->urls(null, (string) get_edit_post_link($attachmentId, 'raw')) + ->with('attachment', is_array($details) ? $details : $this->fallback($attachmentId, $title)) + ->toArray(); + } + + /** @return array The required summary fields when the attachment cannot be read. */ + private function fallback(int $attachmentId, string $title): array + { + return [ + 'id' => $attachmentId, + 'title' => $title, + 'mime_type' => '', + 'url' => (string) wp_get_attachment_url($attachmentId), + 'alt' => '', + ]; + } + + /** @return array */ + public static function outputSchema(): array + { + return ToolResult::schema(['attachment' => MediaPresenter::summarySchema()], ['attachment']); + } + + /** + * Input properties both abilities share. + * + * @return array> + */ + public static function metaProperties(): array + { + return [ + 'title' => ['type' => 'string', 'description' => 'Optional: Titel in der Mediathek.'], + 'alt' => ['type' => 'string', 'description' => 'Optional: Alt-Text (bei Bildern empfohlen).'], + 'post_id' => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Optional: Eintrag, dem das Medium zugeordnet wird (braucht Bearbeitungsrecht).', + ], + ]; + } +} diff --git a/tests/Unit/Integrations/Media/MediaImporterTest.php b/tests/Unit/Integrations/Media/MediaImporterTest.php new file mode 100644 index 0000000..1931932 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaImporterTest.php @@ -0,0 +1,302 @@ + */ + private array $tempFiles = []; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_option')->justReturn(['upload_mb' => 1]); + Functions\when('wp_tempnam')->alias(function (): string { + $file = (string) tempnam(sys_get_temp_dir(), 'lit-up'); + $this->tempFiles[] = $file; + return $file; + }); + Functions\when('wp_delete_file')->alias(static function (string $file): void { + if (is_file($file)) { + unlink($file); + } + }); + Functions\when('sanitize_file_name')->returnArg(); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_parse_url')->alias('parse_url'); + Functions\when('get_allowed_mime_types')->justReturn(MediaImporter::MIMES); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + private function importer(): MediaImporter + { + return new MediaImporter(new Limits()); + } + + private function assertTempFilesRemoved(): void + { + foreach ($this->tempFiles as $file) { + self::assertFileDoesNotExist($file); + } + } + + /** Lets the next wp_safe_remote_get write $body into the stream target and answer with $headers. */ + private function respond(string $body, int $code = 200, array $headers = []): void + { + Functions\when('wp_safe_remote_get')->alias(static function (string $url, array $args) use ($body, $code, $headers): array { + file_put_contents($args['filename'], $body); + return ['response' => ['code' => $code], 'headers' => $headers, 'filename' => $args['filename']]; + }); + $this->responseReaders(); + } + + private function responseReaders(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\when('wp_remote_retrieve_response_code')->alias(static fn (array $r): int => $r['response']['code']); + Functions\when('wp_remote_retrieve_header')->alias( + static fn (array $r, string $name): string => (string) ($r['headers'][$name] ?? '') + ); + } + + public function testBase64UploadIsSideloadedWithAltText(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png' && is_file($file['tmp_name'])), 5, 'Logo') + ->andReturnUsing(static function (array $file): int { + unlink($file['tmp_name']); // WordPress moves the temp file into uploads. + return 55; + }); + Functions\expect('update_post_meta')->once()->with(55, '_wp_attachment_image_alt', 'Firmenlogo')->andReturn(true); + + $id = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', [ + 'title' => 'Logo', + 'alt' => 'Firmenlogo', + 'post_id' => 5, + ]); + + self::assertSame(55, $id); + } + + public function testOversizedBase64IsRejectedBeforeDecoding(): void + { + $result = $this->importer()->fromBase64(str_repeat('A', 2 * 1024 * 1024), 'big.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload'); + } + + public function testInvalidBase64IsRejected(): void + { + $result = $this->importer()->fromBase64('***', 'x.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertSame([], $this->tempFiles); + } + + public function testDisguisedFileIsRejectedAndTheTempFileRemoved(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromBase64(base64_encode('get_error_code()); + self::assertNotSame([], $this->tempFiles); + $this->assertTempFilesRemoved(); + } + + public function testTheWhitelistOnlyKeepsTypesWordPressAllowsTheUser(): void + { + Functions\when('get_allowed_mime_types')->justReturn(['png' => 'image/png', 'svg' => 'image/svg+xml']); + Functions\expect('wp_check_filetype_and_ext')->once() + ->with(Mockery::type('string'), 'logo.png', ['png' => 'image/png']) + ->andReturn(['ext' => false, 'type' => false, 'proper_filename' => false]); + + $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('png', $result->get_error_message()); + } + + public function testSideloadFailureIsAWriteFailureAndCleansUp(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(new WP_Error('upload_error', 'Verzeichnis nicht beschreibbar')); + + $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); + + self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertStringContainsString('Verzeichnis nicht beschreibbar', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testFailedAltTextDeletesTheNewAttachmentAgain(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(55); + Functions\when('update_post_meta')->justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + Functions\expect('wp_delete_attachment')->once()->with(55, true)->andReturn(new \stdClass()); + + try { + $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo']); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + } + $this->assertTempFilesRemoved(); + } + + public function testAltTextFailureWithoutRollbackIsAPartialWriteNamingTheAttachment(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(55); + Functions\when('update_post_meta')->justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + Functions\when('wp_delete_attachment')->justReturn(false); + + try { + $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo']); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(55, $e->postId); + $error = $e->toError(); + self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertStringContainsString('Medium 55', $error->get_error_message()); + self::assertSame(55, $error->get_error_data()['attachment_id']); + } + } + + public function testAnAltTextAlreadyTakenFromTheImageIsNoFailure(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(55); + Functions\when('update_post_meta')->justReturn(false); // Same value already stored (IPTC alt). + Functions\when('get_post_meta')->justReturn('Logo'); + Functions\expect('wp_delete_attachment')->never(); + + self::assertSame(55, $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo'])); + } + + public function testInternalUrlsAreRejectedWithoutRequest(): void + { + Functions\when('wp_http_validate_url')->justReturn(false); + Functions\expect('wp_safe_remote_get')->never(); + + $result = $this->importer()->fromUrl('http://127.0.0.1/wp-config.php', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testOnlyHttpUrlsAreFetched(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\expect('wp_safe_remote_get')->never(); + + $result = $this->importer()->fromUrl('ftp://example.com/logo.png', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testUrlImportDownloadsSafelyWithTheSizeLimit(): void + { + $this->responseReaders(); + Functions\expect('wp_safe_remote_get')->once()->with( + 'https://example.com/bilder/logo.png', + Mockery::on(static function (array $args): bool { + file_put_contents($args['filename'], 'PNGDATA'); + return $args['stream'] === true && $args['limit_response_size'] === 1024 * 1024 + 1; + }) + )->andReturn(['response' => ['code' => 200], 'headers' => []]); + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png'), 0, null) + ->andReturn(56); + + self::assertSame(56, $this->importer()->fromUrl('https://example.com/bilder/logo.png', null, [])); + $this->assertTempFilesRemoved(); + } + + public function testAMissingExtensionIsTakenFromTheContentType(): void + { + $this->respond('PNGDATA', 200, ['content-type' => 'image/png; charset=binary']); + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'foto.png'), 0, null) + ->andReturn(57); + + self::assertSame(57, $this->importer()->fromUrl('https://cdn.example.com/foto?w=800', null, [])); + } + + public function testFailedDownloadIsReportedAndCleansUp(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'Not Found')); + + $result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Not Found', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testHttpErrorStatusIsReported(): void + { + $this->respond('404', 404); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('404', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testOversizedContentLengthIsRejected(): void + { + $this->respond('x', 200, ['content-length' => (string) (5 * 1024 * 1024)]); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testDownloadCutOffAtTheLimitIsRejected(): void + { + $this->respond(str_repeat('x', 1024 * 1024 + 1)); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index c635092..47a9fa4 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -19,6 +19,8 @@ final class MediaIntegrationTest extends TestCase 'lit/media-search', 'lit/media-get', 'lit/media-preview', + 'lit/media-upload-from-url', + 'lit/media-upload', 'lit/media-download', ]; diff --git a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php new file mode 100644 index 0000000..041ad1c --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php @@ -0,0 +1,157 @@ +allows('summary')->andReturn(['id' => 55, 'title' => 'Logo']); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new MediaUploads($presenter, $log ?? Mockery::mock(ChangeLog::class), $schema); + } + + private function savedAttachment(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 55, 'post_type' => 'attachment', 'post_title' => 'Logo'])); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=55&action=edit'); + } + + public function testUploadFromUrlReportsTheNewAttachment(): void + { + $this->savedAttachment(); + $importer = Mockery::mock(MediaImporter::class); + $importer->expects('fromUrl')->with('https://example.com/logo.png', null, ['alt' => 'Logo'])->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-upload-from-url', 'attachment', '55', Mockery::type('string'))->andReturn(3); + + $result = (new MediaUploadFromUrlAbility($importer, $this->uploads($log))) + ->execute(['url' => 'https://example.com/logo.png', 'alt' => 'Logo']); + + self::assertSame(['id' => 55, 'title' => 'Logo'], $result['attachment']); + self::assertSame(['id' => '55', 'type' => 'attachment', 'action' => 'created', 'label' => 'Logo'], $result['changed'][0]); + self::assertSame(3, $result['log_id']); + self::assertSame('https://kunde.de/wp-admin/post.php?post=55&action=edit', $result['edit_url']); + self::assertSame([], $result['warnings']); + } + + public function testBase64UploadPassesFilenameDataAndMeta(): void + { + $this->savedAttachment(); + $importer = Mockery::mock(MediaImporter::class); + $importer->expects('fromBase64')->with('AAAA', 'x.png', ['title' => 'Logo', 'post_id' => 5])->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-upload', 'attachment', '55', Mockery::type('string'))->andReturn(4); + + $result = (new MediaUploadAbility($importer, $this->uploads($log), new Limits())) + ->execute(['filename' => 'x.png', 'data' => 'AAAA', 'title' => 'Logo', 'post_id' => 5]); + + self::assertTrue($result['ok']); + self::assertSame(4, $result['log_id']); + } + + public function testAFailingChangeLogIsOnlyAWarning(): void + { + $this->savedAttachment(); + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromUrl')->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andThrow(new RuntimeException('db down')); + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $file = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $file); + try { + $result = (new MediaUploadFromUrlAbility($importer, $this->uploads($log))) + ->execute(['url' => 'https://example.com/logo.png']); + self::assertStringContainsString('db down', (string) file_get_contents($file)); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertTrue($result['ok']); + self::assertNull($result['log_id']); + self::assertStringContainsString('Medium 55 ist gespeichert', $result['warnings'][0]); + } + + public function testImporterErrorsArePassedThrough(): void + { + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromBase64')->andReturn(Errors::invalidInput('zu groß')); + + $result = (new MediaUploadAbility($importer, $this->uploads(), new Limits())) + ->execute(['filename' => 'x.png', 'data' => 'AAAA']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testAPartialUploadIsLoggedAsPartialAndRethrown(): void + { + $failure = new WriteFailed('Alt-Text fehlt.', false, 55); + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromUrl')->andThrow($failure); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/media-upload-from-url', 'attachment', '55', Mockery::type('string')); + + $this->expectExceptionObject($failure); + (new MediaUploadFromUrlAbility($importer, $this->uploads($log)))->execute(['url' => 'https://example.com/a.png']); + } + + public function testPermissionNeedsUploadRightsAndEditRightsOnTheParent(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $cap === 'upload_files'); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish'])); + $ability = new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()); + + self::assertTrue($ability->permission(['url' => 'https://example.com/a.png'])); + self::assertSame('lit_forbidden', $ability->permission(['url' => 'https://example.com/a.png', 'post_id' => 5])->get_error_code()); + } + + public function testPermissionRefusesUsersWithoutUploadRights(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); + + self::assertSame('lit_forbidden', $ability->permission(['filename' => 'a.png', 'data' => 'AAAA'])->get_error_code()); + } + + public function testUploadsForAParentThatDoesNotExistAreRefused(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(null); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); + + self::assertSame('lit_not_found', $ability->permission(['filename' => 'a.png', 'data' => 'AAAA', 'post_id' => 9])->get_error_code()); + } + + public function testDescriptionNamesTheConfiguredLimit(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 8]); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); + + self::assertStringContainsString('8 MB', $ability->description()); + self::assertFalse($ability->isDirect()); + self::assertTrue((new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()))->isDirect()); + } +} From 56f561c436c24d35edaaa8ac9bee5c0ca66d2d93 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:42:45 +0200 Subject: [PATCH 064/185] fix: media upload whitelist per extension, no stray files, IPv4-only import Empty effective whitelist is rejected instead of falling back to all types; the whitelist is matched per extension against core's keys (mp3|m4a|m4b); a file moved into uploads is deleted when the attachment cannot be created; the import request resolves IPv4 only; empty temp name guard for URL imports. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Media/MediaImporter.php | 105 +++++++++++++--- .../Integrations/Media/MediaImporterTest.php | 118 +++++++++++++++++- 2 files changed, 204 insertions(+), 19 deletions(-) diff --git a/src/Integrations/Media/MediaImporter.php b/src/Integrations/Media/MediaImporter.php index 59312b6..12335c7 100644 --- a/src/Integrations/Media/MediaImporter.php +++ b/src/Integrations/Media/MediaImporter.php @@ -61,6 +61,10 @@ class MediaImporter AdminIncludes::load(); $tmp = (string) wp_tempnam('lit-mcp-import'); + if ($tmp === '') { + return Errors::writeFailed('Die temporäre Datei konnte nicht angelegt werden.'); + } + try { $response = $this->download($url, $tmp); if ($response instanceof WP_Error) { @@ -112,12 +116,26 @@ class MediaImporter /** @return array|WP_Error The response (body streamed into $tmp). */ private function download(string $url, string $tmp): array|WP_Error { - $response = wp_safe_remote_get($url, [ - 'timeout' => self::TIMEOUT, - 'stream' => true, - 'filename' => $tmp, - 'limit_response_size' => $this->limits->uploadBytes() + 1, - ]); + // IPv4 only, for this request only: wp_http_validate_url() checks the IPv4 address + // (gethostbyname), so an AAAA record must not lead cURL to an unchecked IPv6 target. + $ipv4 = static function ($handle): void { + if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- http_api_curl hands us the handle. + curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); + } + }; + add_action('http_api_curl', $ipv4); + try { + $response = wp_safe_remote_get($url, [ + 'timeout' => self::TIMEOUT, + 'stream' => true, + 'filename' => $tmp, + 'limit_response_size' => $this->limits->uploadBytes() + 1, + ]); + } finally { + remove_action('http_api_curl', $ipv4); + } + if ($response instanceof WP_Error) { return $this->downloadFailed($response->get_error_message()); } @@ -145,12 +163,21 @@ class MediaImporter } $mimes = $this->allowedMimes(); + if ($mimes === []) { + // An empty list would make wp_check_filetype() fall back to every type WordPress allows. + return Errors::invalidInput( + 'Auf dieser Website sind keine Dateitypen zum Hochladen freigegeben.', + ['filename' => 'kein Dateityp erlaubt'], + 'Administrator fragen (Filter lit_mcp_upload_mimes bzw. erlaubte Dateitypen von WordPress).' + ); + } + $name = sanitize_file_name($filename !== '' ? $filename : 'datei'); $check = wp_check_filetype_and_ext($tmp, $name, $mimes); if (empty($check['type']) || empty($check['ext'])) { return Errors::invalidInput( 'Dateityp nicht erlaubt oder Inhalt passt nicht zur Endung.', - ['filename' => 'erlaubt: ' . str_replace('|', ', ', implode(', ', array_keys($mimes)))], + ['filename' => 'erlaubt: ' . implode(', ', array_keys($mimes))], 'Nur erlaubte Dateitypen hochladen (Bilder, PDF, MP4, MP3) und die passende Endung verwenden.' ); } @@ -160,18 +187,13 @@ class MediaImporter } $title = isset($meta['title']) ? sanitize_text_field((string) $meta['title']) : null; - $attachmentId = media_handle_sideload( - ['name' => $name, 'tmp_name' => $tmp], - (int) ($meta['post_id'] ?? 0), - $title - ); + $attachmentId = $this->sideload($name, $tmp, (int) ($meta['post_id'] ?? 0), $title); if ($attachmentId instanceof WP_Error) { $reason = 'WordPress hat die Datei nicht übernommen: ' . $attachmentId->get_error_message(); return Errors::writeFailed($reason); } - $attachmentId = (int) $attachmentId; if (isset($meta['alt']) && $meta['alt'] !== '') { $this->saveAlt($attachmentId, sanitize_text_field((string) $meta['alt'])); } @@ -179,6 +201,35 @@ class MediaImporter return $attachmentId; } + /** + * media_handle_sideload() moves the file into uploads before it creates the attachment; + * if that fails, WordPress leaves the file behind. The moved file is recorded (one-shot + * wp_handle_upload filter) and removed on error, so "nothing was saved" holds. + */ + private function sideload(string $name, string $tmp, int $postId, ?string $title): int|WP_Error + { + $moved = null; + $record = static function ($upload, $context = '') use (&$moved) { + if ($context === 'sideload' && is_array($upload) && isset($upload['file'])) { + $moved = (string) $upload['file']; + } + + return $upload; + }; + add_filter('wp_handle_upload', $record, 10, 2); + try { + $result = media_handle_sideload(['name' => $name, 'tmp_name' => $tmp], $postId, $title); + } finally { + remove_filter('wp_handle_upload', $record, 10); + } + + if ($result instanceof WP_Error && is_string($moved) && $moved !== '' && file_exists($moved)) { + wp_delete_file($moved); + } + + return $result instanceof WP_Error ? $result : (int) $result; + } + /** @throws WriteFailed When the alt text cannot be saved (attachment deleted again if possible). */ private function saveAlt(int $attachmentId, string $alt): void { @@ -217,17 +268,35 @@ class MediaImporter } /** - * Whitelist (filterable) restricted to what WordPress allows the current user. + * Whitelist (filterable) restricted to what WordPress allows the current user, per + * extension: core groups extensions differently (e.g. 'mp3|m4a|m4b'), so each of our + * extensions is kept only if WordPress maps it to the same MIME type. * - * @return array + * @return array Single extension => MIME type. */ private function allowedMimes(): array { $mimes = apply_filters('lit_mcp_upload_mimes', self::MIMES); $mimes = is_array($mimes) ? $mimes : self::MIMES; $allowed = get_allowed_mime_types(); + $allowed = is_array($allowed) ? $allowed : []; - return array_intersect_key($mimes, is_array($allowed) ? $allowed : []); + $effective = []; + foreach ($mimes as $extensions => $mime) { + foreach (explode('|', (string) $extensions) as $extension) { + $extension = strtolower(trim($extension)); + if ($extension === '' || $allowed === []) { + continue; + } + + $core = wp_check_filetype('x.' . $extension, $allowed); + if (($core['type'] ?? false) === $mime) { + $effective[$extension] = (string) $mime; + } + } + } + + return $effective; } /** @@ -243,9 +312,9 @@ class MediaImporter } $type = strtolower(trim(explode(';', (string) wp_remote_retrieve_header($response, 'content-type'))[0])); - foreach ($this->allowedMimes() as $extensions => $mime) { + foreach ($this->allowedMimes() as $extension => $mime) { if ($mime === $type) { - return ($name !== '' ? $name : 'datei') . '.' . explode('|', $extensions)[0]; + return ($name !== '' ? $name : 'datei') . '.' . $extension; } } diff --git a/tests/Unit/Integrations/Media/MediaImporterTest.php b/tests/Unit/Integrations/Media/MediaImporterTest.php index 1931932..1fbb6f5 100644 --- a/tests/Unit/Integrations/Media/MediaImporterTest.php +++ b/tests/Unit/Integrations/Media/MediaImporterTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Media; +use Brain\Monkey\Actions; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Media\MediaImporter; @@ -14,6 +16,19 @@ use WP_Error; final class MediaImporterTest extends TestCase { + /** Key shapes as in WordPress core's wp_get_mime_types(). */ + private const CORE_MIMES = [ + 'jpg|jpeg|jpe' => 'image/jpeg', + 'gif' => 'image/gif', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'htm|html' => 'text/html', + 'mp3|m4a|m4b' => 'audio/mpeg', + 'mp4|m4v' => 'video/mp4', + 'pdf' => 'application/pdf', + ]; + /** @var list */ private array $tempFiles = []; @@ -34,7 +49,16 @@ final class MediaImporterTest extends TestCase Functions\when('sanitize_file_name')->returnArg(); Functions\when('wp_basename')->alias('basename'); Functions\when('wp_parse_url')->alias('parse_url'); - Functions\when('get_allowed_mime_types')->justReturn(MediaImporter::MIMES); + Functions\when('get_allowed_mime_types')->justReturn(self::CORE_MIMES); + Functions\when('wp_check_filetype')->alias(static function (string $filename, array $mimes): array { + foreach ($mimes as $extensions => $mime) { + if (preg_match('!\.(' . $extensions . ')$!i', $filename, $match)) { + return ['ext' => $match[1], 'type' => $mime]; + } + } + + return ['ext' => false, 'type' => false]; + }); } protected function tearDown(): void @@ -299,4 +323,96 @@ final class MediaImporterTest extends TestCase self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); $this->assertTempFilesRemoved(); } + + public function testTheWhitelistIsMatchedPerExtensionAgainstCoreKeys(): void + { + Functions\expect('wp_check_filetype_and_ext')->once() + ->with(Mockery::type('string'), 'song.mp3', Mockery::on(static function (array $mimes): bool { + return ($mimes['mp3'] ?? null) === 'audio/mpeg' + && ($mimes['jpeg'] ?? null) === 'image/jpeg' + && ($mimes['m4v'] ?? null) === 'video/mp4' + && ! isset($mimes['m4a'], $mimes['html'], $mimes['htm']); + })) + ->andReturn(['ext' => 'mp3', 'type' => 'audio/mpeg', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(60); + + self::assertSame(60, $this->importer()->fromBase64(base64_encode('ID3DATA'), 'song.mp3', [])); + } + + public function testAnEmptyWhitelistRejectsEverythingInsteadOfFallingBackToAllTypes(): void + { + Functions\when('get_allowed_mime_types')->justReturn(['htm|html' => 'text/html']); + Functions\expect('wp_check_filetype_and_ext')->never(); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromBase64(base64_encode(''), 'seite.html', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + $this->assertTempFilesRemoved(); + } + + public function testTheMovedFileIsRemovedWhenTheAttachmentCannotBeCreated(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + $record = null; + Filters\expectAdded('wp_handle_upload')->once()->whenHappen(static function (callable $callback) use (&$record): void { + $record = $callback; + }); + $moved = (string) tempnam(sys_get_temp_dir(), 'lit-moved'); + $this->tempFiles[] = $moved; + Functions\when('media_handle_sideload')->alias(static function () use (&$record, $moved): WP_Error { + $record(['file' => $moved, 'url' => 'https://kunde.de/x.png', 'type' => 'image/png'], 'sideload'); + return new WP_Error('db_insert_error', 'Datenbankfehler'); + }); + + $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); + + self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertFileDoesNotExist($moved); + self::assertFalse(has_filter('wp_handle_upload')); + } + + public function testTheSavedFileIsKeptOnSuccess(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + $record = null; + Filters\expectAdded('wp_handle_upload')->once()->whenHappen(static function (callable $callback) use (&$record): void { + $record = $callback; + }); + $moved = (string) tempnam(sys_get_temp_dir(), 'lit-moved'); + $this->tempFiles[] = $moved; + Functions\when('media_handle_sideload')->alias(static function () use (&$record, $moved): int { + $record(['file' => $moved], 'sideload'); + return 61; + }); + + self::assertSame(61, $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', [])); + self::assertFileExists($moved); + } + + public function testCurlIsRestrictedToIpv4ForTheImportRequestOnly(): void + { + $this->respond('PNGDATA', 200); + Actions\expectAdded('http_api_curl')->once()->whenHappen(static function (callable $callback): void { + if (function_exists('curl_init')) { + $callback(curl_init()); // Must accept a real cURL handle without error. + } + }); + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]); + + $this->importer()->fromUrl('https://example.com/logo.png', null, []); + + self::assertFalse(has_action('http_api_curl')); + } + + public function testAMissingTempFileNameIsAWriteFailure(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\when('wp_tempnam')->justReturn(''); + Functions\expect('wp_safe_remote_get')->never(); + + $result = $this->importer()->fromUrl('https://example.com/logo.png', null, []); + + self::assertSame('lit_write_failed', $result->get_error_code()); + } } From 931ddeb137155156b8854eb6e781d7c46424e194 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:49:41 +0200 Subject: [PATCH 065/185] feat: batch media metadata updates and delete with usage check lit/media-update validates the whole batch first (edit_post, lengths, optional modified lock) and restores already written items when a write fails midway. lit/media-delete requires confirm, refuses used media without force and reports refused deletes as write_failed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Media/MediaDeleteAbility.php | 168 +++++++ src/Integrations/Media/MediaIntegration.php | 4 +- src/Integrations/Media/MediaMetaWriter.php | 130 ++++++ src/Integrations/Media/MediaPresenter.php | 3 + src/Integrations/Media/MediaUpdateAbility.php | 409 ++++++++++++++++++ .../Media/MediaDeleteAbilityTest.php | 158 +++++++ .../Media/MediaIntegrationTest.php | 2 + .../Integrations/Media/MediaPresenterTest.php | 1 + .../Media/MediaUpdateAbilityTest.php | 271 ++++++++++++ 9 files changed, 1145 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Media/MediaDeleteAbility.php create mode 100644 src/Integrations/Media/MediaMetaWriter.php create mode 100644 src/Integrations/Media/MediaUpdateAbility.php create mode 100644 tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php create mode 100644 tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php diff --git a/src/Integrations/Media/MediaDeleteAbility.php b/src/Integrations/Media/MediaDeleteAbility.php new file mode 100644 index 0000000..3b86c5a --- /dev/null +++ b/src/Integrations/Media/MediaDeleteAbility.php @@ -0,0 +1,168 @@ +mediaTrash = $mediaTrash ?? static fn (): bool => defined('MEDIA_TRASH') && MEDIA_TRASH + && defined('EMPTY_TRASH_DAYS') && EMPTY_TRASH_DAYS; + } + + public function name(): string + { + return 'lit/media-delete'; + } + + public function label(): string + { + return 'Medium löschen'; + } + + public function description(): string + { + return 'Löscht ein Medium samt Datei und allen Bildgrößen – ohne aktivierten Medien-Papierkorb ' + . '(WordPress-Standard) endgültig, das lässt sich nicht rückgängig machen. Ohne confirm: true kommt ' + . 'eine Rückfrage (confirm_required) mit den gefundenen Verwendungen; erst nach Zustimmung des Nutzers ' + . 'mit confirm: true erneut aufrufen. Wird das Medium verwendet (auch nur "möglicherweise"), lehnt das ' + . 'Tool mit in_use ab, außer force: true. Medien im Papierkorb werden nicht gelöscht. ' + . 'Die Verwendungsprüfung findet NICHT: Term-Meta, Benutzer-Meta, Pods-Tabellen (pods_*, podsrel), ' + . 'ACF-Felder (außer als Bildfeld im Schema mit einer ID pro Meta-Eintrag), serialisierte oder ' + . 'kommagetrennte ID-Listen in Meta-Feldern und Optionen anderer Plugins – im Zweifel den Nutzer fragen.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'confirm' => [ + 'type' => 'boolean', + 'description' => 'Erst nach Rückfrage beim Nutzer true setzen.', + ], + 'force' => ['type' => 'boolean', 'description' => 'Trotz gefundener Verwendungen löschen.'], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema( + ['usages' => ['type' => 'array', 'items' => MediaUsageFinder::usageSchema()]], + ['usages'] + ); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => true, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post'); + + return $attachment instanceof WP_Error ? $attachment : true; + } + + public function execute(array $input): mixed + { + $attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post'); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + $title = (string) $attachment->post_title; + $trash = ($this->mediaTrash)(); + // Same search as MediaUsageFinder::isUsed(): certain and possible usages both count. + $usages = $this->finder->usages($id); + + if (($input['confirm'] ?? false) !== true) { + return Errors::confirmRequired($this->question($id, $title, $trash, $usages), ['usages' => $usages]); + } + + if ($usages !== [] && ($input['force'] ?? false) !== true) { + return Errors::inUse( + sprintf( + 'Medium "%s" wird noch verwendet (%d Stelle(n)); nichts wurde gelöscht.', + $title, + count($usages) + ), + $usages + ); + } + + if (! wp_delete_attachment($id, false)) { + return Errors::writeFailed(sprintf('WordPress hat das Löschen von Medium %d abgelehnt.', $id)); + } + + $warnings = []; + $summary = sprintf('Medium "%s" %s', $title, $trash ? 'in den Papierkorb gelegt' : 'gelöscht'); + $logId = FollowUp::run( + fn (): ?int => $this->log->record($this->name(), 'attachment', (string) $id, $summary), + $this->name() . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; das Medium ist trotzdem ' + . ($trash ? 'im Papierkorb.' : 'gelöscht.'), + $warnings + ); + + return ToolResult::ok($summary . '.') + ->changed((string) $id, 'attachment', $trash ? 'trashed' : 'deleted', $title) + ->logId(is_int($logId) ? $logId : null) + ->warnings($warnings) + ->with('usages', $usages) + ->toArray(); + } + + /** @param list> $usages */ + private function question(int $id, string $title, bool $trash, array $usages): string + { + $message = sprintf( + 'Medium "%s" (ID %d) %s?', + $title, + $id, + $trash + ? 'in den Papierkorb legen' + : 'endgültig löschen – samt Datei und allen Bildgrößen, nicht rückgängig zu machen' + ); + + if ($usages === []) { + return $message . ' Keine Verwendung gefunden (die Suche deckt nicht alle Speicherorte ab).'; + } + + $used = ' Es wird an %d Stelle(n) verwendet (siehe usages); löschen nur mit force: true.'; + + return $message . sprintf($used, count($usages)); + } +} diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php index 5bb34f1..a16171c 100644 --- a/src/Integrations/Media/MediaIntegration.php +++ b/src/Integrations/Media/MediaIntegration.php @@ -11,7 +11,7 @@ use LIT\MCP\Limits; /** * Media library tools (spec §6.3). Always available. $log and $limits serve the write - * tools (upload now, update and delete to follow). + * tools (upload, update, delete). */ final class MediaIntegration implements Integration { @@ -43,6 +43,8 @@ final class MediaIntegration implements Integration new MediaUploadFromUrlAbility($importer, $uploads), new MediaUploadAbility($importer, $uploads, $this->limits), new MediaDownloadAbility($this->limits), + new MediaUpdateAbility($this->log), + new MediaDeleteAbility($finder, $this->log), ]; } } diff --git a/src/Integrations/Media/MediaMetaWriter.php b/src/Integrations/Media/MediaMetaWriter.php new file mode 100644 index 0000000..0077bcc --- /dev/null +++ b/src/Integrations/Media/MediaMetaWriter.php @@ -0,0 +1,130 @@ + posts column. */ + public const COLUMNS = ['title' => 'post_title', 'caption' => 'post_excerpt', 'description' => 'post_content']; + + /** + * The current values of everything $changes will write. + * + * @param array $changes Sanitized values by input key (alt, title, caption, description). + * @return array{id: int, columns: array, alt_changed: bool, alt: ?string} + * alt null = there was no alt text (it is removed again on restore). + */ + public function snapshot(WP_Post $attachment, array $changes): array + { + $id = (int) $attachment->ID; + $columns = []; + foreach (self::columns($changes) as $column => $value) { + $columns[$column] = (string) $attachment->{$column}; + } + + $altChanged = array_key_exists('alt', $changes); + $alt = $altChanged && metadata_exists('post', $id, self::ALT_KEY) + ? (string) get_post_meta($id, self::ALT_KEY, true) + : null; + + return ['id' => $id, 'columns' => $columns, 'alt_changed' => $altChanged, 'alt' => $alt]; + } + + /** + * @param array $changes Sanitized values by input key. + * @param bool $touched Set to true as soon as something may have been written. + * @throws RuntimeException + */ + public function apply(int $id, array $changes, bool &$touched): void + { + $columns = self::columns($changes); + if ($columns !== []) { + // Set before the call: a throwing save_post hook runs after the row was written. + $touched = true; + $result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true); + if ($result instanceof WP_Error || ! $result) { + // wp_update_post() reports errors before it writes anything. + $touched = false; + throw new RuntimeException(sprintf( + 'Attachment %d not updated: %s', + $id, + $result instanceof WP_Error ? $result->get_error_message() : 'unknown error' + )); + } + } + + if (array_key_exists('alt', $changes)) { + $touched = true; + $this->saveAlt($id, $changes['alt']); + } + } + + /** + * @param array{id: int, columns: array, alt_changed: bool, alt: ?string} $snapshot + * @throws RuntimeException + */ + public function restore(array $snapshot): void + { + $id = $snapshot['id']; + if ($snapshot['columns'] !== []) { + $result = wp_update_post(wp_slash(array_merge($snapshot['columns'], ['ID' => $id])), true); + if ($result instanceof WP_Error || ! $result) { + throw new RuntimeException(sprintf( + 'Attachment %d not restored: %s', + $id, + $result instanceof WP_Error ? $result->get_error_message() : 'unknown error' + )); + } + } + + if (! $snapshot['alt_changed']) { + return; + } + + if ($snapshot['alt'] !== null) { + $this->saveAlt($id, $snapshot['alt']); + } elseif (! delete_post_meta($id, self::ALT_KEY) && metadata_exists('post', $id, self::ALT_KEY)) { + throw new RuntimeException(sprintf('Alt text of attachment %d not removed again.', $id)); + } + } + + /** + * @param array $changes + * @return array Column => value. + */ + private static function columns(array $changes): array + { + $columns = []; + foreach (self::COLUMNS as $key => $column) { + if (array_key_exists($key, $changes)) { + $columns[$column] = $changes[$key]; + } + } + + return $columns; + } + + /** @throws RuntimeException */ + private function saveAlt(int $id, string $alt): void + { + // update_post_meta() is also false when the value is already stored. + $stored = update_post_meta($id, self::ALT_KEY, wp_slash($alt)) !== false + || (string) get_post_meta($id, self::ALT_KEY, true) === $alt; + if (! $stored) { + throw new RuntimeException(sprintf('Alt text of attachment %d not saved.', $id)); + } + } +} diff --git a/src/Integrations/Media/MediaPresenter.php b/src/Integrations/Media/MediaPresenter.php index 22705e5..92c3319 100644 --- a/src/Integrations/Media/MediaPresenter.php +++ b/src/Integrations/Media/MediaPresenter.php @@ -29,6 +29,8 @@ class MediaPresenter 'height' => isset($meta['height']) ? (int) $meta['height'] : null, 'filesize' => isset($meta['filesize']) ? (int) $meta['filesize'] : null, 'date' => (string) $attachment->post_date, + // Optimistic lock for lit/media-update (post_modified_gmt, changes with every row write). + 'modified' => (string) $attachment->post_modified_gmt, ]; } @@ -77,6 +79,7 @@ class MediaPresenter 'height' => ['type' => ['integer', 'null']], 'filesize' => ['type' => ['integer', 'null']], 'date' => ['type' => 'string'], + 'modified' => ['type' => 'string'], ], 'required' => ['id', 'title', 'mime_type', 'url', 'alt'], ]; diff --git a/src/Integrations/Media/MediaUpdateAbility.php b/src/Integrations/Media/MediaUpdateAbility.php new file mode 100644 index 0000000..6283370 --- /dev/null +++ b/src/Integrations/Media/MediaUpdateAbility.php @@ -0,0 +1,409 @@ + 500, 'title' => 255, 'caption' => 2000, 'description' => 20000]; + + private const REASON = 'WordPress hat die Metadaten eines Mediums nicht gespeichert.'; + + private const REV_HINT = 'Die genannten Medien neu lesen (lit/media-get oder lit-media-search) und mit dem ' + . 'aktuellen modified erneut senden.'; + + public function __construct(private ChangeLog $log, private MediaMetaWriter $writer = new MediaMetaWriter()) + { + } + + public function name(): string + { + return 'lit/media-update'; + } + + public function label(): string + { + return 'Medien-Metadaten ändern'; + } + + public function description(): string + { + return 'Ändert Alt-Text, Titel, Bildunterschrift und Beschreibung von bis zu 50 Medien in einem Aufruf – ' + . 'alles oder nichts: Ist ein Eintrag ungültig (unbekannte ID, keine Berechtigung, Papierkorb, zu lang, ' + . 'nichts zu ändern, veraltetes modified), wird nichts gespeichert und failures nennt jeden fehlerhaften ' + . 'Eintrag. Optional pro Eintrag modified (Wert aus lit/media-get oder lit-media-search) mitsenden: ' + . 'Wurde das Medium seitdem geändert, lehnt das Tool mit rev_mismatch ab. modified ändert sich bei ' + . 'Titel, Bildunterschrift und Beschreibung, nicht bei einer reinen Alt-Text-Änderung. ' + . 'Das Ergebnis meldet jeden Eintrag (results, mit neuem modified).'; + } + + public function inputSchema(): array + { + $max = self::MAX_LENGTH; + $text = static fn (string $description, string $key): array => [ + 'type' => 'string', + 'maxLength' => $max[$key], + 'description' => $description, + ]; + + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'alt' => $text('Alt-Text (reiner Text).', 'alt'), + 'title' => $text('Titel in der Mediathek (reiner Text).', 'title'), + 'caption' => $text('Bildunterschrift (einfaches HTML erlaubt).', 'caption'), + 'description' => $text('Beschreibung (einfaches HTML erlaubt).', 'description'), + 'modified' => [ + 'type' => 'string', + 'description' => 'Optional: modified aus lit/media-get bzw. lit-media-search als ' + . 'Schutz vor parallelen Änderungen.', + ], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ], + ], + ], + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema([ + 'results' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'ok' => ['type' => 'boolean'], + 'error' => ['type' => ['string', 'null']], + 'log_id' => ['type' => ['integer', 'null']], + 'modified' => ['type' => ['string', 'null']], + ], + 'required' => ['id', 'ok', 'error', 'log_id', 'modified'], + ], + ], + ], ['results']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return false; + } + + /** edit_post per attachment is checked in execute() for every item. */ + public function permission(array $input): bool|WP_Error + { + return current_user_can('upload_files') + ? true + : Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).'); + } + + public function execute(array $input): mixed + { + $plan = $this->plan($input['items'] ?? null); + if ($plan instanceof WP_Error) { + return $plan; + } + + $this->write($plan); + + return $this->result($plan); + } + + /** + * Validates every item; nothing is written yet. + * + * @return list, snapshot: array{id: int, + * columns: array, alt_changed: bool, alt: ?string}}>|WP_Error + */ + private function plan(mixed $items): array|WP_Error + { + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + sprintf('items muss eine Liste mit 1 bis %d Einträgen sein.', self::MAX_ITEMS), + ['items' => sprintf('1–%d Einträge', self::MAX_ITEMS)] + ); + } + + $plan = []; + $failures = []; + $seen = []; + foreach ($items as $index => $item) { + $entry = $this->validate(is_array($item) ? $item : [], $seen); + if ($entry instanceof WP_Error) { + $failures[] = self::failure($index, is_array($item) ? $item : [], $entry); + continue; + } + + $plan[] = $entry; + } + + return $failures === [] ? $plan : self::refusal($failures, count($items)); + } + + /** + * @param array $item + * @param array $seen IDs of the items before. + * @return array{attachment: WP_Post, changes: array, snapshot: array{id: int, + * columns: array, alt_changed: bool, alt: ?string}}|WP_Error + */ + private function validate(array $item, array &$seen): array|WP_Error + { + $attachment = MediaGuard::attachment($item['id'] ?? null); + if ($attachment instanceof WP_Error) { + return $attachment; + } + + $id = (int) $attachment->ID; + $duplicate = isset($seen[$id]); + $seen[$id] = true; + + $changes = []; + foreach (self::MAX_LENGTH as $key => $max) { + if (! array_key_exists($key, $item)) { + continue; + } + if (! is_string($item[$key]) || mb_strlen($item[$key]) > $max) { + return Errors::invalidInput(sprintf('%s: Text mit höchstens %d Zeichen erwartet.', $key, $max)); + } + $changes[$key] = in_array($key, ['alt', 'title'], true) + ? sanitize_text_field($item[$key]) + : wp_kses_post($item[$key]); + } + + if ($changes === []) { + return Errors::invalidInput('Nichts zu ändern: alt, title, caption oder description angeben.'); + } + + if ($duplicate) { + return Errors::invalidInput(sprintf('Medium %d steht mehrfach in items.', $id)); + } + + if (array_key_exists('modified', $item) && $item['modified'] !== (string) $attachment->post_modified_gmt) { + return self::revMismatch($id); + } + + return [ + 'attachment' => $attachment, + 'changes' => $changes, + 'snapshot' => $this->writer->snapshot($attachment, $changes), + ]; + } + + /** + * @param list, snapshot: array{id: int, + * columns: array, alt_changed: bool, alt: ?string}}> $plan + * @throws WriteFailed When an item could not be saved. + */ + private function write(array $plan): void + { + $written = []; + foreach ($plan as $entry) { + $touched = false; + try { + $this->writer->apply((int) $entry['attachment']->ID, $entry['changes'], $touched); + } catch (Throwable $e) { + if ($touched) { + $written[] = $entry; + } + $this->rollBack($written, $e); + } + $written[] = $entry; + } + } + + /** + * Restores the items written so far, newest first. + * + * @param list, snapshot: array{id: int, + * columns: array, alt_changed: bool, alt: ?string}}> $written + * @throws WriteFailed Always – rolledBack tells whether everything was restored. + */ + private function rollBack(array $written, Throwable $cause): never + { + $unrestored = []; + $failures = []; + foreach (array_reverse($written) as $entry) { + try { + $this->writer->restore($entry['snapshot']); + } catch (Throwable $e) { + $unrestored[] = $entry['attachment']; + $failures[] = $e->getMessage(); + } + } + + $detail = 'Media update failed: ' . $cause->getMessage(); + if ($unrestored === []) { + throw new WriteFailed(self::REASON, true, null, null, $cause, $detail); + } + + $ids = []; + foreach (array_reverse($unrestored) as $attachment) { + $ids[] = (int) $attachment->ID; + $this->log->recordPartial( + $this->name(), + 'attachment', + (string) $attachment->ID, + sprintf('Metadaten von "%s": Änderung nur teilweise zurückgenommen', $attachment->post_title) + ); + } + + throw new WriteFailed( + self::REASON, + false, + null, + null, + $cause, + $detail . '; rollback incomplete: ' . implode('; ', $failures), + sprintf('Möglicherweise geändert: Medien %s (siehe changed_ids).', implode(', ', $ids)), + 'Den Aufruf nicht einfach wiederholen. Die unter changed_ids genannten Medien mit lit/media-get prüfen, ' + . 'die Metadaten ggf. korrigieren und dem Nutzer Bescheid geben.', + ['changed_ids' => $ids] + ); + } + + /** + * Everything is saved – change log and reading back are follow-up steps (warnings only). + * + * @param list, snapshot: array{id: int, + * columns: array, alt_changed: bool, alt: ?string}}> $plan + * @return array + */ + private function result(array $plan): array + { + $warnings = []; + $results = []; + $result = ToolResult::ok(sprintf('%1$d von %1$d Medien aktualisiert.', count($plan))); + $lastLogId = null; + + foreach ($plan as $entry) { + $attachment = $entry['attachment']; + $id = (int) $attachment->ID; + $summary = sprintf( + 'Metadaten von "%s" geändert: %s', + $attachment->post_title, + implode(', ', array_keys($entry['changes'])) + ); + $logId = FollowUp::run( + fn (): ?int => $this->log->record($this->name(), 'attachment', (string) $id, $summary), + $this->name() . ' change log', + sprintf( + 'Änderungsprotokoll für Medium %d konnte nicht geschrieben werden; die Änderung ist gespeichert.', + $id + ), + $warnings + ); + $logId = is_int($logId) ? $logId : null; + $lastLogId = $logId ?? $lastLogId; + $current = get_post($id); + + $results[] = [ + 'id' => $id, + 'ok' => true, + 'error' => null, + 'log_id' => $logId, + 'modified' => $current instanceof WP_Post ? (string) $current->post_modified_gmt : null, + ]; + $result->changed( + (string) $id, + 'attachment', + 'updated', + $entry['changes']['title'] ?? (string) $attachment->post_title + ); + } + + return $result->logId($lastLogId)->warnings($warnings)->with('results', $results)->toArray(); + } + + /** + * @param array $item + * @return array{index: int, id: ?int, code: string, error: string} + */ + private static function failure(int $index, array $item, WP_Error $error): array + { + $payload = json_decode($error->get_error_message(), true); + $id = $item['id'] ?? null; + + return [ + 'index' => $index, + 'id' => is_int($id) ? $id : null, + 'code' => (string) preg_replace('/^lit_/', '', (string) $error->get_error_code()), + 'error' => is_array($payload) ? (string) ($payload['message'] ?? '') : $error->get_error_message(), + ]; + } + + /** + * One error for the whole batch: the common code of all failures, else validation_failed. + * + * @param non-empty-list $failures + */ + private static function refusal(array $failures, int $total): WP_Error + { + $codes = array_unique(array_column($failures, 'code')); + $message = sprintf( + '%d von %d Einträgen fehlerhaft (siehe failures); nichts wurde gespeichert.', + count($failures), + $total + ); + + return match (count($codes) === 1 ? $codes[0] : 'mixed') { + 'forbidden' => Errors::make('forbidden', $message, 'Nur Medien ändern, die das verbundene Konto ' + . 'bearbeiten darf; die übrigen Einträge erneut senden.', 403, ['failures' => $failures]), + 'not_found' => Errors::make('not_found', $message, 'IDs prüfen (lit-media-search) und die Einträge ' + . 'korrigiert erneut senden.', 404, ['failures' => $failures]), + 'rev_mismatch' => Errors::make('rev_mismatch', $message, self::REV_HINT, 409, ['failures' => $failures]), + default => Errors::make('validation_failed', $message, 'Die unter failures genannten Einträge ' + . 'korrigieren und den ganzen Stapel erneut senden.', 400, ['failures' => $failures]), + }; + } + + private static function revMismatch(int $id): WP_Error + { + return Errors::make( + 'rev_mismatch', + sprintf('Medium %d wurde seit dem letzten Lesen geändert.', $id), + self::REV_HINT, + 409 + ); + } +} diff --git a/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php new file mode 100644 index 0000000..e3a484f --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php @@ -0,0 +1,158 @@ + 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher']]; + private const POSSIBLE = [['post_id' => 7, 'title' => 'Blog', 'post_type' => 'post', 'where' => 'content', 'certainty' => 'möglicherweise']]; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Foto'])); + Functions\when('current_user_can')->justReturn(true); + } + + private function ability(array $usages, ChangeLog $log, bool $trash = false): MediaDeleteAbility + { + $finder = Mockery::mock(MediaUsageFinder::class); + $finder->allows('usages')->with(33)->andReturn($usages); + + return new MediaDeleteAbility($finder, $log, static fn (): bool => $trash); + } + + public function testConfirmationIsRequiredAndMentionsUsages(): void + { + Functions\expect('wp_delete_attachment')->never(); + + $result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertStringContainsString('endgültig', $result->get_error_message()); + self::assertStringContainsString('1 Stelle', $result->get_error_message()); + self::assertSame(self::USAGE, $result->get_error_data()['usages']); + } + + public function testConfirmationMentionsTheTrashWhenMediaTrashIsOn(): void + { + $result = $this->ability([], Mockery::mock(ChangeLog::class), true)->execute(['id' => 33, 'confirm' => false]); + + self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertStringContainsString('Papierkorb', $result->get_error_message()); + } + + public function testUsedMediaNeedsForce(): void + { + Functions\expect('wp_delete_attachment')->never(); + + $result = $this->ability(self::USAGE, Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]); + + self::assertSame('lit_in_use', $result->get_error_code()); + self::assertSame(self::USAGE, $result->get_error_data()['usages']); + } + + public function testPossibleUsageAlsoNeedsForce(): void + { + Functions\expect('wp_delete_attachment')->never(); + + $result = $this->ability(self::POSSIBLE, Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]); + + self::assertSame('lit_in_use', $result->get_error_code()); + } + + public function testForceDeletesUsedMedia(): void + { + Functions\expect('wp_delete_attachment')->once()->with(33, false)->andReturn(new WP_Post(['ID' => 33])); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-delete', 'attachment', '33', 'Medium "Foto" gelöscht')->andReturn(50); + + $result = $this->ability(self::USAGE, $log)->execute(['id' => 33, 'confirm' => true, 'force' => true]); + + self::assertSame(['id' => '33', 'type' => 'attachment', 'action' => 'deleted', 'label' => 'Foto'], $result['changed'][0]); + self::assertSame(50, $result['log_id']); + self::assertSame(self::USAGE, $result['usages']); + } + + public function testUnusedMediaGoesToTheTrashWhenMediaTrashIsOn(): void + { + Functions\expect('wp_delete_attachment')->once()->with(33, false)->andReturn(new WP_Post(['ID' => 33])); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-delete', 'attachment', '33', 'Medium "Foto" in den Papierkorb gelegt')->andReturn(51); + + $result = $this->ability([], $log, true)->execute(['id' => 33, 'confirm' => true]); + + self::assertSame('trashed', $result['changed'][0]['action']); + } + + public function testRefusedDeleteIsAWriteFailure(): void + { + Functions\expect('wp_delete_attachment')->once()->andReturn(false); + + $result = $this->ability([], Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]); + + self::assertSame('lit_write_failed', $result->get_error_code()); + } + + public function testChangeLogFailureIsOnlyAWarning(): void + { + Functions\expect('wp_delete_attachment')->once()->andReturn(new WP_Post(['ID' => 33])); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andThrow(new RuntimeException('db down')); + + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + try { + $result = $this->ability([], $log)->execute(['id' => 33, 'confirm' => true]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertSame('deleted', $result['changed'][0]['action']); + self::assertNull($result['log_id']); + self::assertCount(1, $result['warnings']); + } + + public function testTrashedAttachmentIsRefused(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash'])); + Functions\expect('wp_delete_attachment')->never(); + + $ability = $this->ability([], Mockery::mock(ChangeLog::class)); + + self::assertSame('lit_validation_failed', $ability->permission(['id' => 33])->get_error_code()); + self::assertSame('lit_validation_failed', $ability->execute(['id' => 33, 'confirm' => true])->get_error_code()); + } + + public function testChecksDeletePostOnTheAttachment(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => ! ($cap === 'delete_post' && $id === 33)); + + $result = $this->ability([], Mockery::mock(ChangeLog::class))->permission(['id' => 33]); + + self::assertSame('lit_forbidden', $result->get_error_code()); + } + + public function testIsDestructiveAndDescribesWhatTheUsageCheckMisses(): void + { + $ability = $this->ability([], Mockery::mock(ChangeLog::class)); + + self::assertSame(['readonly' => false, 'destructive' => true, 'idempotent' => false], $ability->annotations()); + self::assertFalse($ability->isDirect()); + foreach (['endgültig', 'Term-Meta', 'Benutzer-Meta', 'Pods', 'ACF', 'kommagetrennt', 'Optionen anderer Plugins'] as $needle) { + self::assertStringContainsString($needle, $ability->description()); + } + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index 47a9fa4..8fe60c4 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -22,6 +22,8 @@ final class MediaIntegrationTest extends TestCase 'lit/media-upload-from-url', 'lit/media-upload', 'lit/media-download', + 'lit/media-update', + 'lit/media-delete', ]; public function testProvidesTheMediaAbilities(): void diff --git a/tests/Unit/Integrations/Media/MediaPresenterTest.php b/tests/Unit/Integrations/Media/MediaPresenterTest.php index 1be6b89..2f7c8b0 100644 --- a/tests/Unit/Integrations/Media/MediaPresenterTest.php +++ b/tests/Unit/Integrations/Media/MediaPresenterTest.php @@ -45,6 +45,7 @@ final class MediaPresenterTest extends TestCase self::assertSame('Porträt', $summary['alt']); self::assertSame(1600, $summary['width']); self::assertSame(123456, $summary['filesize']); + self::assertSame('2026-09-25 08:00:00', $summary['modified']); } public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void diff --git a/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php new file mode 100644 index 0000000..a883529 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php @@ -0,0 +1,271 @@ +> */ + private array $postUpdates = []; + + /** @var list */ + private array $altUpdates = []; + + protected function setUp(): void + { + parent::setUp(); + $this->postUpdates = []; + $this->altUpdates = []; + + $posts = [ + 33 => new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Foto', + 'post_excerpt' => 'Alt-Unterschrift', 'post_modified_gmt' => '2026-09-25 08:00:00']), + 34 => new WP_Post(['ID' => 34, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Logo', + 'post_modified_gmt' => '2026-09-25 09:00:00']), + 35 => new WP_Post(['ID' => 35, 'post_type' => 'attachment', 'post_status' => 'trash', 'post_title' => 'Alt']), + 5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Seite']), + ]; + Functions\when('get_post')->alias(static fn ($id) => $posts[(int) $id] ?? null); + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_kses_post')->alias(static fn ($value): string => strip_tags((string) $value, '')); + Functions\when('metadata_exists')->justReturn(true); + Functions\when('get_post_meta')->justReturn('alter Alt-Text'); + Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) { + $this->postUpdates[] = $data; + + return (int) $data['ID']; + }); + Functions\when('update_post_meta')->alias(function (int $id, string $key, $value) { + $this->altUpdates[] = [$id, $value]; + + return true; + }); + } + + private function log(int ...$ids): ChangeLog + { + $log = Mockery::mock(ChangeLog::class); + if ($ids !== []) { + $log->expects('record')->times(count($ids))->andReturn(...$ids); + } + + return $log; + } + + public function testUpdatesEveryItemAndReportsPerItem(): void + { + $log = Mockery::mock(ChangeLog::class); + $log->expects('record') + ->with('lit/media-update', 'attachment', '33', 'Metadaten von "Foto" geändert: alt, caption') + ->andReturn(41); + $log->expects('record') + ->with('lit/media-update', 'attachment', '34', 'Metadaten von "Logo" geändert: title') + ->andReturn(42); + + $result = (new MediaUpdateAbility($log))->execute(['items' => [ + ['id' => 33, 'alt' => 'Team vor dem Haus', 'caption' => 'Unser Team'], + ['id' => 34, 'title' => 'Firmenlogo', 'modified' => '2026-09-25 09:00:00'], + ]]); + + self::assertSame([['post_excerpt' => 'Unser xTeam', 'ID' => 33], ['post_title' => 'Firmenlogo', 'ID' => 34]], $this->postUpdates); + self::assertSame([[33, 'Team vor dem Haus']], $this->altUpdates); + self::assertSame(['id' => 33, 'ok' => true, 'error' => null, 'log_id' => 41, 'modified' => '2026-09-25 08:00:00'], $result['results'][0]); + self::assertSame(42, $result['results'][1]['log_id']); + self::assertSame(['id' => '34', 'type' => 'attachment', 'action' => 'updated', 'label' => 'Firmenlogo'], $result['changed'][1]); + self::assertSame('2 von 2 Medien aktualisiert.', $result['summary']); + } + + public function testAnInvalidItemRefusesTheWholeBatchAndNamesEveryFailure(): void + { + $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [ + ['id' => 33, 'alt' => 'ok'], + ['id' => 99, 'alt' => 'x'], + ['id' => 5, 'alt' => 'x'], + ['id' => 35, 'alt' => 'x'], + ]]); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('3 von 4', $result->get_error_message()); + self::assertStringContainsString('nichts wurde gespeichert', $result->get_error_message()); + $failures = $result->get_error_data()['failures']; + self::assertSame([1, 2, 3], array_column($failures, 'index')); + self::assertSame(['not_found', 'not_found', 'validation_failed'], array_column($failures, 'code')); + self::assertStringContainsString('nicht gefunden', $failures[0]['error']); + self::assertStringContainsString('Papierkorb', $failures[2]['error']); + self::assertSame([], $this->postUpdates); + self::assertSame([], $this->altUpdates); + } + + public function testFailuresOfOneKindKeepTheirCode(): void + { + $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 99, 'alt' => 'x']]]); + + self::assertSame('lit_not_found', $result->get_error_code()); + } + + public function testRejectsEmptyDuplicateAndOverlongItems(): void + { + $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [ + ['id' => 33], + ['id' => 34, 'alt' => str_repeat('a', 501)], + ['id' => 34, 'title' => 'doppelt'], + ['id' => 33, 'caption' => ['kein', 'Text']], + ]]); + + $failures = $result->get_error_data()['failures']; + self::assertSame([0, 1, 2, 3], array_column($failures, 'index')); + self::assertStringContainsString('Nichts zu ändern', $failures[0]['error']); + self::assertStringContainsString('alt', $failures[1]['error']); + self::assertStringContainsString('mehrfach', $failures[2]['error']); + self::assertStringContainsString('caption', $failures[3]['error']); + self::assertSame([], $this->postUpdates); + } + + public function testStaleModifiedRefusesTheBatchWithRevMismatch(): void + { + $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [ + ['id' => 33, 'alt' => 'neu', 'modified' => '2026-09-25 08:00:00'], + ['id' => 34, 'alt' => 'neu', 'modified' => '2026-09-24 07:00:00'], + ]]); + + self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertSame(1, $result->get_error_data()['failures'][0]['index']); + self::assertStringContainsString('lit/media-get', $result->get_error_data()['hint']); + self::assertSame([], $this->altUpdates); + } + + public function testAFailedWriteRestoresTheItemsAlreadyChanged(): void + { + Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) { + $this->postUpdates[] = $data; + + return $data['ID'] === 34 && ($data['post_title'] ?? '') === 'Neu' + ? new WP_Error('db', 'Datenbankfehler') + : (int) $data['ID']; + }); + + try { + (new MediaUpdateAbility($this->log()))->execute(['items' => [ + ['id' => 33, 'alt' => 'neu', 'caption' => 'neu'], + ['id' => 34, 'title' => 'Neu'], + ]]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertStringContainsString('Datenbankfehler', $e->getMessage()); + } + + // Item 34 was refused before anything was written; item 33 gets caption and alt back. + self::assertSame([ + ['post_excerpt' => 'neu', 'ID' => 33], + ['post_title' => 'Neu', 'ID' => 34], + ['post_excerpt' => 'Alt-Unterschrift', 'ID' => 33], + ], $this->postUpdates); + self::assertSame([[33, 'neu'], [33, 'alter Alt-Text']], $this->altUpdates); + } + + public function testAHookThrowingAfterTheWriteAlsoRestoresThatItem(): void + { + Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) { + $this->postUpdates[] = $data; + if (($data['post_title'] ?? '') === 'Neu') { + throw new RuntimeException('save_post hook failed'); + } + + return (int) $data['ID']; + }); + + $this->expectException(WriteFailed::class); + try { + (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 34, 'title' => 'Neu']]]); + } finally { + self::assertSame([['post_title' => 'Neu', 'ID' => 34], ['post_title' => 'Logo', 'ID' => 34]], $this->postUpdates); + } + } + + public function testRestoreRemovesAnAltTextThatDidNotExistBefore(): void + { + Functions\when('metadata_exists')->justReturn(false); + Functions\when('wp_update_post')->alias(static fn (array $data) => $data['ID'] === 34 + ? new WP_Error('db', 'Datenbankfehler') + : (int) $data['ID']); + Functions\expect('delete_post_meta')->once()->with(33, '_wp_attachment_image_alt')->andReturn(true); + + $this->expectException(WriteFailed::class); + (new MediaUpdateAbility($this->log()))->execute(['items' => [ + ['id' => 33, 'alt' => 'neu'], + ['id' => 34, 'title' => 'Neu'], + ]]); + } + + public function testAFailedRestoreIsAPartialWriteNamingTheChangedIds(): void + { + Functions\when('wp_update_post')->alias(static fn (array $data) => $data['ID'] === 34 + ? new WP_Error('db', 'Datenbankfehler') + : (int) $data['ID']); + Functions\when('update_post_meta')->alias(static function (int $id, string $key, $value) { + if ($value === 'alter Alt-Text') { + throw new RuntimeException('meta write failed'); + } + + return true; + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->once()->with('lit/media-update', 'attachment', '33', Mockery::type('string')); + + try { + (new MediaUpdateAbility($log))->execute(['items' => [ + ['id' => 33, 'alt' => 'neu'], + ['id' => 34, 'title' => 'Neu'], + ]]); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + $error = $e->toError(); + self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertSame([33], $error->get_error_data()['changed_ids']); + self::assertStringContainsString('33', $error->get_error_message()); + } + } + + public function testChangeLogFailureIsOnlyAWarning(): void + { + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->andThrow(new RuntimeException('db down')); + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + try { + $result = (new MediaUpdateAbility($log))->execute(['items' => [['id' => 33, 'alt' => 'neu']]]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertTrue($result['results'][0]['ok']); + self::assertNull($result['results'][0]['log_id']); + self::assertCount(1, $result['warnings']); + } + + public function testIsIdempotentIndirectAndNotDestructive(): void + { + $ability = new MediaUpdateAbility($this->log()); + + self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations()); + self::assertFalse($ability->isDirect()); + self::assertSame(50, $ability->inputSchema()['properties']['items']['maxItems']); + self::assertStringContainsString('modified', $ability->description()); + } +} From 7f136f6f4cb6dd825fbc86f655005eca0aefa223 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:54:37 +0200 Subject: [PATCH 066/185] fix: media usage check fails closed on database errors; refused deletes checked for partial state MediaUsageFinder treats a failed query (null result or last_error) as a possible usage and an incomplete scan, so media-delete refuses without force. A refused wp_delete_attachment() is write_failed only when the attachment is intact, otherwise partial_write. media-update: re-read hint on write_failed, normalized ids in failures, documented lock resolution and kses on restore. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Errors.php | 7 ++- src/Abilities/WriteFailed.php | 8 ++- src/Integrations/Media/MediaDeleteAbility.php | 38 +++++++++++- src/Integrations/Media/MediaMetaWriter.php | 4 ++ src/Integrations/Media/MediaUpdateAbility.php | 20 +++++-- src/Integrations/Media/MediaUsageFinder.php | 54 +++++++++++++++-- tests/Unit/Abilities/WriteFailedTest.php | 9 +++ .../Media/MediaDeleteAbilityTest.php | 52 +++++++++++++++- .../Media/MediaUpdateAbilityTest.php | 7 ++- .../Media/MediaUsageFinderTest.php | 59 +++++++++++++++++++ 10 files changed, 238 insertions(+), 20 deletions(-) diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index 74d5724..52576bf 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -102,14 +102,15 @@ final class Errors * A write failed and was rolled back completely. * * @param array $details Extra error details (e.g. a backup that stays). + * @param string|null $hint Replaces the default hint (e.g. "re-read before retrying"). */ - public static function writeFailed(string $reason, array $details = []): WP_Error + public static function writeFailed(string $reason, array $details = [], ?string $hint = null): WP_Error { return self::make( 'write_failed', trim('Speichern fehlgeschlagen: ' . $reason) . ' Nichts wurde gespeichert.', - 'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den Administrator ' - . 'informieren (Details im PHP-Error-Log der Website).', + $hint ?? 'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den ' + . 'Administrator informieren (Details im PHP-Error-Log der Website).', 500, $details ); diff --git a/src/Abilities/WriteFailed.php b/src/Abilities/WriteFailed.php index a557dc5..d4ef312 100644 --- a/src/Abilities/WriteFailed.php +++ b/src/Abilities/WriteFailed.php @@ -14,7 +14,8 @@ use WP_Error; * client-safe German text. The AbilityRegistrar logs the detail and returns toError(): * lit_write_failed when everything was rolled back, lit_partial_write otherwise. * $state/$hint customise the partial-write error for writes that are not about a post (see - * Errors::partialWrite()); $details are added to either error. + * Errors::partialWrite()); $details are added to either error. $rolledBackHint replaces the + * default hint of the write_failed error. */ final class WriteFailed extends RuntimeException { @@ -28,7 +29,8 @@ final class WriteFailed extends RuntimeException string $detail = '', public readonly ?string $state = null, public readonly ?string $hint = null, - public readonly array $details = [] + public readonly array $details = [], + public readonly ?string $rolledBackHint = null ) { $internal = $detail !== '' ? $detail : ($previous !== null ? $previous->getMessage() : $reason); parent::__construct( @@ -41,7 +43,7 @@ final class WriteFailed extends RuntimeException public function toError(): WP_Error { return $this->rolledBack - ? Errors::writeFailed($this->reason, $this->details) + ? Errors::writeFailed($this->reason, $this->details, $this->rolledBackHint) : Errors::partialWrite( $this->reason, $this->postId, diff --git a/src/Integrations/Media/MediaDeleteAbility.php b/src/Integrations/Media/MediaDeleteAbility.php index 3b86c5a..217481f 100644 --- a/src/Integrations/Media/MediaDeleteAbility.php +++ b/src/Integrations/Media/MediaDeleteAbility.php @@ -10,6 +10,7 @@ use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\ChangeLog\ChangeLog; use WP_Error; +use WP_Post; /** * lit/media-delete: deletes one attachment after confirmation. WordPress has no media trash by @@ -123,8 +124,9 @@ final class MediaDeleteAbility implements Ability ); } + $file = (string) get_post_meta($id, '_wp_attached_file', true); if (! wp_delete_attachment($id, false)) { - return Errors::writeFailed(sprintf('WordPress hat das Löschen von Medium %d abgelehnt.', $id)); + return $this->refused($id, $title, $file); } $warnings = []; @@ -145,6 +147,40 @@ final class MediaDeleteAbility implements Ability ->toArray(); } + /** + * wp_delete_attachment() returned false/null. It deletes meta, terms and the row before the + * files, so a failure can leave half a deletion: only an attachment that is still there with + * its file reference intact counts as "nothing deleted". + */ + private function refused(int $id, string $title, string $file): WP_Error + { + $reason = sprintf('WordPress hat das Löschen von Medium %d abgelehnt.', $id); + $current = get_post($id); + $intact = $current instanceof WP_Post + && $current->post_type === 'attachment' + && (string) get_post_meta($id, '_wp_attached_file', true) === $file; + if ($intact) { + return Errors::writeFailed($reason); + } + + $this->log->recordPartial( + $this->name(), + 'attachment', + (string) $id, + sprintf('Medium "%s": Löschen nur teilweise ausgeführt', $title) + ); + + return Errors::partialWrite( + $reason, + null, + null, + sprintf('Medium %d ist möglicherweise teilweise gelöscht (Daten oder Dateien fehlen).', $id), + 'Den Aufruf nicht einfach wiederholen. Das Medium mit lit/media-get bzw. in der WordPress-Mediathek ' + . 'prüfen und dem Nutzer Bescheid geben.', + ['attachment_id' => $id] + ); + } + /** @param list> $usages */ private function question(int $id, string $title, bool $trash, array $usages): string { diff --git a/src/Integrations/Media/MediaMetaWriter.php b/src/Integrations/Media/MediaMetaWriter.php index 0077bcc..6eaf608 100644 --- a/src/Integrations/Media/MediaMetaWriter.php +++ b/src/Integrations/Media/MediaMetaWriter.php @@ -12,6 +12,10 @@ use WP_Post; * Writes the editable metadata of an attachment (alt text, title, caption, description) and * restores it from a snapshot taken before (lit/media-update rolls a batch back with it). * Every failure is a RuntimeException with an internal message for the PHP error log. + * + * Both apply() and restore() save through wp_update_post(), which runs the kses filters again + * for users without unfiltered_html: a caption or description with markup the current user may + * not save comes back filtered on restore (like any save of that user in the media library). */ final class MediaMetaWriter { diff --git a/src/Integrations/Media/MediaUpdateAbility.php b/src/Integrations/Media/MediaUpdateAbility.php index 6283370..896a57a 100644 --- a/src/Integrations/Media/MediaUpdateAbility.php +++ b/src/Integrations/Media/MediaUpdateAbility.php @@ -10,6 +10,7 @@ use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\PostGuard; use Throwable; use WP_Error; use WP_Post; @@ -21,7 +22,8 @@ use WP_Post; * changed are restored (write_failed) – or, if that fails too, partial_write names them. * * The `modified` lock compares post_modified_gmt, which WordPress bumps on every write of the - * posts row (title, caption, description) but not on a pure alt-text (post meta) change. + * posts row (title, caption, description) but not on a pure alt-text (post meta) change. Its + * resolution is one second: a parallel write within the same second as the read goes unnoticed. */ final class MediaUpdateAbility implements Ability { @@ -274,7 +276,17 @@ final class MediaUpdateAbility implements Ability $detail = 'Media update failed: ' . $cause->getMessage(); if ($unrestored === []) { - throw new WriteFailed(self::REASON, true, null, null, $cause, $detail); + throw new WriteFailed( + self::REASON, + true, + null, + null, + $cause, + $detail, + rolledBackHint: 'Das Zurücksetzen hat modified der Medien geändert: vor einem neuen Versuch die Medien ' + . 'neu lesen (lit/media-get oder lit-media-search) und den Stapel mit dem aktuellen modified ' + . 'senden; tritt der Fehler wieder auf, den Administrator informieren (Details im PHP-Error-Log).' + ); } $ids = []; @@ -362,11 +374,9 @@ final class MediaUpdateAbility implements Ability private static function failure(int $index, array $item, WP_Error $error): array { $payload = json_decode($error->get_error_message(), true); - $id = $item['id'] ?? null; - return [ 'index' => $index, - 'id' => is_int($id) ? $id : null, + 'id' => PostGuard::id($item['id'] ?? null), 'code' => (string) preg_replace('/^lit_/', '', (string) $error->get_error_code()), 'error' => is_array($payload) ? (string) ($payload['message'] ?? '') : $error->get_error_message(), ]; diff --git a/src/Integrations/Media/MediaUsageFinder.php b/src/Integrations/Media/MediaUsageFinder.php index 1042351..0f6d3f2 100644 --- a/src/Integrations/Media/MediaUsageFinder.php +++ b/src/Integrations/Media/MediaUsageFinder.php @@ -34,9 +34,10 @@ class MediaUsageFinder public const POSSIBLE = 'möglicherweise'; public const UNREADABLE_TITLE = '(nicht lesbar)'; public const INCOMPLETE_TITLE = 'Inhaltssuche unvollständig – weitere Verwendungen möglich'; + public const FAILED_TITLE = 'Verwendungsprüfung fehlgeschlagen – Verwendungen unbekannt'; public const WHERE = [ 'featured', 'field', 'content', 'site_icon', 'custom_logo', 'divi_logo', 'header_image', - 'background_image', 'widget', + 'background_image', 'widget', 'unknown', ]; /** Max. entries whose content is inspected per call. */ @@ -62,6 +63,9 @@ class MediaUsageFinder /** * @param Closure(): list $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()). */ + /** Set by results() when a query of the current collect() run failed. */ + private bool $queryFailed = false; + public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null) { } @@ -109,16 +113,21 @@ class MediaUsageFinder $found = []; $complete = true; + $this->queryFailed = false; $this->addRows($found, $this->featured($wpdb, $attachmentId, $limit), 'featured'); + $this->addFailure($found); if (count($found) < $limit) { $this->addRows($found, $this->fields($wpdb, $attachmentId, $limit), 'field'); + $this->addFailure($found); } if (count($found) < $limit) { $this->addSiteUsages($found, $attachmentId, $fileRegex); } if (count($found) < $limit) { $complete = $this->addContentUsages($found, $wpdb, $attachmentId, $stem, $fileRegex, $limit); + $this->addFailure($found); } + $complete = $complete && ! $this->queryFailed; return [ 'usages' => array_slice(array_values($found), 0, $max), @@ -127,6 +136,41 @@ class MediaUsageFinder ]; } + /** + * A failed query may have hidden usages: add one "possible" entry so the attachment never + * looks unused (media-delete then needs force, the unused filter skips it). + * + * @param array> $found + */ + private function addFailure(array &$found): void + { + if ($this->queryFailed && ! isset($found['check:failed'])) { + $found['check:failed'] = self::siteUsage(self::FAILED_TITLE, 'check', 'unknown', self::POSSIBLE); + } + } + + /** + * get_results() that notices failures: a null result or a database error marks the + * current scan as failed (see addFailure()) instead of passing for "no rows". + * + * @return list + */ + private function results(\wpdb $wpdb, ?string $sql): array + { + $wpdb->last_error = ''; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- every caller passes $wpdb->prepare() output. + $rows = is_string($sql) && $sql !== '' ? $wpdb->get_results($sql) : null; + if (is_array($rows) && (string) $wpdb->last_error === '') { + return array_values($rows); + } + + $this->queryFailed = true; + $message = sprintf('[wp-lit-mcp] media usage query failed: %s', (string) $wpdb->last_error); + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + + return []; + } + /** @return array JSON Schema of one entry of usages(). */ public static function usageSchema(): array { @@ -149,7 +193,7 @@ class MediaUsageFinder $statuses = self::EXCLUDED_STATUSES; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names and a constant. - return (array) $wpdb->get_results($wpdb->prepare( + return $this->results($wpdb, $wpdb->prepare( "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %s @@ -172,7 +216,7 @@ class MediaUsageFinder $statuses = self::EXCLUDED_STATUSES; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders holds one %s per key. - return (array) $wpdb->get_results($wpdb->prepare( + return $this->results($wpdb, $wpdb->prepare( "SELECT DISTINCT p.ID, p.post_title, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key IN ({$placeholders}) AND pm.meta_value = %s @@ -340,7 +384,7 @@ class MediaUsageFinder $statuses = self::EXCLUDED_STATUSES; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only placeholders, table names, a constant. - return (array) $wpdb->get_results($wpdb->prepare( + return $this->results($wpdb, $wpdb->prepare( "SELECT p.ID, p.post_title, p.post_type FROM {$wpdb->posts} p WHERE p.post_type NOT IN ('revision', 'attachment', 'nav_menu_item') AND p.post_status NOT IN ({$statuses}) @@ -359,7 +403,7 @@ class MediaUsageFinder $placeholders = implode(', ', array_fill(0, count($ids), '%d')); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders holds one %d per ID. - $rows = (array) $wpdb->get_results($wpdb->prepare( + $rows = $this->results($wpdb, $wpdb->prepare( "SELECT p.ID, p.post_content FROM {$wpdb->posts} p WHERE p.ID IN ({$placeholders})", ...$ids )); diff --git a/tests/Unit/Abilities/WriteFailedTest.php b/tests/Unit/Abilities/WriteFailedTest.php index a42a209..7babd6a 100644 --- a/tests/Unit/Abilities/WriteFailedTest.php +++ b/tests/Unit/Abilities/WriteFailedTest.php @@ -21,6 +21,15 @@ final class WriteFailedTest extends TestCase self::assertSame('lit_write_failed', $failure->toError()->get_error_code()); } + public function testRolledBackHintReplacesTheDefaultHintOfWriteFailedOnly(): void + { + $clean = new WriteFailed('x', true, rolledBackHint: 'Erst neu lesen.'); + $partial = new WriteFailed('x', false, rolledBackHint: 'Erst neu lesen.'); + + self::assertSame('Erst neu lesen.', $clean->toError()->get_error_data()['hint']); + self::assertNotSame('Erst neu lesen.', $partial->toError()->get_error_data()['hint']); + } + public function testIncompleteRollbackMapsToPartialWriteWithTheIds(): void { $error = (new WriteFailed('Felder konnten nicht gespeichert werden.', false, 5, 811))->toError(); diff --git a/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php index e3a484f..42eb80c 100644 --- a/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php @@ -23,6 +23,7 @@ final class MediaDeleteAbilityTest extends TestCase parent::setUp(); Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Foto'])); Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_meta')->justReturn('2026/09/foto.jpg'); } private function ability(array $usages, ChangeLog $log, bool $trash = false): MediaDeleteAbility @@ -96,8 +97,9 @@ final class MediaDeleteAbilityTest extends TestCase self::assertSame('trashed', $result['changed'][0]['action']); } - public function testRefusedDeleteIsAWriteFailure(): void + public function testRefusedDeleteWithTheAttachmentIntactIsAWriteFailure(): void { + Functions\when('get_post_meta')->justReturn('2026/09/foto.jpg'); Functions\expect('wp_delete_attachment')->once()->andReturn(false); $result = $this->ability([], Mockery::mock(ChangeLog::class))->execute(['id' => 33, 'confirm' => true]); @@ -105,6 +107,54 @@ final class MediaDeleteAbilityTest extends TestCase self::assertSame('lit_write_failed', $result->get_error_code()); } + public function testRefusedDeleteThatRemovedDataIsAPartialWrite(): void + { + $meta = '2026/09/foto.jpg'; + Functions\when('get_post_meta')->alias(static function () use (&$meta): string { + return $meta; + }); + Functions\expect('wp_delete_attachment')->once()->andReturnUsing(static function () use (&$meta) { + $meta = ''; + + return null; + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->once()->with('lit/media-delete', 'attachment', '33', Mockery::type('string')); + + $result = $this->ability([], $log)->execute(['id' => 33, 'confirm' => true]); + + self::assertSame('lit_partial_write', $result->get_error_code()); + self::assertSame(33, $result->get_error_data()['attachment_id']); + } + + public function testADatabaseErrorInTheUsageCheckRefusesWithoutForce(): void + { + Functions\when('get_post_meta')->justReturn('2026/09/foto.jpg'); + Functions\when('get_option')->justReturn(false); + Functions\when('get_theme_mod')->justReturn(false); + Functions\expect('wp_delete_attachment')->never(); + /** @var \Mockery\MockInterface&\wpdb $db */ + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('get_results')->andReturn(null); + $ability = new MediaDeleteAbility( + new MediaUsageFinder(static fn (): array => [], $db), + Mockery::mock(ChangeLog::class), + static fn (): bool => false + ); + + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + try { + $result = $ability->execute(['id' => 33, 'confirm' => true]); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertSame('lit_in_use', $result->get_error_code()); + self::assertSame(MediaUsageFinder::FAILED_TITLE, $result->get_error_data()['usages'][0]['title']); + } + public function testChangeLogFailureIsOnlyAWarning(): void { Functions\expect('wp_delete_attachment')->once()->andReturn(new WP_Post(['ID' => 33])); diff --git a/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php index a883529..b472903 100644 --- a/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php @@ -102,17 +102,19 @@ final class MediaUpdateAbilityTest extends TestCase $failures = $result->get_error_data()['failures']; self::assertSame([1, 2, 3], array_column($failures, 'index')); self::assertSame(['not_found', 'not_found', 'validation_failed'], array_column($failures, 'code')); + self::assertSame([99, 5, 35], array_column($failures, 'id')); self::assertStringContainsString('nicht gefunden', $failures[0]['error']); self::assertStringContainsString('Papierkorb', $failures[2]['error']); self::assertSame([], $this->postUpdates); self::assertSame([], $this->altUpdates); } - public function testFailuresOfOneKindKeepTheirCode(): void + public function testFailuresOfOneKindKeepTheirCodeAndReportTheNormalizedId(): void { - $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 99, 'alt' => 'x']]]); + $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => '99', 'alt' => 'x']]]); self::assertSame('lit_not_found', $result->get_error_code()); + self::assertSame(99, $result->get_error_data()['failures'][0]['id']); } public function testRejectsEmptyDuplicateAndOverlongItems(): void @@ -165,6 +167,7 @@ final class MediaUpdateAbilityTest extends TestCase } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertStringContainsString('neu lesen', $e->toError()->get_error_data()['hint']); self::assertStringContainsString('Datenbankfehler', $e->getMessage()); } diff --git a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php index 979b388..72510e7 100644 --- a/tests/Unit/Integrations/Media/MediaUsageFinderTest.php +++ b/tests/Unit/Integrations/Media/MediaUsageFinderTest.php @@ -316,4 +316,63 @@ final class MediaUsageFinderTest extends TestCase self::assertStringNotContainsString('foto', $this->queries[1]); self::assertStringNotContainsString("'%/%'", $this->queries[1]); } + + /** + * A finder whose query $failing (SQL fragment) fails – returns null or sets last_error. + */ + private function failingFinder(string $failing, bool $viaLastError): MediaUsageFinder + { + Functions\when('get_post_meta')->justReturn('2026/09/foto.jpg'); + Functions\when('get_option')->justReturn(false); + Functions\when('get_theme_mod')->justReturn(false); + + /** @var MockInterface&\wpdb $db */ + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('get_results')->andReturnUsing(static function (string $sql) use ($db, $failing, $viaLastError) { + if (! str_contains($sql, $failing)) { + return []; + } + if ($viaLastError) { + $db->last_error = 'Lost connection to MySQL server'; + + return []; + } + + return null; + }); + + return new MediaUsageFinder(static fn (): array => ['photo'], $db); + } + + /** @return mixed Result of $run with error_log() redirected (a PHP internal, not stubbable). */ + private static function quietly(callable $run): mixed + { + $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $file); + try { + return $run(); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + } + + public function testAFailedQueryNeverReportsUnused(): void + { + foreach (['_thumbnail_id', 'pm.meta_key IN', 'post_content LIKE'] as $failing) { + foreach ([true, false] as $viaLastError) { + $label = $failing . ($viaLastError ? ' (last_error)' : ' (null)'); + + self::assertTrue(self::quietly(fn () => $this->failingFinder($failing, $viaLastError)->isUsed(33)), $label); + + $scan = self::quietly(fn () => $this->failingFinder($failing, $viaLastError)->scan(33)); + self::assertFalse($scan['complete'], $label); + self::assertSame( + [['post_id' => 0, 'title' => MediaUsageFinder::FAILED_TITLE, 'post_type' => 'check', 'where' => 'unknown', 'certainty' => 'möglicherweise']], + $scan['usages'], + $label + ); + } + } + } } From 2f8a9f0c618aff1c3960ae4bb0dbd5046d9e5c91 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 03:03:25 +0200 Subject: [PATCH 067/185] feat: configurable exposure policy for direct, indirect and disabled tools Introduces ExposurePolicy (option lit_mcp_exposure) deciding per ability whether it is a direct MCP tool, only reachable via mcp-adapter's execute-ability meta-tool, or disabled entirely (not registered with the Abilities API at all). Ability::isDirect() remains the default when no override applies. lit/guide can never be disabled or made indirect; lit/media-preview and lit/media-download (image/embedded-resource results) can never be made indirect, only direct or disabled. AbilityRegistrar and ServerRegistrar take an optional ExposurePolicy and use it to filter registered abilities / compute the direct tool list. IntegrationRegistry::directAbilityNames() is removed (superseded by ExposurePolicy::directNames()). InstructionsBuilder now also takes the policy and lists the currently direct tools by name (lit-), noting the rest are reachable via mcp-adapter-execute-ability as lit/. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/AbilityRegistrar.php | 5 +- src/Abilities/ExposurePolicy.php | 122 ++++++++++++++++++ src/Guides/InstructionsBuilder.php | 18 ++- src/Integrations/IntegrationRegistry.php | 13 -- src/Plugin.php | 10 +- src/Server/ServerRegistrar.php | 9 +- tests/Unit/Abilities/AbilityRegistrarTest.php | 14 ++ tests/Unit/Abilities/ExposurePolicyTest.php | 116 +++++++++++++++++ tests/Unit/Guides/InstructionsBuilderTest.php | 52 +++++++- .../Integrations/IntegrationRegistryTest.php | 1 - tests/Unit/Server/ServerRegistrarTest.php | 28 ++++ 11 files changed, 362 insertions(+), 26 deletions(-) create mode 100644 src/Abilities/ExposurePolicy.php create mode 100644 tests/Unit/Abilities/ExposurePolicyTest.php diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php index 01c6f78..6b14c6f 100644 --- a/src/Abilities/AbilityRegistrar.php +++ b/src/Abilities/AbilityRegistrar.php @@ -18,7 +18,7 @@ final class AbilityRegistrar { public const CATEGORY = 'lit-mcp'; - public function __construct(private IntegrationRegistry $registry) + public function __construct(private IntegrationRegistry $registry, private ?ExposurePolicy $policy = null) { } @@ -38,7 +38,8 @@ final class AbilityRegistrar public function registerAbilities(): void { - foreach ($this->registry->abilities() as $ability) { + $policy = $this->policy ?? new ExposurePolicy(); + foreach ($policy->enabled($this->registry->abilities()) as $ability) { wp_register_ability($ability->name(), $this->argsFor($ability)); } } diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php new file mode 100644 index 0000000..3f5443b --- /dev/null +++ b/src/Abilities/ExposurePolicy.php @@ -0,0 +1,122 @@ + $overrides Ability name => mode. */ + public function __construct(private array $overrides = []) + { + } + + public static function fromOption(): self + { + return new self(self::sanitize(get_option(self::OPTION, []))); + } + + public function mode(Ability $ability): string + { + $mode = $this->overrides[$ability->name()] ?? null; + + return is_string($mode) && self::isAllowed($ability->name(), $mode) ? $mode : $this->defaultMode($ability); + } + + public function defaultMode(Ability $ability): string + { + return $ability->isDirect() ? self::DIRECT : self::INDIRECT; + } + + /** + * @param list $abilities + * @return list + */ + public function enabled(array $abilities): array + { + return array_values(array_filter( + $abilities, + fn (Ability $ability): bool => $this->mode($ability) !== self::DISABLED + )); + } + + /** + * @param list $abilities + * @return list + */ + public function directNames(array $abilities): array + { + $names = []; + foreach ($abilities as $ability) { + if ($this->mode($ability) === self::DIRECT) { + $names[] = $ability->name(); + } + } + + return $names; + } + + public static function isAllowed(string $name, string $mode): bool + { + if (! in_array($mode, self::MODES, true)) { + return false; + } + + if (in_array($name, self::REQUIRED, true)) { + return $mode === self::DIRECT; + } + + if (in_array($name, self::DIRECT_ONLY, true)) { + return $mode !== self::INDIRECT; + } + + return true; + } + + /** + * Settings API sanitize callback for the option. + * + * @return array + */ + public static function sanitize(mixed $input): array + { + if (! is_array($input)) { + return []; + } + + $clean = []; + foreach ($input as $name => $mode) { + if ( + is_string($name) + && is_string($mode) + && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $name) === 1 + && self::isAllowed($name, $mode) + ) { + $clean[$name] = $mode; + } + } + ksort($clean); + + return $clean; + } +} diff --git a/src/Guides/InstructionsBuilder.php b/src/Guides/InstructionsBuilder.php index 3e3784a..8d092f3 100644 --- a/src/Guides/InstructionsBuilder.php +++ b/src/Guides/InstructionsBuilder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Guides; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\IntegrationRegistry; @@ -16,9 +17,15 @@ final class InstructionsBuilder /** @var (callable(): list)|null */ private $postTypeNames; - public function __construct(private IntegrationRegistry $registry, ?callable $postTypeNames = null) - { + private ExposurePolicy $policy; + + public function __construct( + private IntegrationRegistry $registry, + ?callable $postTypeNames = null, + ?ExposurePolicy $policy = null + ) { $this->postTypeNames = $postTypeNames; + $this->policy = $policy ?? new ExposurePolicy(); } public function build(): string @@ -28,9 +35,16 @@ final class InstructionsBuilder $this->registry->available() )); + $directNames = array_map( + static fn (string $name): string => 'lit-' . substr($name, strlen('lit/')), + $this->policy->directNames($this->registry->abilities()) + ); + $lines = [ 'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).', 'Aktive Integrationen: ' . $active . '.', + 'Direkt aufrufbare Tools: ' . implode(', ', $directNames) . '.', + 'Weitere Fähigkeiten über mcp-adapter-execute-ability (Ability-Name lit/).', ]; if ($this->postTypeNames !== null) { diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index 6364c0f..d9f7341 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -42,17 +42,4 @@ final class IntegrationRegistry return $this->abilities; } - - /** @return list */ - public function directAbilityNames(): array - { - $names = []; - foreach ($this->abilities() as $ability) { - if ($ability->isDirect()) { - $names[] = $ability->name(); - } - } - - return $names; - } } diff --git a/src/Plugin.php b/src/Plugin.php index f515a99..70f3e98 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP; use LIT\MCP\Abilities\AbilityRegistrar; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; @@ -54,10 +55,15 @@ final class Plugin new MediaIntegration($schema, $changeLog, $limits) ); - (new AbilityRegistrar($registry))->register(); + $policy = ExposurePolicy::fromOption(); + (new AbilityRegistrar($registry, $policy))->register(); (new ServerRegistrar( $registry, - new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames()) + new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy), + null, + null, + null, + $policy ))->register(); \WPMedia\MCP\OAuth\Bootstrap::instance(); diff --git a/src/Server/ServerRegistrar.php b/src/Server/ServerRegistrar.php index e950559..7516852 100644 --- a/src/Server/ServerRegistrar.php +++ b/src/Server/ServerRegistrar.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Server; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Plugin; @@ -48,13 +49,17 @@ final class ServerRegistrar /** @var callable(string): bool */ private $classExists; + private ExposurePolicy $policy; + public function __construct( private IntegrationRegistry $registry, private InstructionsBuilder $instructions, ?callable $oauthEnabled = null, ?callable $createAdapterServer = null, - ?callable $classExists = null + ?callable $classExists = null, + ?ExposurePolicy $policy = null ) { + $this->policy = $policy ?? new ExposurePolicy(); $this->oauthEnabled = $oauthEnabled ?? static fn (): bool => (new OAuthContext())->is_enabled(); $this->classExists = $classExists @@ -102,7 +107,7 @@ final class ServerRegistrar 'name' => 'LIT MCP', 'description' => $this->instructions->build(), 'version' => Plugin::VERSION, - 'tools' => array_merge($this->registry->directAbilityNames(), self::META_TOOLS), + 'tools' => array_merge($this->policy->directNames($this->registry->abilities()), self::META_TOOLS), ]; } diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index d58a19c..33b1ece 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Abilities; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\AbilityRegistrar; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\IntegrationRegistry; @@ -185,4 +186,17 @@ final class AbilityRegistrarTest extends TestCase $this->registrarFor($ability)->registerAbilities(); } + + public function testDisabledAbilitiesAreNotRegistered(): void + { + $ability = $this->ability(); + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn('core'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn([$ability]); + Functions\expect('wp_register_ability')->never(); + + (new AbilityRegistrar(new IntegrationRegistry($integration), new ExposurePolicy(['lit/test' => 'disabled']))) + ->registerAbilities(); + } } diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php new file mode 100644 index 0000000..e889cdb --- /dev/null +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -0,0 +1,116 @@ +allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn($direct); + + return $ability; + } + + public function testDefaultFollowsIsDirect(): void + { + $policy = new ExposurePolicy(); + + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testOverridesChangeTheMode(): void + { + $policy = new ExposurePolicy(['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled']); + + self::assertSame('indirect', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('disabled', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testImpossibleOrBrokenOverridesFallBackToTheDefault(): void + { + $policy = new ExposurePolicy([ + 'lit/media-preview' => 'indirect', + 'lit/media-download' => 'indirect', + 'lit/guide' => 'disabled', + 'lit/content-get' => 'sometimes', + ]); + + self::assertSame('direct', $policy->mode($this->ability('lit/media-preview', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/media-download', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + } + + public function testGuideCanNeitherBeDisabledNorMadeIndirect(): void + { + $policy = new ExposurePolicy(['lit/guide' => 'indirect']); + + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true))); + self::assertFalse(ExposurePolicy::isAllowed('lit/guide', 'indirect')); + self::assertFalse(ExposurePolicy::isAllowed('lit/guide', 'disabled')); + self::assertTrue(ExposurePolicy::isAllowed('lit/guide', 'direct')); + } + + public function testMediaPreviewAndDownloadMayStillBeDisabled(): void + { + $policy = new ExposurePolicy(['lit/media-preview' => 'disabled', 'lit/media-download' => 'disabled']); + + self::assertSame('disabled', $policy->mode($this->ability('lit/media-preview', true))); + self::assertSame('disabled', $policy->mode($this->ability('lit/media-download', true))); + } + + public function testEnabledAndDirectNamesKeepTheRegistryOrder(): void + { + $a = $this->ability('lit/a', true); + $b = $this->ability('lit/b', false); + $c = $this->ability('lit/c', true); + $policy = new ExposurePolicy(['lit/b' => 'direct', 'lit/c' => 'disabled']); + + self::assertSame([$a, $b], $policy->enabled([$a, $b, $c])); + self::assertSame(['lit/a', 'lit/b'], $policy->directNames([$a, $b, $c])); + } + + public function testSanitizeKeepsOnlyValidEntries(): void + { + self::assertSame( + ['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled'], + ExposurePolicy::sanitize([ + 'lit/menu-list' => 'disabled', + 'lit/content-get' => 'indirect', + 'lit/guide' => 'disabled', + '', 'summary' => 'böse Zusammenfassung']; + + self::assertStringNotContainsString('#is', '', $value); + } + + public function code(string $value): string + { + return $value; + } + + public function url(string $value): string + { + return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value); + } + }; + } + + public static function media(): MediaResolver + { + return new class implements MediaResolver { + public function image(int $id): ?array + { + return match ($id) { + 33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'], + 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''], + default => null, + }; + } + }; + } + + public static function placeholders(): Placeholders + { + return new Placeholders(DesignSystemTest::sample()); + } + + public static function attrWriter(): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`: +```php + */ + public static function placeholders(): array + { + return [ + 'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], + 'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + 'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'], + 'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'], + 'post title' => [ + '{{post:title}}', + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + ], + 'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'], + 'custom field' => [ + '{{field:telefon_mobil}}', + '$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$', + ], + 'plain text' => ['Hallo Welt', 'Hallo Welt'], + ]; + } + + /** @dataProvider placeholders */ + public function testResolvesToDiviVariables(string $input, string $expected): void + { + $resolved = ComposeFixture::placeholders()->resolve($input); + + self::assertSame($expected, $resolved); + self::assertSame($expected !== $input, Placeholders::isVariable($resolved)); + } + + /** @return array */ + public static function invalid(): array + { + return [ + 'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'], + 'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'], + 'unknown font' => ['{{font:mono}}', 'heading, body'], + 'unknown post value' => ['{{post:views}}', 'title, excerpt'], + 'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'], + 'embedded' => ['Seite: {{post:title}}', 'allein im Wert'], + ]; + } + + /** @dataProvider invalid */ + public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + ComposeFixture::placeholders()->resolve($input); + } + + public function testResolvesDeepValues(): void + { + self::assertSame( + ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'], + ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px']) + ); + self::assertFalse(Placeholders::isVariable('$variable(kaputt)$')); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php`: +```php + [ + 'innerContent' => ['desktop' => ['value' => 'Alt']], + 'decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']], + 'phone' => ['value' => ['size' => '22px']], + ]]], + ], + 'builderVersion' => '5.2.1', + ]; + + public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ + 'title.decoration.font.font' => [ + 'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']], + 'tablet' => ['size' => '26px'], + ], + 'title.innerContent' => ['desktop' => 'Neu'], + ], 'attrs'); + + self::assertSame([], $result['errors']); + self::assertSame( + [ + 'desktop' => [ + 'value' => ['headingLevel' => 'h3'], + 'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], + ], + 'phone' => ['value' => ['size' => '22px']], + 'tablet' => ['value' => ['size' => '26px']], + ], + $result['attrs']['title']['decoration']['font']['font'] + ); + self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']); + self::assertSame('5.2.1', $result['attrs']['builderVersion']); + } + + public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void + { + $writer = ComposeFixture::attrWriter(); + + $removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs'); + self::assertArrayNotHasKey('decoration', $removed['attrs']['title']); + + $phoneGone = $writer->apply('divi/heading', self::HEADING, [ + 'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']], + ], 'attrs'); + self::assertSame( + ['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]], + $phoneGone['attrs']['title']['decoration']['font']['font'] + ); + } + + public function testUnknownPathsGetSuggestions(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ + 'title.decoration.font' => ['desktop' => ['color' => '#fff']], + 'title.innerContent' => ['mobile' => 'x'], + ], 'changes[0].attrs'); + + self::assertSame(['title.decoration.font'], $result['invalid_paths']); + self::assertStringContainsString( + 'title.decoration.font.font', + $result['errors']['changes[0].attrs.title.decoration.font'] + ); + self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']); + self::assertSame(self::HEADING, $result['attrs']); + } + + public function testSanitizesByPlace(): void + { + $writer = ComposeFixture::attrWriter(); + + self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl')); + self::assertSame('

x

', $writer->cleanValue('divi/text', 'content.innerContent', '

x

')); + self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', '')); + self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px')); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + $writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$') + ); + self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x'])); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\Placeholders" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/Placeholders.php`: +```php +}}, {{var:}}, {{font:heading|body}}, {{post:title|…}}, + * {{field:}}. A placeholder must be the whole value; unknown names fail with the + * list of existing ones. Pure PHP. + */ +final class Placeholders +{ + /** {{post:}} => Divi dynamic content option. */ + public const POST = [ + 'title' => 'post_title', + 'excerpt' => 'post_excerpt', + 'date' => 'post_date', + 'modified' => 'post_modified_date', + 'author' => 'post_author', + 'url' => 'post_link_url', + 'featured_image' => 'post_featured_image', + 'categories' => 'post_categories', + 'tags' => 'post_tags', + ]; + + /** Dynamic content that is text and takes before/after settings. */ + private const TEXT_SETTINGS = [ + 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags', + ]; + + private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/'; + + public function __construct(private DesignSystem $design) + { + } + + /** Whether $value is a Divi variable reference ($variable({…})$), e.g. from an earlier read. */ + public static function isVariable(string $value): bool + { + return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1 + && is_array(json_decode($match[1], true)); + } + + /** + * Resolves $value when it is a placeholder; other strings are returned unchanged. + * + * @throws InvalidArgumentException With a German message for the client. + */ + public function resolve(string $value): string + { + if (preg_match(self::PATTERN, trim($value), $match) !== 1) { + if (str_contains($value, '{{')) { + throw new InvalidArgumentException( + 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, ' + . 'post, field).' + ); + } + + return $value; + } + + [, $kind, $name] = $match; + + return match ($kind) { + 'color' => $this->color($name), + 'var' => $this->variable($name), + 'font' => $this->font($name), + 'post' => $this->post($name), + default => $this->field($name), + }; + } + + /** + * Applies resolve() to every string inside $value (full-form values are nested). + * + * @throws InvalidArgumentException + */ + public function resolveDeep(mixed $value): mixed + { + if (is_string($value)) { + return $this->resolve($value); + } + + if (is_array($value)) { + return array_map([$this, 'resolveDeep'], $value); + } + + return $value; + } + + private function color(string $name): string + { + $id = $this->design->colorId($name); + if ($id === null) { + $ids = array_column($this->design->colors(), 'id'); + $known = array_merge( + array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)), + array_column($this->design->colors(), 'label') + ); + throw new InvalidArgumentException(sprintf( + 'Unbekannte globale Farbe "%s". Vorhanden: %s.', + $name, + implode(', ', array_unique($known)) + )); + } + + return self::variableString('color', $id, new stdClass()); + } + + private function variable(string $name): string + { + $variable = $this->design->variable($name); + if ($variable === null) { + $labels = array_column($this->design->variables(), 'label'); + throw new InvalidArgumentException(sprintf( + 'Unbekannte globale Variable "%s". Vorhanden: %s.', + $name, + $labels === [] ? '(keine)' : implode(', ', $labels) + )); + } + + $type = $variable['type'] === 'colors' ? 'color' : 'content'; + + return self::variableString($type, $variable['id'], new stdClass()); + } + + private function font(string $name): string + { + $id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null; + if ($id === null) { + throw new InvalidArgumentException( + sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name) + ); + } + + return self::variableString('content', $id, new stdClass()); + } + + private function post(string $name): string + { + $option = self::POST[strtolower($name)] ?? null; + if ($option === null) { + throw new InvalidArgumentException(sprintf( + 'Unbekannter Beitragswert "%s". Vorhanden: %s.', + $name, + implode(', ', array_keys(self::POST)) + )); + } + + $settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : new stdClass(); + + return self::variableString('content', $option, $settings); + } + + private function field(string $name): string + { + if (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1) { + throw new InvalidArgumentException(sprintf('Ungültiger Feldschlüssel "%s".', $name)); + } + + return self::variableString('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']); + } + + /** @param array|stdClass $settings */ + private static function variableString(string $type, string $name, array|stdClass $settings): string + { + $payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]; + + return '$variable(' . Json::encode($payload) . ')$'; + } +} +``` + +`src/Integrations/Divi/Compose/ValueSanitizer.php`: +```php + breakpoint map. Paths are checked + * against the catalog (invalid_attr_path with suggestions), placeholders resolved, strings + * sanitized. A breakpoint may be given short ({"desktop": "#fff"}) or raw + * ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop. + * Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP. + */ +final class AttrWriter +{ + private const STATES = ['value', 'hover', 'sticky']; + + /** innerContent sub keys that hold URLs. */ + private const URL_KEY = '/(url|src|link)$/i'; + + private const CODE_MODULES = ['divi/code', 'divi/fullwidth-code']; + + public function __construct( + private ModuleCatalog $catalog, + private Placeholders $placeholders, + private ValueSanitizer $sanitizer + ) { + } + + /** + * @param array $attrs Current attributes of the block. + * @param array $changes Path => breakpoint map, or null to remove the path. + * @return array{attrs: array, errors: array, invalid_paths: list} + */ + public function apply(string $module, array $attrs, array $changes, string $field): array + { + $errors = []; + $invalid = []; + foreach ($changes as $path => $map) { + $path = (string) $path; + $key = $field . '.' . $path; + if (! $this->catalog->isKnownPath($module, $path)) { + $suggestions = $this->catalog->suggestions($module, $path); + $errors[$key] = 'unbekannter Attributpfad' + . ($suggestions === [] ? '' : '; ähnlich: ' . implode(', ', $suggestions)); + $invalid[] = $path; + continue; + } + + if ($map === null) { + $attrs = AttrMap::remove($attrs, $path); + continue; + } + + try { + $normalized = $this->clean($module, $path, $this->placeholders->resolveDeep(self::normalize($map))); + } catch (InvalidArgumentException $e) { + $errors[$key] = $e->getMessage(); + continue; + } + + $merged = self::merge(AttrMap::get($attrs, $path), $normalized); + $attrs = $merged === [] ? AttrMap::remove($attrs, $path) : AttrMap::set($attrs, $path, $merged); + } + + return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid]; + } + + /** + * Cleans one value written to $path (used by the short form too). + * + * @throws InvalidArgumentException + */ + public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed + { + if (is_array($value)) { + $clean = []; + foreach ($value as $key => $item) { + $clean[$key] = $this->cleanValue($module, $path, $item, is_string($key) ? $key : $subKey); + } + + return $clean; + } + + if (! is_string($value) || Placeholders::isVariable($value)) { + return $value; + } + + if (! str_ends_with($path, '.innerContent')) { + return $this->sanitizer->text($value); + } + + if ($subKey !== null && preg_match(self::URL_KEY, $subKey) === 1) { + return $this->sanitizer->url($value); + } + + return in_array($module, self::CODE_MODULES, true) + ? $this->sanitizer->code($value) + : $this->sanitizer->html($value); + } + + /** + * @return array> Breakpoint => state => value. + * @throws InvalidArgumentException + */ + public static function normalize(mixed $map): array + { + $shape = 'erwartet {"desktop": Wert, "tablet": Wert, "phone": Wert, "hover": Wert} ' + . 'oder {"desktop": {"value": …, "hover": …}}'; + if (! is_array($map) || $map === [] || array_is_list($map)) { + throw new InvalidArgumentException($shape); + } + + $normalized = []; + foreach ($map as $key => $value) { + $key = (string) $key; + if ($key === 'hover' || $key === 'sticky') { + $normalized['desktop'][$key] = $value; + } elseif (in_array($key, AttrMap::BREAKPOINTS, true)) { + $isRaw = is_array($value) && $value !== [] && ! array_is_list($value) + && array_diff(array_map('strval', array_keys($value)), self::STATES) === []; + foreach ($isRaw ? $value : ['value' => $value] as $state => $stateValue) { + $normalized[$key][(string) $state] = $stateValue; + } + } else { + throw new InvalidArgumentException(sprintf('unbekannter Breakpoint "%s"; %s', $key, $shape)); + } + } + + return $normalized; + } + + /** + * @param array> $normalized + * @return array> + * @throws InvalidArgumentException + */ + private function clean(string $module, string $path, array $normalized): array + { + foreach ($normalized as $breakpoint => $states) { + foreach ($states as $state => $value) { + $normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value); + } + } + + return $normalized; + } + + /** + * @param array> $changes + * @return array + */ + private static function merge(mixed $existing, array $changes): array + { + $map = is_array($existing) && AttrMap::isBreakpointMap($existing) ? $existing : []; + foreach ($changes as $breakpoint => $states) { + $current = is_array($map[$breakpoint] ?? null) ? $map[$breakpoint] : []; + foreach ($states as $state => $value) { + if ($value === null) { + unset($current[$state]); + } else { + $current[$state] = $value; + } + } + + if ($current === []) { + unset($map[$breakpoint]); + } else { + $map[$breakpoint] = $current; + } + } + + return $map; + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest' && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/ tests/Unit/Integrations/Divi/Compose/ +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi placeholders and full-form attribute writer + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 2: Kurzform schreiben – `ShortformWriter` + +Spec §5.4: Kurzform-Felder nach `ShortformMap` (2a) – Wert je Art prüfen (`onoff` aus true/false, `level` h1–h6, `number`, `enum`, `ids` als „12,33“, `color` nur Farbe oder Platzhalter, `url` ohne `javascript:`), Platzhalter auflösen (nicht beim Code-Modul), bereinigen und unter `.desktop.value[.]` setzen; `null` entfernt; `with` setzt feste Zusatzwerte (Blurb-Bild → `useIcon: off`). Medienfelder (`image_id`, `portrait_id`, `image_ids`) holen URL, ID und Alt-Text über `MediaResolver` – ein ausdrücklich gesetztes `alt` gewinnt. + +**Files:** +- Create: `src/Integrations/Divi/Compose/ShortformWriter.php` +- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`shortform()`) +- Test: `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php` + +**Interfaces:** +- Consumes: `ShortformMap::fields()` (2a), `AttrMap::set/remove` (2a), `Placeholders`, `ValueSanitizer`, `MediaResolver`, `AttrWriter::cleanValue()` (Task 1). +- Produces: `ShortformWriter(Placeholders, ValueSanitizer, MediaResolver, AttrWriter)`: `apply(string $type, array $attrs, array $values, string $field): array{attrs, errors}` (`$type` = Kurzname, Fehlerschlüssel `"."`); `ComposeFixture::shortform(): ShortformWriter`. + +- [ ] **Step 1: Failing Tests** + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Compose\Placeholders; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +``` + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methode ergänzen) ersetzen: +```php + public static function attrWriter(): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); + } +``` +durch: +```php + public static function attrWriter(): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); + } + + public static function shortform(): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); + } +``` + +`tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`: +```php +apply('button', [], [ + 'text' => 'Termin buchen', + 'url' => '/kontakt', + 'new_window' => true, + ], 'nodes[0]'); + + self::assertSame([], $result['errors']); + self::assertSame( + ['button' => ['innerContent' => ['desktop' => ['value' => [ + 'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on', + ]]]]], + $result['attrs'] + ); + } + + public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void + { + $writer = ComposeFixture::shortform(); + + $image = $writer->apply('image', [], ['image_id' => 33], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'], + $image['attrs']['image']['innerContent']['desktop']['value'] + ); + + $own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n'); + self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']); + + $blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'], + $blurb['attrs']['imageIcon']['innerContent']['desktop']['value'] + ); + + $gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n'); + self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']); + } + + public function testValidatesKindsAndNames(): void + { + $result = ComposeFixture::shortform()->apply('heading', [], [ + 'level' => 'h7', + 'text' => '{{color:Lila}}', + 'size' => 12, + ], 'nodes[1]'); + + self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']); + self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']); + self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']); + + $more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f'); + self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']); + self::assertSame('true oder false erwartet', $more['errors']['f.required']); + + $media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm'); + self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']); + self::assertSame('ungültige URL', $media['errors']['m.url']); + + $none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a'); + self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']); + } + + public function testPlaceholdersAndNullRemoval(): void + { + $writer = ComposeFixture::shortform(); + $attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs']; + + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + $attrs['title']['innerContent']['desktop']['value'] + ); + + $removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs']; + self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned'); + + $divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs']; + self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']); + self::assertSame( + 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a', + $writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color'] + ); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ShortformWriterTest` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\ShortformWriter" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/ShortformWriter.php`: +```php +.desktop.value[.]". + * null removes a field. Pure PHP. + */ +final class ShortformWriter +{ + private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/'; + + public function __construct( + private Placeholders $placeholders, + private ValueSanitizer $sanitizer, + private MediaResolver $media, + private AttrWriter $attrs + ) { + } + + /** + * @param string $type Short module name ("button"). + * @param array $attrs Current attributes. + * @param array $values Field => value. + * @return array{attrs: array, errors: array} + */ + public function apply(string $type, array $attrs, array $values, string $field): array + { + $fields = ShortformMap::fields($type); + $errors = []; + foreach ($values as $name => $value) { + $name = (string) $name; + $spec = $fields[$name] ?? null; + if ($spec === null) { + $errors[$field . '.' . $name] = $fields === [] + ? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen' + : 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields)); + continue; + } + + try { + $attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values); + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . $name] = $e->getMessage(); + } + } + + return ['attrs' => $attrs, 'errors' => $errors]; + } + + /** + * @param array $attrs + * @param array $spec + * @param array $all All values of this call (media must not overwrite an explicit alt). + * @return array + * @throws InvalidArgumentException + */ + private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array + { + $base = $spec['path'] . '.desktop.value'; + if ($value === null) { + return AttrMap::remove($attrs, $spec['sub'] === null ? $spec['path'] : $base . '.' . $spec['sub']); + } + + if ($spec['kind'] === 'media') { + $attrs = $this->media($attrs, $base, $spec['keys'], $value, ! array_key_exists('alt', $all)); + } else { + $clean = $this->value($module, $spec, $value); + $attrs = AttrMap::set($attrs, $spec['sub'] === null ? $base : $base . '.' . $spec['sub'], $clean); + } + + foreach ($spec['with'] ?? [] as $sub => $fixed) { + $attrs = AttrMap::set($attrs, $base . '.' . $sub, $fixed); + } + + return $attrs; + } + + /** + * @param array $attrs + * @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key. + * @return array + * @throws InvalidArgumentException + */ + private function media(array $attrs, string $base, array $keys, mixed $value, bool $withAlt): array + { + $id = is_int($value) ? $value : (is_string($value) && ctype_digit($value) ? (int) $value : 0); + $image = $id > 0 ? $this->media->image($id) : null; + if ($image === null) { + throw new InvalidArgumentException( + 'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen' + ); + } + + [$urlKey, $idKey, $altKey] = $keys; + $attrs = AttrMap::set($attrs, $base . '.' . $urlKey, $this->sanitizer->url($image['url'])); + if ($idKey !== null) { + $attrs = AttrMap::set($attrs, $base . '.' . $idKey, (string) $id); + } + + if ($altKey !== null && $withAlt && $image['alt'] !== '') { + $attrs = AttrMap::set($attrs, $base . '.' . $altKey, $this->sanitizer->text($image['alt'])); + } + + return $attrs; + } + + /** + * @param array $spec + * @throws InvalidArgumentException + */ + private function value(string $module, array $spec, mixed $value): mixed + { + switch ($spec['kind']) { + case 'onoff': + if (is_bool($value)) { + return $value ? 'on' : 'off'; + } + if ($value === 'on' || $value === 'off') { + return $value; + } + throw new InvalidArgumentException('true oder false erwartet'); + case 'level': + if (is_string($value) && preg_match('/^h[1-6]$/', $value) === 1) { + return $value; + } + throw new InvalidArgumentException('h1 bis h6 erwartet'); + case 'number': + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { + return $value + 0; + } + throw new InvalidArgumentException('Zahl erwartet'); + case 'enum': + if (is_string($value) && in_array($value, $spec['values'], true)) { + return $value; + } + throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values'])); + case 'ids': + return $this->ids($value); + } + + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { + throw new InvalidArgumentException('Text erwartet'); + } + + $value = (string) $value; + if ($spec['kind'] !== 'code') { + $value = $this->placeholders->resolve($value); + if (Placeholders::isVariable($value)) { + return $value; + } + } + + if ($spec['kind'] === 'color') { + if (preg_match(self::COLOR, trim($value)) !== 1) { + throw new InvalidArgumentException('Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'); + } + + return $this->sanitizer->text($value); + } + + if ($spec['kind'] === 'url') { + $url = $this->sanitizer->url($value); + if ($url === '' && trim($value) !== '') { + throw new InvalidArgumentException('ungültige URL'); + } + + return $url; + } + + return match ($spec['kind']) { + 'html', 'code' => $this->attrs->cleanValue($module, (string) $spec['path'], $value), + default => $this->sanitizer->text($value), + }; + } + + /** @throws InvalidArgumentException */ + private function ids(mixed $value): string + { + if (! is_array($value) || $value === [] || ! array_is_list($value)) { + throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]'); + } + + $ids = []; + foreach ($value as $id) { + $id = is_int($id) ? $id : (is_string($id) && ctype_digit($id) ? (int) $id : 0); + if ($id <= 0 || $this->media->image($id) === null) { + throw new InvalidArgumentException(sprintf('%s ist kein Bild aus der Mediathek', (string) $id)); + } + $ids[] = $id; + } + + return implode(',', $ids); + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/ShortformWriter.php tests/Unit/Integrations/Divi/Compose/ +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi short-form writer + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 3: Aus Beschreibungen Blöcke bauen – `StructureRules` und `Composer` + +Spec §5.4 „Struktur“ und §5.5: Abschnitt → Zeilen (`"columns": "1_3,2_3"`) → Spalten → Module; Vollbreite über `section_type: "fullwidth"`; Kind-Module in `items`; `preset` per Name (Modul-Presets aus dem Designsystem); fehlendes `label` → „ – “; `heading` ohne `level` → `h2` (D9); jeder neue Block bekommt `builderVersion` (live `ET_BUILDER_PRODUCT_VERSION`). Spaltenbreiten über `module.decoration.sizing.desktop.value.flexType` wie Divi 5.2 (D8; `a_b` mit 24 % b = 0 → `(a·24/b)_24`, sonst Fünftel und 1/7, 1/9, 1/10, 1/11; Summe = 1). `StructureRules` prüft, was wohin darf (Seite → Abschnitte, regulärer Abschnitt → Zeilen, Vollbreite → `fullwidth-*`, Spalte/Gruppe → Module, Elternmodul → seine Kind-Module; Spalten nur mit ihrer Zeile; Spezialabschnitte und nur lesbare Knoten nicht). Alle Fehler werden mit Eingabepfad gesammelt. `update()` wendet `set`/`attrs`/`preset`/`label` auf einen vorhandenen Block an (für `lit-divi-update`). + +**Files:** +- Create: `src/Integrations/Divi/Compose/{StructureRules,Composer}.php` +- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`rules()`, `composer()`) +- Test: `tests/Unit/Integrations/Divi/Compose/ComposerTest.php` + +**Interfaces:** +- Consumes: `Block::create()/insertChild()/name()/attrs()/isDivi()/isOpaque()`, `AttrMap::set/desktop` (2a), `ModuleCatalog::has/category/childrenOf/title/normalize` (2a), `ShortformMap::fields()` (2a), `DesignSystem::modulePresetId/modulePresetNames` (2a), `ShortformWriter`, `AttrWriter` (Tasks 1–2). +- Produces: + - `StructureRules(ModuleCatalog)`: `violation(?Block $parent, string $child): ?string` (null = erlaubt; `$parent` null = oberste Ebene); `isPlainModule(string $name): bool`. + - `Composer(ModuleCatalog, StructureRules, ShortformWriter, AttrWriter, DesignSystem, string $builderVersion)`: `compose(array $specs, ?Block $parent, string $field, string $context = ''): array{blocks: list, errors: array, invalid_paths: list}` (Fehlerschlüssel z. B. `nodes[0].rows[1].modules[0][2].text`); `update(Block $block, array $change, string $field): array{attrs, errors, invalid_paths}`; `autoLabel(string $name, string $context): string`. + - `ComposeFixture::rules()`, `ComposeFixture::composer()` (Builder-Version `5.2.1`). + +- [ ] **Step 1: Failing Tests** + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +use LIT\MCP\Integrations\Divi\Compose\Composer; +``` + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +use LIT\MCP\Integrations\Divi\Compose\StructureRules; +``` + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methoden ergänzen) ersetzen: +```php + public static function shortform(): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); + } +} +``` +durch: +```php + public static function shortform(): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); + } + + public static function rules(): StructureRules + { + return new StructureRules(ModuleCatalogTest::sampleCatalog()); + } + + public static function composer(): Composer + { + return new Composer( + ModuleCatalogTest::sampleCatalog(), + self::rules(), + self::shortform(), + self::attrWriter(), + DesignSystemTest::sample(), + '5.2.1' + ); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/ComposerTest.php`: +```php +compose([[ + 'type' => 'section', + 'label' => 'Hero', + 'background_color' => '{{color:Nachtblau}}', + 'rows' => [[ + 'columns' => '1_3,2_3', + 'modules' => [ + [['type' => 'image', 'image_id' => 33]], + [ + ['type' => 'heading', 'text' => 'Willkommen'], + ['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'], + ], + ], + ]], + ]], null, 'nodes'); + + self::assertSame([], $result['errors']); + self::assertStringStartsWith( + '', + Serializer::block($result['blocks'][0]) + ); + + $section = $result['blocks'][0]; + [$heading, $button] = $section->children()[0]->children()[1]->children(); + self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1])); + self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']); + self::assertSame('Hero – Heading', NodeSummary::label($heading)); + self::assertSame(['p-2'], $button->attrs()['modulePreset']); + + // What the composer writes, the parser reads back unchanged. + $markup = Serializer::block($section); + self::assertSame($markup, Serializer::document((new Parser())->parse($markup))); + self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque()); + } + + public function testShortcutsItemsFullwidthAndFullForm(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'section', 'modules' => [ + ['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => '

Antwort

', 'open' => true]]], + ['type' => 'text', 'content' => '

x

', 'label' => 'Einleitung', + 'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]], + ]], + ['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [ + ['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]], + ]], + ], null, 'nodes'); + + self::assertSame([], $result['errors']); + [$regular, $fullwidth] = $result['blocks']; + $column = $regular->children()[0]->children()[0]; + self::assertSame('24_24', NodeSummary::columnWidth($column)); + [$accordion, $text] = $column->children(); + self::assertSame('divi/accordion-item', $accordion->children()[0]->name()); + self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']); + self::assertSame('Einleitung', NodeSummary::label($text)); + self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']); + self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']); + self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name()); + } + + public function testCollectsEveryErrorWithItsInputPath(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'button', 'text' => 'x'], + ['type' => 'section', 'rows' => [ + ['columns' => '1_2,1_3', 'modules' => []], + ['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]], + ]], + ['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1], + ['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]], + ], null, 'nodes'); + + self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']); + self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']); + self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']); + self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']); + self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']); + self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']); + self::assertSame(['title.fnt'], $result['invalid_paths']); + self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']); + } + + public function testColumnWidths(): void + { + $composer = ComposeFixture::composer(); + $widths = static function (string $columns) use ($composer): array { + $result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n'); + + return [ + array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()), + $result['errors'], + ]; + }; + + self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2')); + self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4')); + self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5')); + self::assertSame(['24_24'], $widths('1_7,5_7')[0]); + self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]); + self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]); + } + + public function testUpdateChangesShortFormPresetAndLabel(): void + { + $block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); + + $result = ComposeFixture::composer()->update($block, [ + 'set' => ['text' => 'Neu', 'url' => '/neu'], + 'preset' => 'Standard', + 'label' => 'Hero – CTA', + ], 'changes[0]'); + + self::assertSame([], $result['errors']); + self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']); + self::assertSame(['p-1'], $result['attrs']['modulePreset']); + self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']); + + $cleared = ComposeFixture::composer()->update( + Block::create('divi/button', $result['attrs']), + ['preset' => null], + 'changes[0]' + ); + self::assertArrayNotHasKey('modulePreset', $cleared['attrs']); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ComposerTest` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\StructureRules" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/StructureRules.php`: +```php +catalog->has($child)) { + return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child); + } + + if ($parent === null) { + return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt'; + } + + if (! $parent->isDivi() || $parent->isOpaque()) { + return 'der Zielknoten ist nur lesbar (globales Modul oder fremder Block)'; + } + + $parentName = $parent->name(); + if ($parentName === 'divi/section') { + return $this->inSection($parent, $child); + } + + if (in_array($parentName, self::ROWS, true)) { + return 'Spalten entstehen mit der Zeile ("columns"); eine neue Zeile mit der gewünschten Aufteilung ' + . 'einfügen'; + } + + if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') { + return $this->isPlainModule($child) || $child === 'divi/group' + ? null + : sprintf('%s passt nicht in eine Spalte', $child); + } + + $children = $this->catalog->childrenOf($parentName); + if ($children === []) { + return sprintf('%s kann keine Kind-Module enthalten', $parentName); + } + + return in_array($child, $children, true) + ? null + : sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children)); + } + + public function isPlainModule(string $name): bool + { + return $this->catalog->category($name) === 'module' && ! str_starts_with($name, 'divi/fullwidth-'); + } + + private function inSection(Block $section, string $child): ?string + { + $type = AttrMap::desktop($section->attrs(), 'module.advanced.type'); + if ($type === 'specialty') { + return 'Spezialabschnitte (specialty) nur im Divi-Builder umbauen; Module in ihre Spalten einfügen'; + } + + if ($type === 'fullwidth') { + return str_starts_with($child, 'divi/fullwidth-') + ? null + : 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module'; + } + + return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)'; + } +} +``` + +`src/Integrations/Divi/Compose/Composer.php`: +```php + – " (spec §5.5). + * Every error is collected with its input path; blocks are only usable when there are none. + * Pure PHP. + */ +final class Composer +{ + private const COMMON = ['type', 'label', 'preset', 'attrs']; + private const SECTION = ['section_type', 'background_color', 'rows', 'modules']; + private const ROW = ['columns', 'modules']; + + /** Column fractions Divi's flex layout knows besides multiples of 1/24. */ + private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '4_5', '1_7', '1_9', '1_10', '1_11']; + + /** @var array */ + private array $errors = []; + + /** @var list */ + private array $invalidPaths = []; + + public function __construct( + private ModuleCatalog $catalog, + private StructureRules $rules, + private ShortformWriter $shortform, + private AttrWriter $attrWriter, + private DesignSystem $design, + private string $builderVersion + ) { + } + + /** + * @param list $specs Node descriptions. + * @param Block|null $parent Block the nodes go into (null = top level of the page). + * @param string $field Input path of $specs for error keys, e.g. "nodes". + * @param string $context Admin label of the surrounding section ("" if none). + * @return array{blocks: list, errors: array, invalid_paths: list} + */ + public function compose(array $specs, ?Block $parent, string $field, string $context = ''): array + { + $this->errors = []; + $this->invalidPaths = []; + $blocks = []; + foreach (array_values($specs) as $position => $spec) { + $block = $this->node($spec, $parent, sprintf('%s[%d]', $field, $position), $context); + if ($block !== null) { + $blocks[] = $block; + } + } + + return ['blocks' => $blocks, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** + * Applies short-form fields, full-form attrs, preset and label to an existing block + * (lit-divi-update); errors like compose(). + * + * @param array $change set, attrs, preset, label + * @return array{attrs: array, errors: array, invalid_paths: list} + */ + public function update(Block $block, array $change, string $field): array + { + $this->errors = []; + $this->invalidPaths = []; + $attrs = $block->attrs(); + $short = $block->shortName(); + + if (isset($change['set'])) { + if (! is_array($change['set']) || array_is_list($change['set'])) { + $this->errors[$field . '.set'] = 'Objekt mit Kurzform-Feldern erwartet'; + } else { + $result = $this->shortform->apply($short, $attrs, $change['set'], $field . '.set'); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + } + + $attrs = $this->common($block->name(), $attrs, $change, $field, null); + + return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** Admin label for a new module: " – " or the title alone. */ + public function autoLabel(string $name, string $context): string + { + $title = $this->catalog->title($name); + + return $context === '' ? $title : $context . ' – ' . $title; + } + + private function node(mixed $spec, ?Block $parent, string $path, string $context): ?Block + { + if (! is_array($spec) || array_is_list($spec)) { + $this->errors[$path] = 'Objekt mit "type" erwartet'; + + return null; + } + + $type = $this->typeOf($spec, $parent); + if ($type === null) { + $this->errors[$path . '.type'] = 'type fehlt'; + + return null; + } + + $violation = $this->rules->violation($parent, $type); + if ($violation !== null) { + $this->errors[$path . '.type'] = $violation; + + return null; + } + + return match ($type) { + 'divi/section' => $this->section($spec, $path), + 'divi/row' => $this->row($spec, $path, $context), + default => $this->module($type, $spec, $path, $context), + }; + } + + /** @param array $spec */ + private function typeOf(array $spec, ?Block $parent): ?string + { + if (is_string($spec['type'] ?? null) && $spec['type'] !== '') { + return ModuleCatalog::normalize($spec['type']); + } + + $children = $parent === null ? [] : $this->catalog->childrenOf($parent->name()); + + return count($children) === 1 ? $children[0] : null; + } + + /** @param array $spec */ + private function section(array $spec, string $path): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::SECTION), $path); + $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : ''; + $attrs = ['builderVersion' => $this->builderVersion]; + if (($spec['section_type'] ?? 'regular') === 'fullwidth') { + $attrs = AttrMap::set($attrs, 'module.advanced.type', ['desktop' => ['value' => 'fullwidth']]); + } elseif (($spec['section_type'] ?? 'regular') !== 'regular') { + $this->errors[$path . '.section_type'] = 'regular oder fullwidth erwartet (specialty nur im Divi-Builder)'; + } + + if (isset($spec['background_color'])) { + $attrs = $this->backgroundColor($attrs, $spec['background_color'], $path . '.background_color'); + } + + $section = Block::create('divi/section', $this->common('divi/section', $attrs, $spec, $path, $label)); + if (isset($spec['rows']) && isset($spec['modules']) && ($spec['section_type'] ?? 'regular') !== 'fullwidth') { + $this->errors[$path] = 'entweder rows oder modules angeben'; + + return $section; + } + + if (($spec['section_type'] ?? 'regular') === 'fullwidth') { + $this->children($section, $spec['modules'] ?? [], $path . '.modules', $label); + } elseif (isset($spec['modules'])) { + $this->children($section, [['type' => 'row', 'modules' => $spec['modules']]], $path . '.modules', $label); + } else { + $this->children($section, $spec['rows'] ?? [], $path . '.rows', $label); + } + + return $section; + } + + /** @param array $spec */ + private function row(array $spec, string $path, string $context): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path); + $widths = $this->widths($spec['columns'] ?? '4_4', $path . '.columns'); + $label = $context === '' ? '' : $context . ' – Zeile'; + $attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label); + $row = Block::create('divi/row', $attrs); + + $modules = $spec['modules'] ?? []; + if (! is_array($modules) || ! array_is_list($modules)) { + $this->errors[$path . '.modules'] = 'Liste erwartet (je Spalte eine Liste von Modulen)'; + $modules = []; + } + + // One column: a flat list of modules is accepted. + $first = $modules[0] ?? null; + if (count($widths) === 1 && is_array($first) && ! array_is_list($first)) { + $modules = [$modules]; + } + + if (count($modules) > count($widths)) { + $this->errors[$path . '.modules'] = sprintf( + '%d Spalten, aber %d Modul-Listen', + count($widths), + count($modules) + ); + } + + foreach ($widths as $position => $width) { + $column = Block::create('divi/column', AttrMap::set( + ['builderVersion' => $this->builderVersion], + 'module.decoration.sizing', + ['desktop' => ['value' => ['flexType' => $width]]] + )); + $row->insertChild($position, $column); + $list = $modules[$position] ?? []; + $listPath = sprintf('%s.modules[%d]', $path, $position); + $this->children($column, is_array($list) ? $list : [], $listPath, $context); + } + + return $row; + } + + /** @param array $spec */ + private function module(string $name, array $spec, string $path, string $context): Block + { + $short = substr($name, strlen('divi/')); + $fields = array_keys(ShortformMap::fields($short)); + $hasItems = $this->catalog->childrenOf($name) !== []; + $allowed = array_merge( + self::COMMON, + $fields, + $hasItems ? ['items'] : [], + $name === 'divi/group' ? ['modules'] : [] + ); + $this->unknownKeys($spec, $allowed, $path); + + $values = array_intersect_key($spec, array_flip($fields)); + if ($name === 'divi/heading' && ! array_key_exists('level', $values)) { + $values['level'] = 'h2'; + } + + $attrs = ['builderVersion' => $this->builderVersion]; + if ($values !== []) { + $result = $this->shortform->apply($short, $attrs, $values, $path); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + + $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $this->autoLabel($name, $context); + $block = Block::create($name, $this->common($name, $attrs, $spec, $path, $label)); + + if ($hasItems) { + $this->children($block, $spec['items'] ?? [], $path . '.items', $context); + } elseif ($name === 'divi/group') { + $this->children($block, $spec['modules'] ?? [], $path . '.modules', $context); + } + + return $block; + } + + /** + * Preset, full-form attrs and admin label – shared by new and updated blocks. + * + * @param array $attrs + * @param array $spec + * @return array + */ + private function common(string $name, array $attrs, array $spec, string $path, ?string $label): array + { + if (array_key_exists('preset', $spec)) { + $attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset'); + } + + if (isset($spec['attrs'])) { + if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) { + $this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; + } else { + $result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs'); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + array_push($this->invalidPaths, ...$result['invalid_paths']); + } + } + + $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $label; + if ($label !== null && $label !== '') { + $attrs = AttrMap::set($attrs, 'module.meta.adminLabel', ['desktop' => ['value' => $this->clean($label)]]); + } + + return $attrs; + } + + /** + * @param array $attrs + * @return array + */ + private function preset(string $name, array $attrs, mixed $preset, string $path): array + { + if ($preset === null || $preset === '') { + unset($attrs['modulePreset']); + + return $attrs; + } + + $id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null; + if ($id === null) { + $names = $this->design->modulePresetNames($name); + $this->errors[$path] = sprintf( + 'unbekanntes Preset; vorhanden für %s: %s', + $name, + $names === [] ? '(keine)' : implode(', ', $names) + ); + + return $attrs; + } + + $attrs['modulePreset'] = [$id]; + + return $attrs; + } + + /** + * @param array $attrs + * @return array + */ + private function backgroundColor(array $attrs, mixed $color, string $path): array + { + $result = $this->attrWriter->apply( + 'divi/section', + $attrs, + ['module.decoration.background' => ['desktop' => ['color' => $color]]], + $path + ); + foreach ($result['errors'] as $message) { + $this->errors[$path] = $message; + } + + return $result['attrs']; + } + + /** @param mixed $specs */ + private function children(Block $parent, mixed $specs, string $path, string $context): void + { + if (! is_array($specs) || ($specs !== [] && ! array_is_list($specs))) { + $this->errors[$path] = 'Liste erwartet'; + + return; + } + + foreach ($specs as $position => $spec) { + $child = $this->node($spec, $parent, sprintf('%s[%d]', $path, $position), $context); + if ($child !== null) { + $parent->insertChild(PHP_INT_MAX, $child); + } + } + } + + /** + * "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row. + * + * @return list + */ + private function widths(mixed $columns, string $path): array + { + $parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : []; + $widths = []; + $sum = 0.0; + foreach ($parts as $part) { + $valid = preg_match('/^(\d{1,2})_(\d{1,2})$/', $part, $match) === 1 + && (int) $match[1] >= 1 + && (int) $match[1] <= (int) $match[2]; + if (! $valid) { + $this->errors[$path] = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet'; + + return ['24_24']; + } + + [$numerator, $denominator] = [(int) $match[1], (int) $match[2]]; + if (24 % $denominator === 0) { + $widths[] = ($numerator * 24 / $denominator) . '_24'; + } elseif (in_array($part, self::SPECIAL_WIDTHS, true)) { + $widths[] = $part; + } else { + $this->errors[$path] = sprintf('Spaltenbreite %s gibt es in Divi nicht', $part); + + return ['24_24']; + } + $sum += $numerator / $denominator; + } + + if ($widths === [] || abs($sum - 1.0) > 0.01) { + $this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")'; + + return ['24_24']; + } + + return $widths; + } + + /** + * @param array $spec + * @param list $allowed + */ + private function unknownKeys(array $spec, array $allowed, string $path): void + { + foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) { + $this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed); + } + } + + private function clean(string $label): string + { + try { + return (string) $this->attrWriter->cleanValue('divi/section', 'module.meta.adminLabel', $label); + } catch (InvalidArgumentException) { + return $label; + } + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` +Expected: PASS (u. a. die exakte Markup-Erwartung in `testBuildsASectionFromTheShortForm` und dass der Parser das erzeugte Markup unverändert zurückgibt), phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/StructureRules.php src/Integrations/Divi/Compose/Composer.php tests/Unit/Integrations/Divi/Compose/ +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi composer – short/full form to blocks with structure rules, presets and labels + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 4: Baum ändern und prüfen – `TreeEditor` und `Warnings` + +Spec §5.7: Knoten an `parent` (optional `index`), `before` oder `after` einfügen, verschieben (auch zwischen Zeilen und Abschnitten), entfernen, duplizieren – immer gegen `StructureRules`; ein Knoten kann nicht in seinen eigenen Teilbaum wandern; Verschieben und Löschen erhalten jedes Byte der übrigen Seite (Text zwischen Blöcken bleibt stehen). Spec §5.5: Warnungen nur zu neu erzeugten oder geänderten Knoten (und deren Kindern) – feste Farbe, obwohl eine globale Farbe passt (RGB-Abstand ≤ 24), feste Schrift = Theme-Schrift, Bild ohne Alt-Text (`image`, `blurb`, `slide`), leere Module (Kurzform-Hauptfeld leer, Elternmodul ohne Einträge, leere Struktur), Sprung in der Überschriftenhierarchie (Heading-Standard h1, Blurb h4, CTA h2, `

`–`

` im Text-Modul), Code-Modul. „Ähnliche Abschnitte“ folgt in Etappe 3 (D6). + +**Files:** +- Create: `src/Integrations/Divi/Compose/{TreeEditor,Warnings}.php` +- Test: `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`, `tests/Unit/Integrations/Divi/Compose/WarningsTest.php` + +**Interfaces:** +- Consumes: `NodeIndex::find/parentOf/root/idOf/entries`, `Container::indexOf/insertChild/removeChild`, `Block::copy()` (2a); `StructureRules` (Task 3); `DesignSystem::colors/fonts/colorPlaceholder`, `ShortformMap::childType/primary/values`, `NodeSummary::textOf`, `AttrMap::desktop`, `Placeholders::isVariable`. +- Produces: + - `TreeEditor(StructureRules)`: `position(NodeIndex $index, array $input): array{container: Container, parent: ?Block, index: int}|string` (Fehlertext); `insert(array $position, list $blocks): ?string`; `move(NodeIndex $index, Block $node, array $position): ?string`; `remove(NodeIndex $index, Block $node): void`; `duplicate(NodeIndex $index, Block $node): Block` (Kopie direkt dahinter). + - `Warnings(DesignSystem)`: `collect(NodeIndex $index, list $touched): list` (Format `": "`, IDs nach der Änderung). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`: +```php +parse((string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name)); + } + + public function testResolvesPositions(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + $end = $editor->position($index, []); + self::assertSame($index->root(), $end['container']); + self::assertNull($end['parent']); + + $inColumn = $editor->position($index, ['parent' => 's0.r0.c1', 'index' => 1]); + self::assertSame($index->find('s0.r0.c1'), $inColumn['parent']); + self::assertSame(1, $inColumn['index']); + + $after = $editor->position($index, ['after' => 's0.r0.c1.m0']); + self::assertSame($index->find('s0.r0.c1'), $after['container']); + self::assertSame(1, $after['index']); + + $beforeSection = $editor->position($index, ['before' => 's1']); + self::assertNull($beforeSection['parent']); + self::assertSame(1, $beforeSection['index']); + + self::assertSame('nur eines von parent, before oder after angeben', $editor->position($index, ['parent' => 's0', 'after' => 's1'])); + self::assertSame('Knoten "s9" gibt es nicht', $editor->position($index, ['before' => 's9'])); + } + + public function testInsertChecksTheStructureRules(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $button = Block::create('divi/button'); + + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [$button]) + ); + self::assertStringContainsString('Zeilen', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [$button])); + self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->insert($editor->position($index, ['parent' => 's0.r0']), [$button])); + self::assertStringContainsString('accordion-item', (string) $editor->insert($editor->position($index, ['parent' => 's1.r0.c1.m0']), [$button])); + + self::assertNull($editor->insert($editor->position($index, ['after' => 's0.r0.c1.m0']), [$button])); + self::assertSame('divi/button', NodeIndex::build($document)->find('s0.r0.c1.m1')?->name()); + } + + public function testMoveWithinAndAcrossParents(): void + { + $document = $this->document(); + $original = Serializer::document($document); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $button = $index->find('s0.r0.c1.m2'); + + self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0']))); + $index = NodeIndex::build($document); + self::assertSame($button, $index->find('s0.r0.c1.m0')); + + self::assertNull($editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c0']))); + $index = NodeIndex::build($document); + self::assertSame($button, $index->find('s1.r0.c0.m1')); + self::assertStringContainsString('Termin buchen', Serializer::document($document)); + self::assertSame(strlen($original), strlen(Serializer::document($document)), 'moving keeps every byte'); + + $section = $index->find('s0'); + self::assertSame( + 'ein Knoten kann nicht in sich selbst verschoben werden', + $editor->move($index, $section, $editor->position($index, ['parent' => 's0.r0.c0'])) + ); + self::assertNull($editor->move($index, $section, $editor->position($index, []))); + self::assertSame($section, NodeIndex::build($document)->find('s1')); + } + + public function testRemoveAndDuplicate(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + $copy = $editor->duplicate($index, $index->find('s1.r0.c1.m0')); + $index = NodeIndex::build($document); + self::assertSame($copy, $index->find('s1.r0.c1.m1')); + self::assertSame(Serializer::block($index->find('s1.r0.c1.m0')), Serializer::block($copy)); + + $editor->remove($index, $index->find('s0')); + self::assertSame('divi/section', NodeIndex::build($document)->find('s0')?->name()); + self::assertNull(NodeIndex::build($document)->find('s1')); + } + + public function testReadOnlyTargetsAndSpecialtySections(): void + { + $document = $this->document('synthetic-mixed.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $text = Block::create('divi/text'); + + self::assertStringContainsString('nur lesbar', (string) $editor->insert($editor->position($index, ['parent' => 's1.g1']), [$text])); + self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')])); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text])); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/WarningsTest.php`: +```php +compose([ + ['type' => 'section', 'label' => 'Test', 'modules' => [ + ['type' => 'heading', 'text' => 'Titel', 'level' => 'h1'], + ['type' => 'heading', 'text' => 'Zu tief', 'level' => 'h4'], + ['type' => 'image', 'src' => 'https://kunde.test/a.jpg'], + ['type' => 'text', 'content' => ''], + ['type' => 'code', 'content' => '
x
'], + ['type' => 'accordion'], + ['type' => 'button', 'text' => 'Los', 'attrs' => [ + 'button.decoration.background' => ['desktop' => ['color' => '#2ea3f3']], + 'button.decoration.font.font' => ['desktop' => ['family' => 'Open Sans', 'color' => '#abcdef']], + ]], + ]], + ], null, 'nodes'); + self::assertSame([], $composed['errors']); + $document = new Document(); + foreach ($composed['blocks'] as $block) { + $document->append($block); + } + + $warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']); + + self::assertSame( + [ + 's0.r0.c0.m2: Bild ohne Alt-Text.', + 's0.r0.c0.m3: text ist leer (content fehlt).', + 's0.r0.c0.m4: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' + . 'natives Modul passt.', + 's0.r0.c0.m5: accordion ohne Einträge.', + 's0.r0.c0.m6: feste Farbe #2ea3f3 – globale Farbe "Primärfarbe" ({{color:primary}}) passt.', + 's0.r0.c0.m6: feste Schrift Open Sans – {{font:heading}} verwenden.', + 's0.r0.c0.m1: Sprung in der Überschriftenhierarchie (h4 nach h1).', + ], + $warnings + ); + + self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), [])); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'TreeEditorTest|WarningsTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\TreeEditor" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/TreeEditor.php`: +```php + $input + * @return array{container: Container, parent: ?Block, index: int}|string Position or German error. + */ + public function position(NodeIndex $index, array $input): array|string + { + $given = array_filter( + [ + 'parent' => $input['parent'] ?? null, + 'before' => $input['before'] ?? null, + 'after' => $input['after'] ?? null, + ], + static fn (mixed $value): bool => is_string($value) && $value !== '' + ); + if (count($given) > 1) { + return 'nur eines von parent, before oder after angeben'; + } + + $at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX; + if ($given === []) { + return ['container' => $index->root(), 'parent' => null, 'index' => $at]; + } + + $key = (string) array_key_first($given); + $node = $index->find((string) $given[$key]); + if ($node === null) { + return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]); + } + + if ($key === 'parent') { + return ['container' => $node, 'parent' => $node, 'index' => $at]; + } + + $container = $index->parentOf($node); + if ($container === null) { + return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]); + } + + $sibling = (int) $container->indexOf($node); + + return [ + 'container' => $container, + 'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null, + 'index' => $key === 'before' ? $sibling : $sibling + 1, + ]; + } + + /** + * @param array{container: Container, parent: ?Block, index: int} $position + * @param list $blocks + */ + public function insert(array $position, array $blocks): ?string + { + foreach ($blocks as $block) { + $violation = $this->rules->violation($position['parent'], $block->name()); + if ($violation !== null) { + return $violation; + } + } + + foreach (array_values($blocks) as $offset => $block) { + $at = $position['index'] === PHP_INT_MAX ? PHP_INT_MAX : $position['index'] + $offset; + $position['container']->insertChild($at, $block); + } + + return null; + } + + /** @param array{container: Container, parent: ?Block, index: int} $position */ + public function move(NodeIndex $index, Block $node, array $position): ?string + { + for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) { + if ($ancestor === $node) { + return 'ein Knoten kann nicht in sich selbst verschoben werden'; + } + } + + $violation = $this->rules->violation($position['parent'], $node->name()); + if ($violation !== null) { + return $violation; + } + + $from = $index->parentOf($node); + if ($from === null) { + return 'der Knoten hat keinen Elternknoten'; + } + + $old = $from->removeChild($node); + $target = $position['index']; + if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) { + --$target; + } + $position['container']->insertChild($target, $node); + + return null; + } + + public function remove(NodeIndex $index, Block $node): void + { + $index->parentOf($node)?->removeChild($node); + } + + /** Inserts a copy right after $node and returns it. */ + public function duplicate(NodeIndex $index, Block $node): Block + { + $copy = $node->copy(); + $container = $index->parentOf($node); + if ($container !== null) { + $container->insertChild((int) $container->indexOf($node) + 1, $copy); + } + + return $copy; + } + + private function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } +} +``` + +`src/Integrations/Divi/Compose/Warnings.php`: +```php + 1, 'divi/blurb' => 4, 'divi/cta' => 2]; + + private const IMAGE_SOURCES = [ + 'divi/image' => ['image.innerContent', 'src', 'alt'], + 'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'], + 'divi/slide' => ['image.innerContent', 'src', 'alt'], + ]; + + public function __construct(private DesignSystem $design) + { + } + + /** + * @param list $touched Created or changed blocks (their descendants count too). + * @return list + */ + public function collect(NodeIndex $index, array $touched): array + { + $blocks = []; + foreach ($touched as $block) { + foreach ($this->withDescendants($block) as $node) { + $blocks[spl_object_id($node)] = $node; + } + } + + $warnings = []; + foreach ($blocks as $block) { + $id = $index->idOf($block) ?? '?'; + array_push( + $warnings, + ...$this->code($block, $id), + ...$this->image($block, $id), + ...$this->empty($block, $id), + ...$this->fixedValues($block, $id) + ); + } + + return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks)))); + } + + /** @return list */ + private function withDescendants(Block $block): array + { + $all = [$block]; + if (! $block->isOpaque()) { + foreach ($block->children() as $child) { + array_push($all, ...$this->withDescendants($child)); + } + } + + return $all; + } + + /** @return list */ + private function code(Block $block, string $id): array + { + return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true) + ? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' + . 'natives Modul passt.', $id)] + : []; + } + + /** @return list */ + private function image(Block $block, string $id): array + { + $spec = self::IMAGE_SOURCES[$block->name()] ?? null; + if ($spec === null) { + return []; + } + + $value = AttrMap::desktop($block->attrs(), $spec[0]); + $src = is_array($value) ? ($value[$spec[1]] ?? '') : ''; + $alt = is_array($value) ? ($value[$spec[2]] ?? '') : ''; + + return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '') + ? [sprintf('%s: Bild ohne Alt-Text.', $id)] + : []; + } + + /** @return list */ + private function empty(Block $block, string $id): array + { + $name = $block->name(); + if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) { + return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : []; + } + + $type = $block->shortName(); + if (ShortformMap::childType($type) !== null && $block->children() === []) { + return [sprintf('%s: %s ohne Einträge.', $id, $type)]; + } + + $primary = ShortformMap::primary($type); + if ($primary === null || ShortformMap::childType($type) !== null) { + return []; + } + + $values = ShortformMap::values($type, $block->attrs()); + $value = $values[$primary] ?? ''; + + return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : []; + } + + /** @return list */ + private function fixedValues(Block $block, string $id): array + { + $warnings = []; + $fonts = array_filter($this->design->fonts()); + foreach ($this->leaves($block->attrs()) as [$key, $value]) { + if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) { + $match = $this->similarColor($value); + if ($match !== null) { + $warnings[] = sprintf( + '%s: feste Farbe %s – globale Farbe "%s" (%s) passt.', + $id, + $value, + $match['label'], + DesignSystem::colorPlaceholder($match['id'], $match['label']) + ); + } + } elseif ($key === 'family') { + $font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true); + if ($font !== false) { + $warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font); + } + } + } + + return $warnings; + } + + /** + * Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block. + * + * @param array $touched + * @return list + */ + private function headings(NodeIndex $index, array $touched): array + { + $warnings = []; + $previous = null; + foreach ($index->entries() as $entry) { + $block = $entry['block']; + foreach ($this->levels($block) as $level) { + if ($previous !== null && $level > $previous + 1 && isset($touched[spl_object_id($block)])) { + $warnings[] = sprintf( + '%s: Sprung in der Überschriftenhierarchie (h%d nach h%d).', + $entry['id'], + $level, + $previous + ); + } + $previous = $level; + } + } + + return $warnings; + } + + /** @return list Heading levels a block renders, in order. */ + private function levels(Block $block): array + { + $default = self::HEADING_DEFAULTS[$block->name()] ?? null; + if ($default !== null) { + $font = AttrMap::desktop($block->attrs(), 'title.decoration.font.font'); + $level = is_array($font) && is_string($font['headingLevel'] ?? null) + ? $font['headingLevel'] + : 'h' . $default; + + return [(int) substr($level, 1)]; + } + + if ($block->name() === 'divi/text') { + $html = AttrMap::desktop($block->attrs(), 'content.innerContent'); + preg_match_all('/design->colors() as $color) { + $other = self::rgb($color['value']); + if ($rgb !== null && $other !== null && self::distance($rgb, $other) <= self::SIMILAR) { + return $color; + } + } + + return null; + } + + /** + * @param array $values + * @return list [last key, value] of every string leaf (variables excluded). + */ + private function leaves(array $values): array + { + $leaves = []; + foreach ($values as $name => $value) { + if (is_array($value)) { + array_push($leaves, ...$this->leaves($value)); + } elseif (is_string($value) && ! Placeholders::isVariable($value)) { + $leaves[] = [(string) $name, $value]; + } + } + + return $leaves; + } + + /** @return array{int, int, int}|null */ + private static function rgb(string $hex): ?array + { + $hex = ltrim(trim($hex), '#'); + if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) { + return null; + } + + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))]; + } + + /** + * @param array{int, int, int} $a + * @param array{int, int, int} $b + */ + private static function distance(array $a, array $b): float + { + return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2); + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/TreeEditor.php src/Integrations/Divi/Compose/Warnings.php tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Unit/Integrations/Divi/Compose/WarningsTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi tree editor and quality warnings + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 5: Speicher-Pipeline – `DiviSaver`, `DiviWriter`, `DiviComposition` + +Spec §5.6 und D7: `DiviSaver` legt den Wiederherstellungspunkt an (`RevisionPoint::capture`), speichert mit `wp_slash()` über `wp_update_post()` (Status unverändert) und schaltet dabei **nur für Benutzer ohne `unfiltered_html`** kses aus (`kses_remove_filters()` … `kses_init_filters()` im `finally`) – sonst würde `filter_block_content()` jeden unberührten Block neu serialisieren. Danach Divi-Flags und Divi-CSS-Cache (Fehler dort nur als Warnung über `FollowUp`), und eine Warnung, falls ein Filter den gespeicherten Inhalt verändert hat. Wirft ein Hook, wird der vorherige Inhalt zurückgeschrieben (`WriteFailed` rolled back; misslingt auch das: `partial_write`). `DiviWriter` ist der gemeinsame Rahmen aller Divi-Schreib-Tools: `page()` prüft Rechte, `writable()` und die `tree_rev`-Sperre (Ruling P16); `save()` serialisiert, speichert (nichts, wenn sich nichts ändert), ruft `lit_mcp_after_save`, protokolliert und baut das `ToolResult` (geänderte Knoten mit neuen IDs, Outline der betroffenen Abschnitte, Warnungen, `revision_id`, `rev`, `tree_rev`, `preview_url`, `edit_url` mit `et_fb=1`). `DiviComposition` verdrahtet die Compose-Klassen einmal je Anfrage mit Live-Katalog, Designsystem, `WpValueSanitizer` und `WpMediaResolver`. + +**Files:** +- Create: `src/Integrations/Divi/Pages/{WpValueSanitizer,WpMediaResolver,DiviComposition,DiviChange,DiviSaver,DiviWriter}.php` +- Test: `tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php` + +**Interfaces:** +- Consumes: `RevisionPoint::capture`, `PostGuard::missedSchedule`, `FollowUp::run`, `Errors::revMismatch/notSavedKeeping`, `WriteFailed`, `ToolResult`, `Rev::of/ofPost`, `PostChangeResult` (`afterSave`, `reread`, `log`, `result`), `ChangeLog::recordPartial` (Etappe 1); `DiviPageLoader::load/writable/OUTLINE_HINT`, `DiviPage`, `NodeIndex`, `NodeSummary::label`, `Outline`, `Serializer::document`, `DesignSystemReader`, `DiviModules` (2a); Compose-Klassen (Tasks 1–4). +- Produces: + - `WpValueSanitizer implements ValueSanitizer` (`sanitize_text_field`, `wp_kses_post`, Code ungefiltert nur mit `unfiltered_html`, `esc_url_raw`); `WpMediaResolver implements MediaResolver` (nur Bilder, die der Benutzer lesen darf). + - `DiviComposition` (nicht final): `__construct(DiviModules, DesignSystemReader, ValueSanitizer = new WpValueSanitizer(), MediaResolver = new WpMediaResolver())`; `composer(): Composer`; `attrWriter(): AttrWriter`; `rules(): StructureRules`; `editor(): TreeEditor`; `warnings(): Warnings`. + - `DiviChange(string $summary)`: `changed(Block $b, string $action): self` (inserted/updated/moved/duplicated; alles außer moved zählt für Warnungen); `removed(string $idBefore, Block $b): self`; `warning(string): self`; `entries()`, `touched()`, `warnings()`; `readonly string $summary`. + - `DiviSaver` (nicht final): `REASON`, `CHANGED_ON_SAVE`; `save(WP_Post $post, string $content): array{revision_id: ?int, warnings: list}` (wirft `WriteFailed`). + - `DiviWriter` (nicht final): `__construct(DiviPageLoader, DiviSaver, ChangeLog, DesignSystemReader, DiviComposition)`; `static inputProperties(): array` (`post_id`, `tree_rev`); `static positionProperties(): array` (`parent`, `before`, `after`, `index`); `page(array $input): DiviPage|WP_Error`; `static sectionLabel(NodeIndex $index, ?Block $block): string`; `save(string $ability, DiviPage $page, DiviChange $change, array $input): array` (ToolResult-Array mit `tree_rev`; wirft `WriteFailed`). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`: +```php + */ + private array $calls = []; + + protected function setUp(): void + { + parent::setUp(); + $this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']); + Functions\when('wp_save_post_revision')->justReturn(811); + Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); + Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool { + $this->calls[] = "meta:$key=$value"; + + return true; + }); + Functions\when('kses_remove_filters')->alias(function (): void { + $this->calls[] = 'kses off'; + }); + Functions\when('kses_init_filters')->alias(function (): void { + $this->calls[] = 'kses on'; + }); + } + + public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\expect('wp_update_post')->once()->with(['ID' => 7, 'post_content' => 'NEU'], true) + ->andReturnUsing(function (): int { + $this->calls[] = 'update'; + + return 7; + }); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertSame(['revision_id' => 811, 'warnings' => []], $saved); + self::assertSame( + ['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'], + $this->calls + ); + } + + public function testKeepsKsesUntouchedForUnfilteredUsersAndWarnsWhenAFilterChangedTheContent(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU, gefiltert'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertNotContains('kses off', $this->calls); + self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']); + } + + public function testRefusedUpdateIsACleanFailure(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(new WP_Error('db_update_error', 'DB kaputt')); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(811, $e->revisionId); + self::assertStringContainsString('Revision 811 bleibt erhalten', $e->toError()->get_error_message()); + } + } + + public function testAThrowingHookRestoresThePreviousContent(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + $written = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$written): int { + $written[] = $data['post_content']; + if ($data['post_content'] === 'NEU') { + throw new RuntimeException('save_post hook failed'); + } + + return 7; + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(['NEU', 'ALT'], $written); + } + } + + public function testFailedRestoreIsAPartialWrite(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + Functions\when('wp_update_post')->alias(static function (): int { + throw new RuntimeException('database gone'); + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } +} +``` + +`tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php`: +```php +allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + + return new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()) + ); + } + + public function testPageChecksTheTreeRevLock(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class)); + + self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])); + self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code()); + $stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviPageLoaderTest::givenPost($content))]); + self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev'); + } + + public function testSectionLabelOfANode(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $page = (new DiviPageLoader(DiviPageLoaderTest::schema()))->load(7, true); + + self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1'))); + self::assertSame('', DiviWriter::sectionLabel($page->index, null)); + } + + public function testUnchangedTreeIsNotSaved(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->expects('save')->never(); + $writer = $this->writer($saver, Mockery::mock(ChangeLog::class)); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + + $result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []); + + self::assertSame(Rev::of($content), $result['tree_rev']); + self::assertStringStartsWith('Keine Änderung', $result['summary']); + } + + public function testFailedRollbackIsLoggedAsPartial(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811)); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811); + $writer = $this->writer($saver, $log); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + $page->document->children()[0]->removeChild($page->index->find('s1')); + + $this->expectException(WriteFailed::class); + $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviSaverTest|DiviWriterTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Pages\DiviSaver" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Pages/WpValueSanitizer.php`: +```php + $url, 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true)]; + } +} +``` + +`src/Integrations/Divi/Pages/DiviComposition.php`: +```php +composer === null) { + $placeholders = new Placeholders($this->design->read()); + $this->composer = new Composer( + $this->modules->catalog(), + $this->rules(), + new ShortformWriter($placeholders, $this->sanitizer, $this->media, $this->attrWriter()), + $this->attrWriter(), + $this->design->read(), + $this->modules->builderVersion() + ); + } + + return $this->composer; + } + + public function attrWriter(): AttrWriter + { + return $this->attrWriter ??= new AttrWriter( + $this->modules->catalog(), + new Placeholders($this->design->read()), + $this->sanitizer + ); + } + + public function rules(): StructureRules + { + return new StructureRules($this->modules->catalog()); + } + + public function editor(): TreeEditor + { + return new TreeEditor($this->rules()); + } + + public function warnings(): Warnings + { + return new Warnings($this->design->read()); + } +} +``` + +`src/Integrations/Divi/Pages/DiviChange.php`: +```php + */ + private array $changed = []; + + /** @var list */ + private array $touched = []; + + /** @var list */ + private array $warnings = []; + + public function __construct(public readonly string $summary) + { + } + + /** A node that still exists after the change (inserted, updated, moved, duplicated). */ + public function changed(Block $block, string $action): self + { + $this->changed[] = [ + 'block' => $block, + 'id' => null, + 'type' => $block->name(), + 'action' => $action, + 'label' => NodeSummary::label($block), + ]; + if ($action !== 'moved') { + $this->touched[] = $block; + } + + return $this; + } + + /** A node that is gone; $id is its ID before the change. */ + public function removed(string $id, Block $block): self + { + $this->changed[] = [ + 'block' => null, + 'id' => $id, + 'type' => $block->name(), + 'action' => 'deleted', + 'label' => NodeSummary::label($block), + ]; + + return $this; + } + + public function warning(string $warning): self + { + $this->warnings[] = $warning; + + return $this; + } + + /** @return list */ + public function entries(): array + { + return $this->changed; + } + + /** @return list */ + public function touched(): array + { + return $this->touched; + } + + /** @return list */ + public function warnings(): array + { + return $this->warnings; + } +} +``` + +`src/Integrations/Divi/Pages/DiviSaver.php`: +```php +} + * @throws WriteFailed + */ + public function save(WP_Post $post, string $content): array + { + $postId = (int) $post->ID; + $revisionId = RevisionPoint::capture($postId); + + try { + $result = $this->update($postId, $content); + } catch (Throwable $e) { + $this->restore($postId, (string) $post->post_content, $revisionId, $e); + } + + if ($result instanceof WP_Error || $result === 0) { + $message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0'; + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + null, + sprintf('wp_update_post(%d) refused: %s', $postId, $message), + rolledBackState: self::keeping($revisionId) + ); + } + + $warnings = []; + FollowUp::run( + static function () use ($postId): void { + update_post_meta($postId, '_et_pb_use_divi_5', 'on'); + update_post_meta($postId, '_et_pb_use_builder', 'on'); + }, + 'divi builder flags', + 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' + . 'einmal im Divi-Builder öffnen und speichern.', + $warnings + ); + FollowUp::run( + static function () use ($postId): void { + if (class_exists('ET_Core_PageResource')) { + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + } + }, + 'divi static css', + 'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. ' + . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', + $warnings + ); + + $stored = get_post($postId); + if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) { + $warnings[] = self::CHANGED_ON_SAVE; + } + + return ['revision_id' => $revisionId, 'warnings' => $warnings]; + } + + private function update(int $postId, string $content): int|WP_Error + { + $kses = ! current_user_can('unfiltered_html') + && has_filter('content_save_pre', 'wp_filter_post_kses') !== false; + if ($kses) { + kses_remove_filters(); + } + + try { + return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true); + } finally { + if ($kses) { + kses_init_filters(); + } + } + } + + /** @throws WriteFailed Always. */ + private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never + { + $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); + try { + $current = get_post($postId); + if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { + throw new RuntimeException('restoring would publish a missed schedule'); + } + + $restored = $this->update($postId, $before); + if ($restored instanceof WP_Error || $restored === 0) { + throw new RuntimeException('previous content could not be written back'); + } + } catch (Throwable $e) { + $detail .= '; rollback: ' . $e->getMessage(); + throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail); + } + + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + $cause, + $detail, + rolledBackState: self::keeping($revisionId) + ); + } + + /** write_failed must not claim "nothing was saved" while the restore-point revision stays. */ + private static function keeping(?int $revisionId): ?string + { + return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId)); + } +} +``` + +`src/Integrations/Divi/Pages/DiviWriter.php`: +```php +> post_id and tree_rev of every Divi write. */ + public static function inputProperties(): array + { + return [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'tree_rev' => [ + 'type' => 'string', + 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', + ], + ]; + } + + /** @return array> Target position of insert and move. */ + public static function positionProperties(): array + { + return [ + 'parent' => [ + 'type' => 'string', + 'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: ' + . 'Ende der Seite.', + ], + 'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'], + 'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'], + 'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'], + ]; + } + + /** + * The page to change, after rights, type, layout and the tree_rev lock were checked. + * + * @param array $input + */ + public function page(array $input): DiviPage|WP_Error + { + $page = $this->loader->load($input['post_id'] ?? null, true); + if ($page instanceof WP_Error) { + return $page; + } + + $problem = $this->loader->writable($page); + if ($problem !== null) { + return $problem; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde ' + . 'geschrieben.', + DiviPageLoader::OUTLINE_HINT + ); + } + + return $page; + } + + /** Section label around $block, for automatic admin labels ("" when none). */ + public static function sectionLabel(NodeIndex $index, ?Block $block): string + { + for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) { + if ($node->name() === 'divi/section') { + return NodeSummary::label($node); + } + } + + return ''; + } + + /** + * Saves the changed tree of $page and returns the ToolResult array. + * + * @param array $input + * @return array + * @throws WriteFailed + */ + public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array + { + $post = $page->post; + $content = Serializer::document($page->document); + if ($content === (string) $post->post_content) { + return ToolResult::ok('Keine Änderung – die Seite ist bereits so.') + ->rev(Rev::ofPost($post)) + ->with('tree_rev', $page->treeRev()) + ->warnings($change->warnings()) + ->toArray(); + } + + $index = NodeIndex::build($page->document); + $quality = $this->composition->warnings()->collect($index, $change->touched()); + $warnings = array_merge($change->warnings(), $quality); + + try { + $saved = $this->saver->save($post, $content); + } catch (WriteFailed $e) { + if (! $e->rolledBack) { + $this->log->recordPartial( + $ability, + (string) $post->post_type, + (string) $post->ID, + sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $e->revisionId + ); + } + throw $e; + } + + $result = (new PostChangeResult( + $ability, + $this->log, + (int) $post->ID, + 'die Änderung', + array_merge($warnings, $saved['warnings']) + ))->afterSave($input); + $updated = $result->reread($post) ?? $post; + $summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary); + $result->log((string) $post->post_type, $summary, $saved['revision_id']); + + $status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status); + $tool = $result->result($status, $updated) + ->revisionId($saved['revision_id']) + ->outline($this->outline($index, $change)) + ->with('tree_rev', Rev::of((string) $updated->post_content)) + ->urls((string) get_preview_post_link($updated), $page->editUrl()); + foreach ($change->entries() as $entry) { + $id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null); + $tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']); + } + + return $tool->toArray(); + } + + /** Outline of the sections that contain changed nodes. */ + private function outline(NodeIndex $index, DiviChange $change): ?string + { + $sections = []; + foreach ($change->entries() as $entry) { + $id = $entry['block'] !== null ? $index->idOf($entry['block']) : null; + if ($id !== null) { + $sections[explode('.', $id)[0]] = true; + } + } + + if ($sections === []) { + return null; + } + + $outline = new Outline($this->design->read()->presetNames()); + $texts = []; + foreach (array_keys($sections) as $sectionId) { + $texts[] = $outline->text($outline->rows($index, (string) $sectionId)); + } + + return implode("\n", $texts); + } + + private static function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Pages/WpValueSanitizer.php src/Integrations/Divi/Pages/WpMediaResolver.php src/Integrations/Divi/Pages/DiviComposition.php src/Integrations/Divi/Pages/DiviChange.php src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviWriter.php tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 6: `lit/divi-insert` und `lit/divi-update` + +Spec §5.7: Einfügen von Knoten in Kurz- und/oder Vollform – Module, Zeilen oder ganze Abschnitte – an `parent`/`before`/`after` (ohne Position ans Seitenende); Ändern mehrerer vorhandener Knoten in einem Aufruf (`changes` mit `set`, `attrs`, `preset`, `label`), alles oder nichts. Unbekannte Attributpfade → neuer Fehlercode `invalid_attr_path` (Spec §8.2) mit `fields`; nur lesbare Knoten (globale Module, fremde Blöcke) lassen sich nicht ändern. Die Divi-Integration bekommt dafür das Änderungsprotokoll. + +**Files:** +- Create: `src/Integrations/Divi/Pages/{DiviInsertAbility,DiviUpdateAbility}.php` +- Modify: `src/Abilities/Errors.php` (`invalidAttrPath()`), `src/Integrations/Divi/DiviIntegration.php` (ganze Datei ersetzen), `src/Plugin.php`, `docs/guides/overview.md` +- Test: `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviChange`, `DiviComposition::composer/editor` (Task 5); `Composer::compose/update`, `TreeEditor::position/insert` (Tasks 3–4); `ToolResult::schema`. +- Produces: + - `Errors::invalidAttrPath(array $fields): WP_Error` (Code `lit_invalid_attr_path`, Status 400). + - `DiviInsertAbility(DiviWriter, DiviComposition)` – `lit/divi-insert`, direkt; Input `{post_id, tree_rev, nodes (1–20 Objekte), parent?, before?, after?, index?}`. + - `DiviUpdateAbility(DiviWriter, DiviComposition)` – `lit/divi-update`, direkt, idempotent; Input `{post_id, tree_rev, changes: [{id, set?, attrs?, preset?, label?}] (1–50)}`. + - `DiviIntegration(ContentSchema $schema, ChangeLog $log)` (neue Signatur). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`: +```php + Contents handed to DiviSaver::save(). */ + private array $saved = []; + + private DiviWriter $writer; + private DiviComposition $composition; + + protected function setUp(): void + { + parent::setUp(); + $this->original = DiviPageLoaderTest::fixture('synthetic-basic.html'); + $this->post = DiviPageLoaderTest::givenPost($this->original); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $this->composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()); + + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array { + $this->saved[] = $content; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(4711); + + $this->writer = new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + $this->composition + ); + } + + private function treeRev(): string + { + return Rev::of($this->original); + } + + public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void + { + $ability = new DiviInsertAbility($this->writer, $this->composition); + + $result = $ability->execute([ + 'post_id' => 7, + 'tree_rev' => $this->treeRev(), + 'after' => 's0.r0.c1.m1', + 'nodes' => [['type' => 'text', 'content' => '

Neu

']], + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + self::assertCount(1, $this->saved); + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + self::assertSame(Rev::ofPost($this->post), $result['rev']); + self::assertSame(811, $result['revision_id']); + self::assertSame(4711, $result['log_id']); + self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']); + self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); + + // Byte identity: only the new block was added, every other byte is unchanged. + $inserted = ''; + self::assertSame(str_replace('' . $inserted . ''; + private const INPUT = ['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]]; + + private function schema(): ContentSchema + { + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return $schema; + } + + private function writer(): PostWriter + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Start']); + $writer->allows('deniedMeta')->andReturn([]); + $writer->allows('writeExtras'); + $writer->allows('deleteCreatedTerms'); + + return $writer; + } + + private function composer(bool $available = true): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn($available); + $composer->allows('compose')->with('page', [['type' => 'section']]) + ->andReturn(['content' => self::MARKUP, 'warnings' => ['s0: leer (section ohne Inhalt).']]); + + return $composer; + } + + public function testCreatesADiviDraftWithTreeRev(): void + { + $composer = $this->composer(); + $composer->expects('afterInsert')->with(42); + Functions\expect('wp_insert_post')->once()->with([ + 'post_title' => 'Start', + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_author' => 3, + 'post_content' => self::MARKUP, + ], true)->andReturn(42); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_content' => self::MARKUP])); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit'); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(9); + + $result = (new ContentCreateAbility($this->schema(), $this->writer(), $log, $composer))->execute(self::INPUT); + + self::assertTrue($result['ok']); + self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']); + self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']); + } + + public function testDiviAndContentExcludeEachOtherAndNeedDivi(): void + { + Functions\expect('wp_insert_post')->never(); + + $both = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $this->composer())) + ->execute(self::INPUT + ['content' => '

x

']); + self::assertSame('lit_validation_failed', $both->get_error_code()); + + $inactive = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $this->composer(false))) + ->execute(self::INPUT); + self::assertSame('lit_unsupported', $inactive->get_error_code()); + + $none = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class)))->execute(self::INPUT); + self::assertSame('lit_unsupported', $none->get_error_code()); + + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn(true); + $composer->allows('compose')->andReturn(Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.')); + $invalid = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $composer)) + ->execute(self::INPUT); + self::assertSame('lit_validation_failed', $invalid->get_error_code()); + } + + public function testFailingBuilderFlagsDiscardTheDraft(): void + { + $composer = $this->composer(); + $composer->allows('afterInsert')->andThrow(new RuntimeException('meta failed')); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + + try { + (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $composer)) + ->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } +} +``` + +`tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php`: +```php +allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + + return new DiviContentComposer( + DiviPageLoaderTest::schema($diviEnabled), + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()) + ); + } + + public function testComposesAPlaceholderWrappedLayout(): void + { + $result = $this->composer()->compose('page', [ + ['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'Willkommen']]], + ['type' => 'section', 'modules' => [['type' => 'image', 'src' => 'https://kunde.test/a.jpg']]], + ]); + + self::assertStringStartsWith('', $result['content']); + $document = (new Parser())->parse($result['content']); + self::assertCount(2, $document->children()[0]->children()); + self::assertSame(['s1.r0.c0.m0: Bild ohne Alt-Text.'], $result['warnings']); + } + + public function testRefusesTypesWithoutBuilderAndInvalidNodes(): void + { + self::assertSame('lit_validation_failed', $this->composer(false)->compose('post', [['type' => 'section']])->get_error_code()); + + $invalid = $this->composer()->compose('page', [['type' => 'section', 'modules' => [['type' => 'button', 'attrs' => ['x.y' => ['desktop' => 1]]]]]]); + self::assertSame('lit_invalid_attr_path', $invalid->get_error_code()); + self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.x.y', $invalid->get_error_data()['fields']); + } + + public function testAfterInsertSetsTheBuilderFlags(): void + { + $flags = []; + Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$flags): int { + $flags[$key] = $value; + + return 1; + }); + + $this->composer()->afterInsert(42); + self::assertSame(['_et_pb_use_divi_5' => 'on', '_et_pb_use_builder' => 'on'], $flags); + + Functions\when('update_post_meta')->justReturn(false); + $this->expectException(RuntimeException::class); + $this->composer()->afterInsert(42); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'ContentCreateDiviTest|DiviContentComposerTest'` +Expected: FAIL – `Interface "LIT\MCP\Integrations\WordPress\Content\ContentComposer" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Content/ContentComposer.php`: +```php + $sections + * @return array{content: string, warnings: list}|WP_Error + */ + public function compose(string $postType, array $sections): array|WP_Error; + + /** + * Runs after the new draft exists (e.g. builder flags). Throwing makes lit/content-create + * delete the draft again (all or nothing). + */ + public function afterInsert(int $postId): void; +} +``` + +`src/Integrations/Divi/Pages/DiviContentComposer.php`: +```php +schema->diviEnabled($postType)) { + return Errors::invalidInput( + 'Für diesen Inhaltstyp ist der Divi-Builder nicht aktiviert; nichts wurde angelegt.', + ['divi' => 'Divi-Builder für "' . $postType . '" nicht aktiv'] + ); + } + + $composed = $this->composition->composer()->compose(array_values($sections), null, 'divi'); + if ($composed['errors'] !== []) { + return $composed['invalid_paths'] !== [] + ? Errors::invalidAttrPath($composed['errors']) + : Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']); + } + + $document = new Document(); + $document->append(Block::create('divi/placeholder', [], $composed['blocks'])); + + return [ + 'content' => Serializer::document($document), + 'warnings' => $this->composition->warnings()->collect(NodeIndex::build($document), $composed['blocks']), + ]; + } + + public function afterInsert(int $postId): void + { + if ( + update_post_meta($postId, '_et_pb_use_divi_5', 'on') === false + || update_post_meta($postId, '_et_pb_use_builder', 'on') === false + ) { + throw new RuntimeException(sprintf('Divi builder flags of post %d could not be set.', $postId)); + } + } +} +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php +use LIT\MCP\Abilities\Errors; +``` +durch: +```php +use LIT\MCP\Abilities\Errors; +use LIT\MCP\Abilities\Rev; +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + private PostWriter $writer, + private ChangeLog $log + ) { + } +``` +durch: +```php + private PostWriter $writer, + private ChangeLog $log, + private ?ContentComposer $divi = null + ) { + } +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + . 'in einem Schritt; bei einem Fehler wird nichts angelegt.'; +``` +durch: +```php + . 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste ' + . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content.'; +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + 'title' => ['type' => 'string', 'minLength' => 1], + ], PostWriter::inputProperties()), +``` +durch: +```php + 'title' => ['type' => 'string', 'minLength' => 1], + 'divi' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 30, + 'items' => ['type' => 'object'], + 'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit ' + . 'content).', + ], + ], PostWriter::inputProperties()), +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + 'post_author' => get_current_user_id(), + ]); +``` +durch: +```php + 'post_author' => get_current_user_id(), + ]); + $warnings = $prepared['warnings']; + if (array_key_exists('divi', $input)) { + $divi = $this->composeDivi($postType, $input); + if ($divi instanceof WP_Error) { + return $divi; + } + $data['post_content'] = $divi['content']; + $warnings = array_merge($warnings, $divi['warnings']); + } +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + try { + $this->writer->writeExtras($postId, $postType, $prepared, $input); + } catch (Throwable $e) { + $this->discard($postId, $postType, $e); + } + + return $this->result($postId, $postType, $input, $prepared['warnings']); + } +``` +durch: +```php + try { + $this->writer->writeExtras($postId, $postType, $prepared, $input); + if (array_key_exists('divi', $input) && $this->divi !== null) { + $this->divi->afterInsert($postId); + } + } catch (Throwable $e) { + $this->discard($postId, $postType, $e); + } + + return $this->result($postId, $postType, $input, $warnings); + } + + /** + * Divi layout from the short form (spec §6.2) – validated completely before the insert. + * + * @param array $input + * @return array{content: string, warnings: list}|WP_Error + */ + private function composeDivi(string $postType, array $input): array|WP_Error + { + if ($this->divi === null || ! $this->divi->available()) { + return Errors::unsupported( + 'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.', + 'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.' + ); + } + + if (array_key_exists('content', $input)) { + return Errors::invalidInput( + 'content und divi schließen sich aus; nichts wurde angelegt.', + ['content' => 'bei Divi-Inhalt weglassen'] + ); + } + + return $this->divi->compose($postType, is_array($input['divi']) ? array_values($input['divi']) : []); + } +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) + ->changed((string) $postId, $postType, 'created', $title) + ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) + ->toArray(); +``` +durch: +```php + $result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) + ->changed((string) $postId, $postType, 'created', $title) + ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')); + if (array_key_exists('divi', $input) && $post instanceof WP_Post) { + // The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away. + $result->with('tree_rev', Rev::of((string) $post->post_content)); + } + + return $result->toArray(); +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; +``` +durch: +```php +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; +use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php + public function __construct(private ContentSchema $schema, private ChangeLog $log) + { + } +``` +durch: +```php + public function __construct( + private ContentSchema $schema, + private ChangeLog $log, + private ?ContentComposer $divi = null + ) { + } +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php + new ContentCreateAbility($this->schema, $writer, $this->log), +``` +durch: +```php + new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi), +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\Integration; +``` +durch: +```php +use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php + * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded + * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). + */ +``` +durch: +```php + * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded + * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). contentComposer() gives + * lit/content-create the Divi short form (spec §6.2). + */ +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php + public function abilities(): array + { + $loader +``` +durch: +```php + public function contentComposer(): ContentComposer + { + return new DiviContentComposer($this->schema, $this->composition); + } + + public function abilities(): array + { + $loader +``` + +In `src/Plugin.php` ersetzen: +```php + $schema = self::contentSchema(); + $registry = new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides'), + new WordPressIntegration($schema, $changeLog), + new MediaIntegration($schema, $changeLog, $limits), + new DiviIntegration($schema, $changeLog) + ); +``` +durch: +```php + $schema = self::contentSchema(); + $divi = new DiviIntegration($schema, $changeLog); + $registry = new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides'), + new WordPressIntegration($schema, $changeLog, $divi->contentComposer()), + new MediaIntegration($schema, $changeLog, $limits), + $divi + ); +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: PASS (auch die bestehenden `ContentCreateAbilityTest`-Fälle ohne `divi`), phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/WordPress/Content/ContentComposer.php src/Integrations/Divi/Pages/DiviContentComposer.php src/Integrations/WordPress/Content/ContentCreateAbility.php src/Integrations/WordPress/WordPressIntegration.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: lit/content-create with Divi short form + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 10: Hinweise, Guides, Version 0.3.0, Ende-zu-Ende-Test und Abnahme + +Die Hinweise nennen jetzt die Schreib-Tools (`PostPresenter::DIVI_NOTE`, Outline-Hinweise, Katalog-Hinweis), die Serveranweisungen bekommen die `tree_rev`-Regel (Spec §9.1), zwei neue Guides (Spec §9.2: `divi-shortform`, `divi-placeholders`) und Ergänzungen in `divi-structure`, `divi-attrs`, `overview`, `workflows`, `content-types`. Version 0.3.0 mit Readme (Auswirkungen auf die Website: kses-Umgehung beim Divi-Speichern und Divi-Cache; Rückgängig über Revisionen). Ein Ende-zu-Ende-Skript prüft alle Schreib-Tools gegen echtes Divi über den OAuth-Endpunkt – einschließlich eines Benutzers ohne `unfiltered_html`. + +**Files:** +- Modify: `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/Divi/Pages/DiviOutlineAbility.php`, `src/Integrations/Divi/Schema/DiviCatalogAbility.php`, `src/Guides/InstructionsBuilder.php`, `src/Plugin.php`, `wp-lit-mcp.php`, `readme.txt`, `docs/guides/{divi-structure,divi-attrs,overview,workflows,content-types}.md` +- Create: `docs/guides/divi-shortform.md`, `docs/guides/divi-placeholders.md`, `tests/Integration/divi-write-smoke.sh` +- Test: `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php` + +**Interfaces:** +- Consumes: alle Schreib-Tools (Tasks 6–9); `tests/Integration/lib.sh` (`connect`, `rpc`, `tool`, `ability`, `error_code`, `cleanup_app_passwords_for`). +- Produces: `PostPresenter::DIVI_NOTE` (nennt `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`); Serveranweisungen mit Zeile `- Divi-Änderungen verlangen die tree_rev …`; `Plugin::VERSION = '0.3.0'`. + +- [ ] **Step 1: Failing Tests** + +In `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` ersetzen: +```php + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']); +``` +durch: +```php + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]); +``` + +In `tests/Unit/Guides/InstructionsBuilderTest.php` ersetzen: +```php + self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); + self::assertStringNotContainsString('lit-divi-outline und', $without); +``` +durch: +```php + self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); + self::assertStringContainsString('tree_rev', $with); + self::assertStringNotContainsString('lit-divi-outline und', $without); +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviReadAbilitiesTest|InstructionsBuilderTest'` +Expected: FAIL – Hinweis ohne `lit-divi-insert` bzw. Serveranweisungen ohne `tree_rev`. + +- [ ] **Step 3: Implementierung – Hinweise, Anweisungen, Version** + +In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen: +```php + /** How Divi content is read and changed with the Divi tools. */ + public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit ' + . 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.'; +``` +durch: +```php + /** How Divi content is read and changed with the Divi tools. */ + public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit ' + . 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, ' + . 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.'; +``` + +In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen: +```php + $hints[] = $page->isDiviLayout() + ? 'Die Seite hat noch keinen Divi-Inhalt.' + : 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.'; +``` +durch: +```php + $hints[] = $page->isDiviLayout() + ? 'Die Seite hat noch keinen Divi-Inhalt: mit lit-divi-insert (ohne parent) den ersten Abschnitt ' + . 'einfügen.' + : 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.'; +``` + +In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen: +```php + if (in_array(false, array_column($rows, 'editable'), true)) { + $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; ' + . 'ihren Inhalt nur im Divi-Builder bearbeiten.'; + } +``` +durch: +```php + if (in_array(false, array_column($rows, 'editable'), true)) { + $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' + . 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.'; + } +``` + +In `src/Integrations/Divi/Schema/DiviCatalogAbility.php` ersetzen: +```php + $catalog = $this->modules->catalog(); + $notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".']; +``` +durch: +```php + $catalog = $this->modules->catalog(); + $notes = [self::CODE_NOTE, 'Details und Beispiele: lit-guide mit topic "divi-shortform" bzw. "divi-attrs".']; +``` + +In `src/Guides/InstructionsBuilder.php` ersetzen: +```php + $divi = str_contains(', ' . $active . ',', ', divi,') + ? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' + . 'topic "divi-structure".'] + : []; +``` +durch: +```php + $divi = str_contains(', ' . $active . ',', ', divi,') + ? [ + '- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' + . 'topic "divi-structure".', + '- Divi-Änderungen verlangen die tree_rev aus lit-divi-outline bzw. der letzten Divi-Antwort ' + . '(nicht rev).', + ] + : []; +``` + +In `src/Plugin.php` ersetzen: +```php +{ + public const VERSION = '0.2.0'; +``` +durch: +```php +{ + public const VERSION = '0.3.0'; +``` + +In `wp-lit-mcp.php` ersetzen: +```php + * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. + * Version: 0.2.0 + * Requires at least: 6.9 +``` +durch: +```php + * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. + * Version: 0.3.0 + * Requires at least: 6.9 +``` + +In `readme.txt` ersetzen: +```text +Requires PHP: 8.1 +Stable tag: 0.2.0 +License: GPL-2.0-or-later +``` +durch: +```text +Requires PHP: 8.1 +Stable tag: 0.3.0 +License: GPL-2.0-or-later +``` + +In `readme.txt` ersetzen: +```text + Transient lit_mcp_endpoint_check. +* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar +``` +durch: +```text + Transient lit_mcp_endpoint_check. +* 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, + sanitize_text_field, esc_url_raw); Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert. + Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert. +* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar +``` + +In `readme.txt` ersetzen: +```text + lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten. +* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision +``` +durch: +```text + lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten. +* Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über + lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision. +* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision +``` + +In `readme.txt` ersetzen: +```text + += 0.2.0 = +``` +durch: +```text + += 0.3.0 = +* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog, + Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und + 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. + += 0.2.0 = +``` + +- [ ] **Step 4: Implementierung – Guides** + +`docs/guides/divi-shortform.md`: +````markdown +# Divi-Kurzform + +Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in +`divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label` +(Admin-Label), `preset` (Name eines Modul-Presets aus `lit-divi-design-system`) und `attrs` (Vollform, siehe +`divi-attrs`). Kurz- und Vollform lassen sich kombinieren; `attrs` gewinnt. + +## Aufbau + +```json +{ "type": "section", "label": "Hero", "background_color": "{{color:primary}}", + "rows": [ + { "columns": "1_3,2_3", + "modules": [ + [ { "type": "image", "image_id": 33 } ], + [ { "type": "heading", "text": "Willkommen", "level": "h1" }, + { "type": "text", "content": "

Seit 1987 in Köln.

" }, + { "type": "button", "text": "Termin buchen", "url": "/kontakt", "preset": "CTA primär" } ] + ] } + ] } +``` + +- **Abschnitt** (`section`): `rows` (Zeilen) oder – für eine einspaltige Zeile – direkt `modules`; + `section_type: "fullwidth"` für Vollbreite-Abschnitte (dann nur `fullwidth-…`-Module in `modules`); + `background_color`. Spezialabschnitte (specialty) nur im Divi-Builder bauen. +- **Zeile** (`row`): `columns` = Breiten als Brüche, die zusammen 1 ergeben (`"4_4"`, `"1_2,1_2"`, + `"1_3,2_3"`, `"1_4,1_4,1_2"`, auch Fünftel); `modules` = je Spalte eine Liste (bei einer Spalte reicht eine + einfache Liste). +- **Module** mit Kind-Modulen (Akkordeon, Tabs, Slider, Kontaktformular, Karte, Social-Media-Follow, + Icon-Liste) bekommen ihre Einträge in `items`; der Eintragstyp ergibt sich von selbst. +- `label` fehlt → Admin-Label „ – “, z. B. „Hero – Button“. Abschnitten deshalb ein Label + geben. +- `heading` ohne `level` wird `h2` (eine Seite hat meist schon eine `h1` im Titel). + +## Kurzform-Felder + +| Modul | Felder | +|---|---| +| `text` | `content` (HTML) | +| `heading` | `text`, `level` (`h1`–`h6`) | +| `button` | `text`, `url`, `new_window` (true/false) | +| `image` | `image_id` (Mediathek, setzt URL/ID/Alt), `src`, `alt`, `title`, `url` | +| `blurb` | `title`, `url`, `content`, `image_id`, `src`, `alt`, `level` | +| `cta` | `title`, `content`, `button_text`, `button_url` | +| `icon` | `url`, `title` | +| `divider` | `show`, `color` | +| `testimonial` | `author`, `job_title`, `company`, `company_url`, `content`, `portrait_id`, `portrait_url` | +| `team-member` | `name`, `position`, `content`, `image_id`, `image_url` | +| `accordion` → `accordion-item` | Eintrag: `title`, `content`, `open` | +| `tabs` → `tab` | Eintrag: `title`, `content` | +| `toggle` | `title`, `content`, `open` | +| `gallery` | `image_ids` (Liste von Anhang-IDs) | +| `slider` → `slide` | Eintrag: `title`, `content`, `button_text`, `button_url`, `image_id`, `src`, `alt` | +| `contact-form` → `contact-field` | Formular: `title`, `email`, `success_message`, `submit_text`; Feld: `title`, `id`, `type` (`input`, `email`, `text`, `checkbox`, `radio`, `select`), `required` | +| `map` → `map-pin` | Karte: `address`, `lat`, `lng`, `zoom`; Pin: `title`, `content`, `address`, `lat`, `lng` | +| `video` | `src`, `thumbnail` | +| `number-counter` | `number`, `title`, `percent` | +| `social-media-follow` → `social-media-follow-network` | Eintrag: `network` (z. B. `facebook`), `url`, `label` | +| `icon-list` → `icon-list-item` | Eintrag: `text` | +| `code` | `content` – nur, wenn kein natives Modul passt (Warnung) | + +Alle anderen Module (siehe `lit-divi-catalog`) gehen über `attrs`. Die Kurzform-Felder eines Moduls samt Pfad +zeigt `lit-divi-catalog` mit `module`. + +## Einfügen, ändern, verschieben, löschen + +- `lit-divi-insert`: `nodes` + Position – `parent` (Pfad-ID, z. B. Spalte `s0.r0.c1`; optional `index`), + `before` oder `after`; ohne Position ans Seitenende. Oben auf der Seite sind nur Abschnitte erlaubt, in + Abschnitten Zeilen, in Spalten Module. +- `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA + primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert. +- `lit-divi-move`: `id` + Position (auch in andere Zeilen oder Abschnitte). +- `lit-divi-delete`: `ids`, erst nach Rückfrage mit `confirm: true`. +- `lit/divi-duplicate-node`: Kopie direkt hinter dem Original. +- Jede Antwort enthält die neue `tree_rev` (für die nächste Änderung), `changed` mit den neuen Pfad-IDs, + 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. + +## Warnungen + +Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert; +Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul. Warnungen dem Nutzer +nennen und möglichst gleich beheben. +```` + +`docs/guides/divi-placeholders.md`: +````markdown +# Platzhalter für Divi-Inhalte + +Platzhalter stehen **allein** im Wert (nicht mitten im Text) und werden beim Speichern in Divis eigene +Verweise übersetzt. So bleibt die Seite mit dem Designsystem verbunden: ändert sich die Primärfarbe, ändert +sich auch der Button. + +| Platzhalter | Bedeutung | +|---|---| +| `{{color:primary}}`, `secondary`, `heading`, `body`, `link` | die fünf Theme-Farben | +| `{{color:}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) | +| `{{var:}}` | globale Variable (Zahl, Text, Bild, Link) | +| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext | +| `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags | +| `{{field:}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` | +| `"preset": ""` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) | + +Beispiele: + +```json +{ "type": "button", "text": "Anrufen", "url": "{{field:telefon_link}}", + "attrs": { "button.decoration.background": { "desktop": { "color": "{{color:primary}}" }, + "hover": { "color": "{{color:Nachtblau}}" } } } } +``` + +```json +{ "type": "heading", "text": "{{post:title}}", "level": "h1" } +``` + +Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen. In `lit-divi-node-get` erscheinen die +übersetzten Verweise als `$variable({…})$` – diese Werte unverändert lassen oder wieder durch einen +Platzhalter ersetzen. +```` + +In `docs/guides/divi-structure.md` ersetzen: +```markdown + +## Was nicht bearbeitet wird +``` +durch: +```markdown + +## Ändern + +Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert`, `lit-divi-update`, +`lit-divi-move`, `lit-divi-delete`), `divi-attrs` (Vollform) und `divi-placeholders`. Ablauf: + +1. `lit-divi-outline` lesen (liefert `tree_rev`), `lit-divi-design-system` für Farben, Variablen, Presets. +2. Änderung mit dieser `tree_rev` senden. Die Antwort enthält die neue `tree_rev` und die neuen Pfad-IDs – + damit direkt weiterarbeiten, ohne erneut zu lesen. +3. `rev_mismatch`: jemand hat die Seite inzwischen geändert (z. B. im Divi-Builder) → neu lesen. +4. Zum Schluss `lit-page-render-summary` prüfen und dem Nutzer die Vorschau (`preview_url`) bzw. den + Divi-Builder (`edit_url`) nennen. Der Status bleibt unverändert; veröffentlichte Seiten werden live + geändert (vorher entsteht eine Revision, `revision_id`). + +## Was nicht bearbeitet wird +``` + +In `docs/guides/divi-structure.md` ersetzen: +```markdown + +- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht. +- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder +``` +durch: +```markdown + +- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, + duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später). +- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder +``` + +In `docs/guides/divi-attrs.md` ersetzen: +```markdown + +## Pfade finden +``` +durch: +````markdown + +## Setzen + +In `attrs` (bei `lit-divi-insert` je Knoten, bei `lit-divi-update` je Änderung) steht Pfad → Wert je Breakpoint. +Kurz: `{"desktop": "#1a1a1a", "phone": "…"}` bzw. bei Objektwerten `{"desktop": {"size": "28px"}}`; Hover und +Sticky: `{"hover": {…}}` bzw. `{"desktop": {"value": {…}, "hover": {…}}}`. Nur die genannten Breakpoints +werden ersetzt, der Rest bleibt; `null` entfernt einen Pfad, Breakpoint oder Zustand: + +```json +{ "id": "s0.r0.c1.m0", + "attrs": { "title.decoration.font.font": { "desktop": { "color": "{{color:heading}}" }, + "phone": { "size": "28px" } }, + "module.decoration.spacing": null } } +``` + +Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt +(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). + +## Pfade finden +```` + +In `docs/guides/overview.md` ersetzen: +```markdown +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-attrs` oder `divi-design-system`. +``` +durch: +```markdown +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`. +``` + +In `docs/guides/overview.md` ersetzen: +```markdown +- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei + Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. +- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), +``` +durch: +```markdown +- 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). +- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), +``` + +In `docs/guides/workflows.md` ersetzen: +```markdown +6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. +``` +durch: +```markdown +6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. + +## Divi-Seite bauen + +1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module. +2. Seite als Entwurf mit Inhalt anlegen: `lit-content-create` mit `divi` (Abschnitte in Kurzform, siehe + `divi-shortform`) – oder eine leere Seite anlegen und Abschnitte mit `lit-divi-insert` einfügen. +3. Weitere Abschnitte/Module mit `lit-divi-insert`, Anpassungen mit `lit-divi-update`; immer die `tree_rev` + der letzten Antwort mitgeben. +4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder) + nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`). + +## Text auf vielen Seiten ersetzen + +`lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach +Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf) und `confirm: true` +ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown +- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. + Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert + wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über + `lit-content-update`). +``` +durch: +```markdown +- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. + Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline`, geändert wird er mit den + Divi-Werkzeugen (siehe `divi-structure`); Titel, Auszug, Felder und Begriffe gehen weiter über + `lit-content-update`. +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown +- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler + bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). +- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist +``` +durch: +```markdown +- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler + bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). Divi-Seiten gleich mit + Inhalt: `divi` = Liste von Abschnitten in Kurzform (siehe `divi-shortform`) statt `content`; die Antwort + enthält dann die `tree_rev` für weitere Divi-Änderungen. +- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown +1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. +2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die + Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen. +3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). +``` +durch: +```markdown +1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. +2. Kopie mit `lit-content-update` bzw. den Divi-Werkzeugen bearbeiten und dem Nutzer die Vorschau zeigen; + `lit-page-render-summary` fasst den gerenderten Inhalt zusammen. +3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). +``` + +- [ ] **Step 5: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: alle Tests PASS (inkl. `ToolNamingTest`: in den Guides nur `lit-divi-insert` usw. für direkte und `lit/divi-duplicate-node`, `lit/divi-replace-text` für indirekte Tools), phpcs ohne Meldungen. + +- [ ] **Step 6: Ende-zu-Ende-Test anlegen und alle Integrationstests ausführen** + +`tests/Integration/divi-write-smoke.sh`: +```bash +#!/usr/bin/env bash +# End-to-end checks of the Etappe-2b Divi write tools through the OAuth-protected endpoint. +# Runs INSIDE the DDEV web container (Divi 5 must be the active theme): +# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh +# Creates draft pages, a temporary role/user without unfiltered_html, and removes everything again. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +FIXTURES=$PLUGIN/tests/Fixtures/divi +ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]') +CREATED_POSTS=() + +cleanup() { + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + cleanup_app_passwords_for "$ADMIN_LOGIN" + cleanup_app_passwords_for lit-smoke-editor + wp user delete lit-smoke-editor --yes >/dev/null 2>&1 || true + wp role delete lit_smoke_editor >/dev/null 2>&1 || true + wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true +} +trap cleanup EXIT + +create_page() { # $1 = fixture file, $2 = title -> prints the ID + LIT_FILE="$FIXTURES/$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;' +} + +content_of() { # $1 = post ID + wp post get "$1" --field=post_content +} + +# Whether the stored content still contains the exact bytes of the fixture section "Leistungen". +keeps_leistungen() { # $1 = post ID + LIT_ID="$1" LIT_FILE="$FIXTURES/converted-landing.html" wp eval ' +$fixture = (string) file_get_contents(getenv("LIT_FILE")); +$start = strpos($fixture, "") - $start); +echo str_contains((string) get_post((int) getenv("LIT_ID"))->post_content, $section) ? "yes" : "no";' +} + +[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" +PAGE_ID=$(create_page converted-landing.html "LIT Divi Schreiben") +CREATED_POSTS+=("$PAGE_ID") +connect "$ADMIN_LOGIN" + +# 1. Direct write tools with correct annotations +LIST=$(rpc tools/list '{}') +for t in lit-divi-insert lit-divi-update lit-divi-move lit-divi-delete; do + echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t" +done +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-delete") | .annotations.destructiveHint == true' >/dev/null \ + || fail "lit-divi-delete lacks destructiveHint" +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-replace-text")' >/dev/null \ + && fail "lit-divi-replace-text must be indirect" +pass "tools/list: Divi write tools direct, replace-text indirect" + +# 2. Insert a whole section in short form with placeholders +TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev') +INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1", + nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [ + [{type: "heading", text: "{{post:title}}", level: "h2"}], + [{type: "text", content: "

Hallo Welt

"}, + {type: "button", text: "Mehr erfahren", url: "/mehr", label: "Neu – CTA"}]]}]}]}')") +echo "$INSERT" | jq -e '.ok == true and .changed[0].id == "s1" and .revision_id != null and .log_id != null' >/dev/null \ + || fail "divi-insert: $INSERT" +TREE_REV=$(echo "$INSERT" | jq -r '.tree_rev') +content_of "$PAGE_ID" | grep -q 'u003cscript' && fail "script tag was stored" +[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "insert changed the bytes of an untouched section" +SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}") +echo "$SUMMARY" | jq -e '.headings[] | select(.level == 2 and .text == "LIT Divi Schreiben")' >/dev/null \ + || fail "{{post:title}} not rendered: $SUMMARY" +pass "lit-divi-insert: section with placeholders, sanitized, untouched bytes kept, rendered" + +# 3. Lock and all-or-nothing +STALE=$(tool lit-divi-update "{\"post_id\":$PAGE_ID,\"tree_rev\":\"000000000000\",\"changes\":[{\"id\":\"s0\",\"label\":\"x\"}]}") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale tree_rev accepted: $STALE" +BAD=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt"}}, {id: "s1.r0.c0.m0", attrs: {"title.decoration.fnt": {desktop: "x"}}}]}')") +[ "$(error_code "$BAD")" = "invalid_attr_path" ] || fail "invalid path accepted: $BAD" +[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "failed update wrote something" +pass "tree_rev lock and invalid_attr_path leave the page unchanged" + +# 4. Update, move, delete +UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt anfragen"}, attrs: {"button.decoration.background": {desktop: {color: "{{color:secondary}}"}}}}]}')") +echo "$UPDATE" | jq -e '.ok == true and .changed[0].action == "updated"' >/dev/null || fail "divi-update: $UPDATE" +TREE_REV=$(echo "$UPDATE" | jq -r '.tree_rev') +MOVE=$(tool lit-divi-move "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1.r0.c1.m1\",\"parent\":\"s1.r0.c0\"}") +echo "$MOVE" | jq -e '.changed[0].id == "s1.r0.c0.m1"' >/dev/null || fail "divi-move: $MOVE" +TREE_REV=$(echo "$MOVE" | jq -r '.tree_rev') +ASK=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"]}") +[ "$(error_code "$ASK")" = "confirm_required" ] || fail "delete without confirm: $ASK" +DELETE=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"],\"confirm\":true}") +echo "$DELETE" | jq -e '.changed[0].action == "deleted"' >/dev/null || fail "divi-delete: $DELETE" +TREE_REV=$(echo "$DELETE" | jq -r '.tree_rev') +[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "update/move/delete changed an untouched section" +pass "lit-divi-update, lit-divi-move, lit-divi-delete" + +# 5. Duplicate and replace text (indirect) +DUP=$(ability lit/divi-duplicate-node "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1\"}") +echo "$DUP" | jq -e '.changed[0].id == "s2" and (.changed[0].label | endswith("(Kopie)"))' >/dev/null || fail "duplicate: $DUP" +DRY=$(ability lit/divi-replace-text '{"search":"Köln","replace":"Bonn","limit":100}') +TARGET=$(echo "$DRY" | jq -c --argjson id "$PAGE_ID" '[.matches[] | select(.post_id == $id) | {post_id, tree_rev}]') +[ "$TARGET" != "[]" ] || fail "dry run found nothing: $DRY" +content_of "$PAGE_ID" | grep -q 'Bonn' && fail "dry run changed content" +RUN=$(ability lit/divi-replace-text "$(jq -cn --argjson t "$TARGET" '{search: "Köln", replace: "Bonn", dry_run: false, confirm: true, targets: $t}')") +echo "$RUN" | jq -e '.results[0].ok == true and .results[0].count >= 1' >/dev/null || fail "replace-text: $RUN" +content_of "$PAGE_ID" | grep -q 'Bonn' || fail "replace-text wrote nothing" +pass "lit/divi-duplicate-node and lit/divi-replace-text (dry run, confirmed run)" + +# 6. New Divi page in one call +CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Neu","divi":[{"type":"section","label":"Start","modules":[{"type":"heading","text":"Neu hier"},{"type":"text","content":"

Text

"}]}]}') +NEW_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty') +[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED" +CREATED_POSTS+=("$NEW_ID") +echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED" +[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing" +[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft" +tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered" +pass "lit-content-create with Divi short form" + +# 7. A user without unfiltered_html: untouched bytes still identical, new values still sanitized +wp role create lit_smoke_editor "LIT Smoke Editor" --clone=editor >/dev/null +wp cap remove lit_smoke_editor unfiltered_html >/dev/null +wp option update lit_mcp_allowed_roles '["administrator","lit_smoke_editor"]' --format=json >/dev/null +wp user create lit-smoke-editor lit-smoke-editor@example.invalid --role=lit_smoke_editor >/dev/null +connect lit-smoke-editor +TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev') +EDIT=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s0.r0.c0.m1", set: {content: "

Neu vom Redakteur

"}}]}')") +echo "$EDIT" | jq -e '.ok == true' >/dev/null || fail "editor update: $EDIT" +content_of "$PAGE_ID" | grep -q 'u003cscript' && fail "editor could store a script" +[ "$(keeps_leistungen "$PAGE_ID")" = "no" ] && fail "kses re-serialized untouched blocks of an editor save" +pass "user without unfiltered_html: byte-identical untouched content, sanitized values" + +echo "ALL CHECKS PASSED" +``` + +Run: +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && chmod +x tests/Integration/divi-write-smoke.sh +cd /home/janniclas/Projekte/WordPress +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/media-admin-smoke.sh +``` +Expected: alle fünf Skripte enden mit `ALL CHECKS PASSED` (bekannte WARN-Zeilen aus Etappe 1 sind zulässig). Schlägt Abschnitt 7 von `divi-write-smoke.sh` fehl („kses re-serialized untouched blocks“), ist die kses-Umgehung in `DiviSaver` wirkungslos – nicht den Test lockern. + +- [ ] **Step 7: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/WordPress/Content/PostPresenter.php src/Integrations/Divi/Pages/DiviOutlineAbility.php src/Integrations/Divi/Schema/DiviCatalogAbility.php src/Guides/InstructionsBuilder.php src/Plugin.php wp-lit-mcp.php readme.txt docs/guides tests/Integration/divi-write-smoke.sh tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php tests/Unit/Guides/InstructionsBuilderTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0 + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +- [ ] **Step 8: Manuelle Abnahme (Jan-Niclas, Browser und claude.ai)** + +Der ausführende Agent bereitet vor und meldet; diese Punkte prüft Jan-Niclas selbst (Spec §1.3 Nr. 2, §13 „Bearbeitbarkeit im Visual Builder“): +1. **Visual Builder:** In claude.ai eine Testseite bauen lassen („Lege eine Entwurfsseite ‚Abnahme Divi‘ an mit Hero (Überschrift, Text, Button mit Preset), einer Zeile 1/3 + 2/3 mit Bild und Blurb, einem Akkordeon und einem Kontaktformular“). Die Seite im Divi Visual Builder öffnen (`edit_url`): alle Module erscheinen als native Module mit Admin-Labels „ – “, lassen sich anklicken, ändern und speichern; die Spaltenbreiten stimmen (1/3 + 2/3); Farben aus `{{color:…}}` sind im Builder als globale Farbe verknüpft (Farbwähler zeigt die globale Farbe). +2. **Zurück zu Claude:** Nach dem Speichern im Builder eine Änderung per claude.ai verlangen – `rev_mismatch` erscheint nur, wenn Claude eine alte `tree_rev` nutzt; nach erneutem Lesen gelingt die Änderung, im Builder gemachte Einstellungen bleiben erhalten. +3. **Rückgängig:** Einen Abschnitt löschen lassen (Rückfrage erscheint), danach über `lit/revision-restore` mit der gemeldeten `revision_id` zurückholen. +4. **Ersetzen:** „Ersetze auf allen Seiten ‚Termin‘ durch ‚Beratungstermin‘“ – Claude zeigt erst den Probelauf und fragt nach, dann ersetzt es; im Protokoll-Tab erscheint je Seite ein Eintrag. +5. **Builder-Fixtures (aus 2a, falls noch offen):** Seiten mit globalem Modul, Presets, Variablen und Dynamic Content mit `tests/Integration/divi-export-page.php` exportieren; `composer test` bleibt grün; danach auf einer Kopie einer solchen Seite `lit-divi-update` an einem normalen Modul ausführen – das globale Modul bleibt byte-identisch. + +Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen (z. B. anders gespeicherte Spaltenbreiten, D8) werden als Folgeaufgaben erfasst. + +--- + +## Self-Review (durchgeführt beim Schreiben) + +- **Spec-Abdeckung (Teil 2b):** §5.4 Kurzform (~20 Module + Kind-Module), Vollform mit Pfadprüfung (`invalid_attr_path` mit Vorschlägen), Breakpoints und Hover/Sticky, Kombination Kurz + Voll, Struktur Abschnitt → Zeilen (`columns`) → Spalten → Module, Vollbreite über `section_type`, Platzhalter-Tabelle vollständig inkl. `preset` → Tasks 1–3; §5.5 Warnungen (außer „ähnliche Abschnitte“, D6) und automatische Admin-Labels → Tasks 3–4; §5.6 Speicher-Pipeline Schritte 1–6 → Task 5; §5.7 `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-duplicate-node`, `lit/divi-replace-text` → Tasks 6–8; §6.2 `lit/content-create` mit Divi-Kurzform → Task 9; §8.1 `ToolResult` mit `changed`, `outline`, `rev`, `tree_rev`, `warnings`, `revision_id`, `log_id`, `preview_url`, `edit_url` (`&et_fb=1`) → Task 5; §8.2 Codes `rev_mismatch`, `invalid_attr_path`, `validation_failed`, `confirm_required`, `unsupported`, alles oder nichts, Stapel je Eintrag → Tasks 5–8; §9.1/§9.2 Regeln und Guides `divi-shortform`, `divi-placeholders` → Task 10; §10 Bereinigung (`wp_kses_post`, `sanitize_text_field`) und `confirm: true` → Tasks 1, 5, 7, 8; §11.3 Annotations → Tasks 6–8. Etappe 3: Library, globale Module bearbeiten, Theme Builder, `lit/divi-dynamic-sources`, ähnliche Abschnitte (D6). +- **Gefundene Spec-Lücken und Auflösung:** kses re-serialisiert für Benutzer ohne `unfiltered_html` alle Blöcke → D7 (`DiviSaver`, Readme, E2E-Abschnitt 7); Spaltenformat von Divi 5.2 (Flex, `flexType`) nicht in der Spec → D8; `divi-replace-text` ohne Sperre je Seite → D10 (`targets` mit `tree_rev`); Platzhalter mitten im Text unklar → D9 (nur ganzer Wert); `content-create` mit Divi und kses → D11. +- **Placeholder-Scan:** alle Code-Schritte enthalten vollständigen Code bzw. exakte Ersetzungen (alt → neu); keine „TBD“, keine „wie in Task N“. +- **Typ-Konsistenz geprüft:** `Composer::compose/update` (Rückgabe `blocks|attrs`, `errors`, `invalid_paths`), `TreeEditor::position` (Array `container/parent/index` oder String), `DiviChange::changed/removed/entries/touched/warnings`, `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviSaver::save` (Rückgabe `revision_id`, `warnings`), `DiviComposition::composer/attrWriter/rules/editor/warnings`, `ContentComposer::available/compose/afterInsert`, `Errors::invalidAttrPath` stimmen zwischen Interfaces-Blöcken, Code und Tests überein. +- **Review Focus:** alle fünf Punkte haben ihren Test im genannten Task. Der Code wurde in einer Kopie des Plugins ausgeführt: nach jedem Task `composer test` grün (bis 742 Tests) und phpcs sauber; die Schreib-Pipeline wurde direkt gegen Divi 5.2.1 in DDEV geprüft (Einfügen, Ändern, Verschieben, Löschen, Probelauf, `{{post:title}}` gerendert, unberührte Bytes identisch auch mit aktivem kses). From 040a60ff18540f00eda024bd1cb96b352261e154 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 11:19:37 +0200 Subject: [PATCH 089/185] docs: spec - tree_rev lock (P16), tool budget and decisions for Etappe 2 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../2026-09-26-etappe-2-entscheidungen.md | 17 ++++++++++++ .../specs/2026-09-25-wp-lit-mcp-design.md | 27 ++++++++++--------- 2 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md diff --git a/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md new file mode 100644 index 0000000..6424ebc --- /dev/null +++ b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md @@ -0,0 +1,17 @@ +# Etappe 2 – Entscheidungen der Planung + +Getroffen bei der Planung von Etappe 2a/2b, 2026-09-26. Kosten, falls falsch, jeweils am Ende. + +- D1 (Ruling P16): `rev` = `Rev::ofPost` (ganzer Eintrag); Divi-Sperre = `tree_rev` = `Rev::of(post_content)`. Divi-Lese-Tools liefern beide, Divi-Schreib-Tools verlangen `tree_rev` und liefern beide neu. – Kosten: keine. +- D2: Direkte Tools nach Etappe 2 = 21 (Spec §4.3 ohne `lit/library-insert`, plus `lit/site-status`, `lit/media-download`). `lit/site-status` bleibt direkt (leeres Schema, Anker von mcp-smoke.sh), `lit/media-download` muss direkt bleiben. Indirekt: `lit/divi-duplicate-node`, `lit/divi-replace-text`. – Kosten: etwas mehr Kontext je Unterhaltung; in Etappe 3 neu bewerten. +- D3: BlockTree mit eigenem Tokenizer (Muster von `WP_Block_Parser`), opake Knoten für globale Module/Layouts und undekodierbare Attribute, unbalanciertes Markup → Ablehnung. – Kosten: globale Module erst ab Etappe 3 bearbeitbar. +- D4: `ExposurePolicy::DIRECT_ONLY` bleibt eine Liste; Etappe 2 bringt keine Bild-/Ressourcen-Tools, ein `Ability::requiresDirect()` wäre YAGNI. – Kosten: bei weiteren Bild-Tools die Liste ergänzen. +- D5: `lit/page-render-summary` gehört zur WordPress-Integration (funktioniert für jeden Eintrag) und rendert den Inhaltsbereich im Prozess (`the_content`), ohne HTTP-Loopback. – Kosten: Header/Footer/Theme Builder fehlen im Auszug. +- D6: `lit/divi-dynamic-sources` und die Warnung „ähnliche Abschnitte“ kommen mit Etappe 3 (Theme Builder, `lit/library-find-similar`). – Kosten: bis dahin keine Quellenliste; `{{field:…}}` nimmt jeden Meta-Schlüssel. +- D7 (Ruling R1): Divi-Speichern ohne kses für Benutzer ohne `unfiltered_html` (sonst re-serialisiert WordPress jeden Block) ist nur vertretbar, weil keine client-gelieferte Zeichenkette ungeprüft in `post_content` gelangt. Klartext-Werte werden je Feldtyp bereinigt (Text: `sanitize_text_field`; Rich-Text: `wp_kses_post`, abhängig von `unfiltered_html`; URLs: `esc_url_raw`, geroutet über die Attribut-Metadaten des Katalogs, ersatzweise über den Schlüsselnamen). Werte mit `$variable(...)$` – ob aus Platzhaltern erzeugt oder unverändert aus `lit/divi-node-get` zurückgegeben – werden geparst, streng geprüft (zulässiger Typ, existierende gcid-/gvid-/Dynamic-Content-Quellennamen, whitelisted Settings-Schlüssel, String-Settings bereinigt) und vom Plugin neu serialisiert; alles andere wird mit `validation_failed` abgelehnt. Code-Modul ungefiltert nur mit `unfiltered_html`. – Kosten: jeder neue Schreibpfad muss bereinigen, streng validieren und neu serialisieren (Review-Pflicht). +- R2 (Ruling): Ob Divi das Dynamic-Content-`before`/`after` bei der Ausgabe selbst escaped, wird in Etappe 2b im Divi-Quellcode verifiziert; die Bereinigung aus D7 gilt unabhängig vom Ergebnis (Verteidigung in der Tiefe). – Kosten: keine (Verifikation, keine Abhängigkeit der Sicherheit vom Ergebnis). +- D8: Spalten neuer Zeilen über `module.decoration.sizing.desktop.value.flexType` (`"1_3,2_3"` → `8_24`, `16_24`; Fünftel und 1/7, 1/9, 1/10, 1/11 nativ), wie Divi 5.2 (Flex-Layout ist Standard). – Kosten: falls der Builder anders speichert, nur Composer anpassen. +- D9: Platzhalter stehen allein im Wert; Überschriften der Kurzform ohne `level` werden `h2`. – Kosten: eingebettete dynamische Texte nur über die Vollform. +- D10: `lit/divi-replace-text` je Inhaltstyp; Probelauf liefert `tree_rev` je Seite, Ausführung nur auf diesen `targets` mit `confirm: true`; ersetzt nur sichtbaren Text (`…innerContent`, Schlüssel text/title/label/alt/titleText), nie URLs oder Stile. – Kosten: Text mit HTML-Entitäten muss so gesucht werden, wie er gespeichert ist. +- D11: `lit/content-create` mit `divi` speichert über `wp_insert_post` mit WordPress' üblicher Filterung (nur neuer, selbst erzeugter Inhalt – nichts Unberührtes, das byte-identisch bleiben müsste). – Kosten: für Benutzer ohne `unfiltered_html` kann WordPress Entitäten normalisieren; `tree_rev` in der Antwort beschreibt den gespeicherten Stand. +- D12: Fixtures: handgeschriebene Grenzfälle (`synthetic-*.html`), mit Divis eigener Konvertierung erzeugte Seiten (`converted-*.html`), echte Modul-Metadaten (`metadata-sample.json`); im Visual Builder gebaute Seiten exportiert Jan-Niclas später mit `tests/Integration/divi-export-page.php` als `builder-*.html` – sie laufen automatisch im Roundtrip-Test mit. diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index ee95a69..49af026 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -114,6 +114,7 @@ Ob dieser Aufbau stabil funktioniert, entscheidet Etappe 0 (§12). - **Direkte Tools (~20):** mit vollem Schema sofort sichtbar; nur direkte Tools können Bilder (`ImageContent`) zurückgeben. Standard-Set: `lit/guide`, `lit/content-types`, `lit/content-query`, `lit/content-get`, `lit/content-create`, `lit/content-update`, `lit/post-publish`, `lit/media-search`, `lit/media-preview`, `lit/media-upload-from-url`, `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-design-system`, `lit/divi-catalog`, `lit/library-insert`, `lit/page-render-summary`. +- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). - **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`). - In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind. - Ability-Namen: `lit/-`. Der Adapter bildet daraus Tool-Namen (`lit--`). @@ -173,12 +174,14 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_ - **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt. - Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar). - Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`). +- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert. ### 5.3 Adressierung und optimistische Sperre - `lit/divi-outline` liefert den Baum mit Pfad-IDs (`s0`, `s0.r1`, `s0.r1.c0.m2`), Modultyp, Admin-Label, Textauszug und Preset-Namen. -- Jede Antwort enthält `rev` = Hash des aktuellen `post_content`. -- Jedes Schreib-Tool verlangt `rev`. Weicht er ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einem `rev`. +- Jede Antwort enthält `tree_rev` = Hash des aktuellen `post_content` (Sperre der Divi-Werkzeuge) und `rev` = Sperre des ganzen Eintrags (`Rev::ofPost`, dieselbe wie bei `lit/content-get` und `lit/content-update`; Ruling P16). +- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten. +- Buchstaben der Pfad-IDs: `s` Abschnitt, `r` Zeile (auch `row-inner`), `c` Spalte (auch `column-inner`), `m` Modul (auch Kind-Module und Gruppen), `g` globales Modul bzw. globales Layout, `x` fremder Block (z. B. Block-Editor); gezählt wird je Buchstabe innerhalb des Elternknotens, `divi/placeholder` ist transparent. ### 5.4 Modulbeschreibung @@ -225,7 +228,7 @@ Jede Schreib-Rückmeldung enthält Warnungen zu: - leeren Modulen, - Sprüngen in der Überschriftenhierarchie, - Verwendung des Code-Moduls, -- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`). +- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`) – ab Etappe 3 zusammen mit `lit/library-find-similar` (D6). Kurzform setzt Admin-Labels automatisch (` – `), sofern nicht angegeben. @@ -233,7 +236,7 @@ Kurzform setzt Admin-Labels automatisch (` – `), sofern nich 1. Rechte prüfen (`edit_post`), `rev` prüfen. 2. Änderung auf dem BlockTree ausführen, validieren (alles oder nichts). -3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, WordPress legt eine Revision an. +3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, vorher Wiederherstellungspunkt (Revision). Für Benutzer ohne `unfiltered_html` wird kses für genau diesen Aufruf abgeschaltet: `filter_block_content()` würde sonst jeden unveränderten Block neu serialisieren (Byte-Identität, globale Module; in DDEV nachgewiesen). Das ist nur vertretbar, weil keine client-gelieferte Zeichenkette ungeprüft in `post_content` gelangt (D7): Klartext-Werte werden je Feldtyp bereinigt (Text: `sanitize_text_field`; Rich-Text: `wp_kses_post`, abhängig von `unfiltered_html`; URLs: `esc_url_raw`, geroutet über die Attribut-Metadaten des Katalogs, ersatzweise über den Schlüsselnamen). Werte mit `$variable(...)$` – ob aus Platzhaltern erzeugt oder unverändert aus `lit/divi-node-get` zurückgegeben – werden geparst, streng geprüft (zulässiger Typ, existierende gcid-/gvid-/Dynamic-Content-Quellennamen, whitelisted Settings-Schlüssel, String-Settings bereinigt) und vom Plugin neu serialisiert; alles andere wird mit `validation_failed` abgelehnt. Ob Divi das Dynamic-Content-`before`/`after` bei der Ausgabe selbst escaped, wird in Etappe 2b im Divi-Quellcode verifiziert; die Bereinigung gilt unabhängig davon (Verteidigung in der Tiefe, R2). 4. Post-Meta `_et_pb_use_divi_5=on`, `_et_pb_use_builder=on` setzen. 5. `ET_Core_PageResource::remove_static_resources($post_id, 'all', true)`; bei globalen Library-Elementen und Theme-Builder-Änderungen `('all', 'all', true)` sowie `et_theme_builder_clear_wp_cache('all')`. 6. Protokolleintrag, `ToolResult` zurückgeben. @@ -242,18 +245,18 @@ Kurzform setzt Admin-Labels automatisch (` – `), sofern nich | Ability | Art | Beschreibung | |---|---|---| -| `lit/divi-outline` | lesen | Baum mit IDs, Labels, Textauszügen, `rev` | +| `lit/divi-outline` | lesen | Baum mit IDs, Labels, Textauszügen, `rev` und `tree_rev` | | `lit/divi-node-get` | lesen | vollständige Attribute eines Knotens (Vollform) | | `lit/divi-catalog` | lesen | Module, Kurzform-Felder, wichtige Attributpfade; Detail pro Modul | | `lit/divi-design-system` | lesen | Farben, Variablen, Presets, Theme-Schriften/-Größen | -| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) | -| `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt) | +| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3, mit dem Theme Builder (D6) | +| `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt); Inhaltsbereich, im Prozess über `the_content` gerendert (ohne Header/Footer/Theme Builder); Teil der WordPress-Integration, funktioniert auch ohne Divi (D5) | | `lit/divi-insert` | schreiben | Knoten (Kurz-/Vollform, auch ganze Abschnitte) an Position `parent`/`before`/`after` einfügen | | `lit/divi-update` | schreiben | Werte setzen/entfernen (Kurz- oder Vollform) | | `lit/divi-move` | schreiben | Knoten verschieben (auch zwischen Zeilen/Abschnitten) | | `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen | | `lit/divi-duplicate-node` | schreiben | Knoten duplizieren | -| `lit/divi-replace-text` | schreiben | Text seitenübergreifend ersetzen, `dry_run` (Standard `true`) | +| `lit/divi-replace-text` | schreiben | Text seitenübergreifend (je Inhaltstyp) ersetzen, `dry_run` (Standard `true`) liefert Treffer je Seite mit `tree_rev`; ausgeführt wird nur auf diesen `targets` mit `confirm: true`, jede Seite mit eigener Revision (D10) | | `lit/library-list`, `lit/library-get` | lesen | Library-Einträge nach Typ (`layout_type`) und Scope | | `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global) | | `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz einfügen | @@ -361,7 +364,7 @@ Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): Claude liest das CD i "summary": "Button in 'Hero' aktualisiert", "changed": [{ "id": "s0.r0.c0.m2", "type": "divi/button", "action": "updated", "label": "Hero – Button" }], "outline": "…betroffener Abschnitt…", - "rev": "a1b2c3", + "rev": "a1b2c3", "tree_rev": "d4e5f6", "warnings": ["Feste Farbe #ff0000 – Variable 'Primär' ist ähnlich"], "revision_id": 812, "snapshot_id": null, "log_id": 4711, "preview_url": "…", "edit_url": "…&et_fb=1" } @@ -396,7 +399,7 @@ Rückgabe als `structuredContent` plus kurze Text-Zusammenfassung. Listen immer Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zeilen: - aktive Integrationen und Post-Typen, -- Kernregeln: vor dem Schreiben `lit/divi-outline` und `lit/divi-design-system` lesen; Variablen/Presets vor festen Werten; native Module vor Code-Modul; Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` mitgeben, +- Kernregeln: vor dem Schreiben `lit/divi-outline` und `lit/divi-design-system` lesen; Variablen/Presets vor festen Werten; native Module vor Code-Modul; Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben, - Verweis auf `lit/guide`. ### 9.2 `lit/guide(topic)` @@ -459,7 +462,7 @@ Referenz: https://modelcontextprotocol.io/specification |---|---|---| | **0 – Anbindung testen** | Plugin-Gerüst, Composer, mcp-oauth + Adapter, Rollen-Gate, App-Password-Scope-Filter, eigener Server mit direkten Tools, `lit/guide` (overview), ein Test-Tool; claude.ai-Connector über `ddev share`/Staging; MCP Inspector; Test: The Events Calendar-Eintrag generisch anlegen | Entscheidung: mcp-oauth bleibt oder Wechsel zu mcp-connect; Events-Erweiterungspunkt nötig ja/nein | | **1 – WordPress-Basis** | Schema-Erkennung inkl. Pods, Content-Tools, Status/Kopie/Austausch, Revisionen, Medien, Menüs, WP-Einstellungen, `ToolResult`, Protokoll, Einstellungsseite | Seiten/Beiträge/Pods-Einträge pflegen, Medien verwalten, Websites mit Struktur und Menü aufsetzen | -| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides | Divi-Seiten mit Claude bauen und pflegen | +| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit Claude bauen und pflegen | | **3 – Library & Theme Builder** | Library-Tools, Node-Copy, Make-Global, Find-Similar, Theme-Builder-Tools, Dynamic Content | CPT-Vorlagen bauen, globale Module einführen | | **4 – Designsystem** | Farben, Variablen, Presets, Theme-Optionen inkl. SEO, Snapshots, Prompts | Corporate Design in einem Rutsch übernehmen | | **U – Auslieferung & Updates** (nach Etappe 1) | **OFFEN – bewusst noch nicht spezifiziert.** Bekannt: Build mit `composer install --no-dev` (~3,3 MB vendor), Updates nur anzeigen (manueller Klick), `plugin-update-checker`; Hosting (Gitea / GitHub / eigene Subdomain mit `update.json`) und Signaturprüfung werden später entschieden | – | @@ -491,5 +494,5 @@ Kann-Kriterien nach Etappe 4 oder bei freier Kapazität. - Etappe 0: Funktioniert der Workaround für direkte Tools mit mcp-oauth stabil? Erfüllt mcp-oauth `iss`/`resource`-Anforderungen? - Etappe 0: Erscheinen generisch angelegte The Events Calendar-Einträge korrekt? -- Etappe 2: Exakte Kurzform-Felder je Modul (aus Modul-Metadaten abgeleitet, im Guide dokumentiert). +- Etappe 2: erledigt – Kurzform-Felder aus Divis Conversion-Outlines abgeleitet (`ShortformMap`), gegen den Live-Katalog geprüft (`tests/Integration/divi-shortform-check.php`), dokumentiert im Guide `divi-shortform`. - Etappe 4: Format der Presets/Variablen an echten Daten verifizieren. From c9b31a61da18f79dcbb9deb134c3529a9d1f51c8 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 11:24:27 +0200 Subject: [PATCH 090/185] docs: fix review round 1 - tree_rev in save pipeline, single-source D7 in Sec 10/11.3/14 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index 49af026..0609854 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -234,7 +234,7 @@ Kurzform setzt Admin-Labels automatisch (` – `), sofern nich ### 5.6 Speicher-Pipeline -1. Rechte prüfen (`edit_post`), `rev` prüfen. +1. Rechte prüfen (`edit_post`), `tree_rev` prüfen. 2. Änderung auf dem BlockTree ausführen, validieren (alles oder nichts). 3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, vorher Wiederherstellungspunkt (Revision). Für Benutzer ohne `unfiltered_html` wird kses für genau diesen Aufruf abgeschaltet: `filter_block_content()` würde sonst jeden unveränderten Block neu serialisieren (Byte-Identität, globale Module; in DDEV nachgewiesen). Das ist nur vertretbar, weil keine client-gelieferte Zeichenkette ungeprüft in `post_content` gelangt (D7): Klartext-Werte werden je Feldtyp bereinigt (Text: `sanitize_text_field`; Rich-Text: `wp_kses_post`, abhängig von `unfiltered_html`; URLs: `esc_url_raw`, geroutet über die Attribut-Metadaten des Katalogs, ersatzweise über den Schlüsselnamen). Werte mit `$variable(...)$` – ob aus Platzhaltern erzeugt oder unverändert aus `lit/divi-node-get` zurückgegeben – werden geparst, streng geprüft (zulässiger Typ, existierende gcid-/gvid-/Dynamic-Content-Quellennamen, whitelisted Settings-Schlüssel, String-Settings bereinigt) und vom Plugin neu serialisiert; alles andere wird mit `validation_failed` abgelehnt. Ob Divi das Dynamic-Content-`before`/`after` bei der Ausgabe selbst escaped, wird in Etappe 2b im Divi-Quellcode verifiziert; die Bereinigung gilt unabhängig davon (Verteidigung in der Tiefe, R2). 4. Post-Meta `_et_pb_use_divi_5=on`, `_et_pb_use_builder=on` setzen. @@ -419,7 +419,7 @@ Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-plac - Whitelists für WP-Einstellungen und Divi-Theme-Optionen. - Uploads: `wp_safe_remote_get`, MIME-Whitelist, Größenlimits. - Destruktive Aktionen: `confirm: true` erforderlich; kein endgültiges Löschen von Posts. -- Eingaben werden gegen JSON Schema und fachlich validiert; Texte in Divi-Attributen mit `wp_kses_post` (Text-Module) bzw. `sanitize_text_field` (einfache Felder). +- Eingaben werden gegen JSON Schema und fachlich validiert; Bereinigung von Divi-Schreibwerten siehe §5.6/D7 (eine Quelle der Wahrheit): je Feldtyp (`sanitize_text_field`/`wp_kses_post`/`esc_url_raw`, URLs geroutet über Katalog-Metadaten bzw. Schlüsselname), `$variable(...)$`-Werte geparst/geprüft/neu serialisiert, sonst `validation_failed`. - HTTPS erforderlich (Hinweis im Admin, wenn nicht aktiv). ## 11. MCP-Konformität @@ -444,7 +444,7 @@ Referenz: https://modelcontextprotocol.io/specification - `inputSchema` für jedes Tool, `outputSchema` wo strukturiert (JSON Schema 2020-12); Ergebnisse als `structuredContent` + Text. - Fachliche Fehler als `isError: true`, Protokollfehler als JSON-RPC-Fehler. - Annotations korrekt: `readOnlyHint` (Lese-Tools), `destructiveHint` (Löschen, Ersetzen, Austausch), `idempotentHint`. -- Zustandslos: kein serverseitiger Sitzungszustand; `rev` als explizites, servererzeugtes Handle in Tool-Argumenten. +- Zustandslos: kein serverseitiger Sitzungszustand; `rev`/`tree_rev` als explizite, servererzeugte Handles in Tool-Argumenten. - Deterministische Reihenfolge der Tool-Liste. - Cursor-Paginierung für Listen. - Tool-Namen spezifikationskonform und eindeutig (`lit-…`). @@ -487,7 +487,7 @@ Kann-Kriterien nach Etappe 4 oder bei freier Kapazität. | Divi ändert Speicherformat | Divi-eigene Metadaten/Funktionen nutzen, Fixtures, Tests je Divi-Update, `builderVersion` setzen | | `.well-known`/POST auf `/wp-json/` durch Hoster/WAF blockiert | Site-Health-Test, Fehlersuche-Doku | | Design-Qualität neuer Abschnitte ohne Vorlagen | Library-first-Arbeitsweise, Designsystem-Platzhalter, Guides, Warnungen | -| Parallele Bearbeitung im Builder | `rev`-Sperre | +| Parallele Bearbeitung im Builder | `tree_rev`-Sperre | | Datenformate Designsystem nur aus Code abgeleitet (lokal keine Daten) | vor Etappe 4 echte Daten im Builder anlegen und als Fixtures sichern | ## 15. Offene Punkte (in Etappen zu klären) From 8222240ed698d9b13310f5d479959e4b16485a5d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 11:26:14 +0200 Subject: [PATCH 091/185] =?UTF-8?q?feat:=20Divi=20BlockTree=20=E2=80=93=20?= =?UTF-8?q?byte-exact=20parser/serializer=20with=20round-trip=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/BlockTree/Block.php | 137 +++++++++++++++++ src/Integrations/Divi/BlockTree/Container.php | 93 ++++++++++++ src/Integrations/Divi/BlockTree/Document.php | 12 ++ src/Integrations/Divi/BlockTree/Json.php | 61 ++++++++ .../Divi/BlockTree/MalformedMarkup.php | 15 ++ src/Integrations/Divi/BlockTree/Parser.php | 89 +++++++++++ .../Divi/BlockTree/Serializer.php | 73 +++++++++ tests/Fixtures/divi/converted-features.html | 1 + tests/Fixtures/divi/converted-landing.html | 1 + tests/Fixtures/divi/divi4-shortcodes.html | 1 + tests/Fixtures/divi/source/features.shortcode | 1 + tests/Fixtures/divi/source/landing.shortcode | 1 + tests/Fixtures/divi/synthetic-basic.html | 1 + tests/Fixtures/divi/synthetic-mixed.html | 12 ++ tests/Integration/divi-convert-fixture.php | 35 +++++ tests/Integration/divi-export-page.php | 45 ++++++ .../Divi/BlockTree/ParserTest.php | 138 ++++++++++++++++++ .../Divi/BlockTree/SerializerTest.php | 103 +++++++++++++ 18 files changed, 819 insertions(+) create mode 100644 src/Integrations/Divi/BlockTree/Block.php create mode 100644 src/Integrations/Divi/BlockTree/Container.php create mode 100644 src/Integrations/Divi/BlockTree/Document.php create mode 100644 src/Integrations/Divi/BlockTree/Json.php create mode 100644 src/Integrations/Divi/BlockTree/MalformedMarkup.php create mode 100644 src/Integrations/Divi/BlockTree/Parser.php create mode 100644 src/Integrations/Divi/BlockTree/Serializer.php create mode 100644 tests/Fixtures/divi/converted-features.html create mode 100644 tests/Fixtures/divi/converted-landing.html create mode 100644 tests/Fixtures/divi/divi4-shortcodes.html create mode 100644 tests/Fixtures/divi/source/features.shortcode create mode 100644 tests/Fixtures/divi/source/landing.shortcode create mode 100644 tests/Fixtures/divi/synthetic-basic.html create mode 100644 tests/Fixtures/divi/synthetic-mixed.html create mode 100644 tests/Integration/divi-convert-fixture.php create mode 100644 tests/Integration/divi-export-page.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/ParserTest.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php diff --git a/src/Integrations/Divi/BlockTree/Block.php b/src/Integrations/Divi/BlockTree/Block.php new file mode 100644 index 0000000..532f8ae --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Block.php @@ -0,0 +1,137 @@ + $attrs + */ + private function __construct( + private string $name, + private array $attrs, + private ?string $rawOpen, + private ?string $rawClose, + private bool $void, + private bool $opaque + ) { + } + + /** @param array $attrs */ + public static function parsed(string $name, array $attrs, string $rawOpen, bool $void, bool $opaque): self + { + return new self($name, $attrs, $rawOpen, null, $void, $opaque); + } + + /** + * A new block (opener and closer are generated; written as a pair like Divi does). + * + * @param array $attrs + * @param list $children + */ + public static function create(string $name, array $attrs = [], array $children = []): self + { + $block = new self($name, $attrs, null, null, false, false); + foreach ($children as $child) { + $block->parts[] = $child; + } + + return $block; + } + + /** Used by the Parser for the closing delimiter. */ + public function closeWith(string $rawClose): void + { + $this->rawClose = $rawClose; + } + + /** Full block name, e.g. "divi/section"; blocks without namespace are "core/…". */ + public function name(): string + { + return $this->name; + } + + /** Name without the "divi/" namespace, e.g. "button"; other namespaces stay. */ + public function shortName(): string + { + return str_starts_with($this->name, 'divi/') ? substr($this->name, 5) : $this->name; + } + + /** @return array */ + public function attrs(): array + { + return $this->attrs; + } + + /** @param array $attrs */ + public function setAttrs(array $attrs): void + { + $this->attrs = $attrs; + $this->rawOpen = null; + } + + public function rawOpen(): ?string + { + return $this->rawOpen; + } + + public function rawClose(): ?string + { + return $this->rawClose; + } + + public function isVoid(): bool + { + return $this->void; + } + + public function isOpaque(): bool + { + return $this->opaque; + } + + public function isDivi(): bool + { + return str_starts_with($this->name, 'divi/'); + } + + /** A global module reference or global layout (edited only in Etappe 3). */ + public function isGlobal(): bool + { + return $this->name === 'divi/global-layout' || array_key_exists('globalModule', $this->attrs); + } + + /** Deep copy – unchanged copies serialize to exactly the same bytes. */ + public function copy(): self + { + return clone $this; + } + + public function __clone() + { + foreach ($this->parts as $position => $part) { + if ($part instanceof Block) { + $this->parts[$position] = clone $part; + } + } + } + + protected function touched(): void + { + if ($this->void) { + $this->void = false; + $this->rawOpen = null; + } + } +} diff --git a/src/Integrations/Divi/BlockTree/Container.php b/src/Integrations/Divi/BlockTree/Container.php new file mode 100644 index 0000000..08cd5f1 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Container.php @@ -0,0 +1,93 @@ + */ + protected array $parts = []; + + /** @return list */ + public function parts(): array + { + return $this->parts; + } + + /** @return list */ + public function children(): array + { + return array_values(array_filter($this->parts, static fn (string|Block $part): bool => $part instanceof Block)); + } + + /** Used by the Parser: adds a part without marking the container as changed. */ + public function append(string|Block $part): void + { + $this->parts[] = $part; + } + + /** Child position of $block (0-based among the child blocks), null when it is no child. */ + public function indexOf(Block $block): ?int + { + $index = array_search($block, $this->children(), true); + + return $index === false ? null : (int) $index; + } + + /** + * Inserts $block so it becomes child number $index (0 = first; count or more = after the + * last child). Text between existing children stays where it is. + */ + public function insertChild(int $index, Block $block): void + { + $children = $this->children(); + $index = max(0, min($index, count($children))); + if ($index < count($children)) { + $position = $this->partPosition($children[$index]); + } elseif ($children === []) { + $position = count($this->parts); + } else { + $position = $this->partPosition($children[count($children) - 1]) + 1; + } + + array_splice($this->parts, $position, 0, [$block]); + $this->touched(); + } + + /** Removes the child block; the text around it stays. Returns its former child index. */ + public function removeChild(Block $block): int + { + $index = $this->indexOf($block); + if ($index === null) { + throw new InvalidArgumentException('Block is not a child of this container.'); + } + + array_splice($this->parts, $this->partPosition($block), 1); + $this->touched(); + + return $index; + } + + /** Called after the list of children changed. */ + protected function touched(): void + { + } + + private function partPosition(Block $block): int + { + $position = array_search($block, $this->parts, true); + if ($position === false) { + throw new InvalidArgumentException('Block is not a child of this container.'); + } + + return (int) $position; + } +} diff --git a/src/Integrations/Divi/BlockTree/Document.php b/src/Integrations/Divi/BlockTree/Document.php new file mode 100644 index 0000000..49849ac --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Document.php @@ -0,0 +1,12 @@ +|null Null when $json is not a JSON object. */ + public static function decodeObject(string $json): ?array + { + try { + $value = json_decode($json, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + if (! $value instanceof stdClass) { + return null; + } + + $converted = self::fromJson($value); + + return is_array($converted) ? $converted : []; + } + + /** Converts json_decode(…, false) output: objects become arrays unless that would lose their object-ness. */ + public static function fromJson(mixed $value): mixed + { + if ($value instanceof stdClass) { + $array = []; + foreach (get_object_vars($value) as $key => $item) { + $array[$key] = self::fromJson($item); + } + + return $array === [] || array_is_list($array) ? (object) $array : $array; + } + + if (is_array($value)) { + return array_map([self::class, 'fromJson'], $value); + } + + return $value; + } + + /** @throws JsonException */ + public static function encode(mixed $value): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Integrations/Divi/BlockTree/MalformedMarkup.php b/src/Integrations/Divi/BlockTree/MalformedMarkup.php new file mode 100644 index 0000000..f15cc1b --- /dev/null +++ b/src/Integrations/Divi/BlockTree/MalformedMarkup.php @@ -0,0 +1,15 @@ +\/)?wp:(?P[a-z][a-z0-9_-]*\/)?(?P[a-z][a-z0-9_-]*)\s+' + . '(?P{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P\/)?-->/s'; + + /** @throws MalformedMarkup */ + public function parse(string $content): Document + { + $document = new Document(); + /** @var list $stack */ + $stack = []; + $offset = 0; + $length = strlen($content); + + while ($offset < $length) { + $found = preg_match(self::TOKEN, $content, $match, PREG_OFFSET_CAPTURE, $offset); + if ($found === false) { + throw new MalformedMarkup('Block delimiters could not be scanned (PCRE error).'); + } + if ($found === 0) { + break; + } + + [$token, $start] = $match[0]; + $container = $stack === [] ? $document : $stack[count($stack) - 1]; + if ($start > $offset) { + $container->append(substr($content, $offset, $start - $offset)); + } + + $name = self::name($match); + if (self::captured($match, 'closer')) { + $open = array_pop($stack); + if ($open === null || $open->name() !== $name) { + throw new MalformedMarkup(sprintf('Closer of %s at byte %d has no opener.', $name, $start)); + } + $open->closeWith($token); + } else { + $json = self::captured($match, 'attrs') ? $match['attrs'][0] : ''; + $attrs = $json === '' ? [] : Json::decodeObject($json); + $void = self::captured($match, 'void'); + $opaque = $attrs === null || $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs); + $block = Block::parsed($name, $attrs ?? [], $token, $void, $opaque); + $container->append($block); + if (! $void) { + $stack[] = $block; + } + } + + $offset = $start + strlen($token); + } + + if ($offset < $length) { + ($stack === [] ? $document : $stack[count($stack) - 1])->append(substr($content, $offset)); + } + + if ($stack !== []) { + throw new MalformedMarkup(sprintf('Block %s is never closed.', $stack[count($stack) - 1]->name())); + } + + return $document; + } + + /** @param array $match */ + private static function name(array $match): string + { + $namespace = self::captured($match, 'namespace') ? $match['namespace'][0] : 'core/'; + + return $namespace . $match['name'][0]; + } + + /** @param array $match */ + private static function captured(array $match, string $group): bool + { + return isset($match[$group]) && $match[$group][1] !== -1 && $match[$group][0] !== ''; + } +} diff --git a/src/Integrations/Divi/BlockTree/Serializer.php b/src/Integrations/Divi/BlockTree/Serializer.php new file mode 100644 index 0000000..b298530 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Serializer.php @@ -0,0 +1,73 @@ + '\\u005c', + '--' => '\\u002d\\u002d', + '<' => '\\u003c', + '>' => '\\u003e', + '&' => '\\u0026', + '\\"' => '\\u0022', + ]; + + public static function document(Document $document): string + { + return self::parts($document->parts()); + } + + public static function block(Block $block): string + { + $open = $block->rawOpen() ?? self::opener($block); + if ($block->isVoid()) { + return $open; + } + + return $open . self::parts($block->parts()) . ($block->rawClose() ?? self::closer($block)); + } + + /** @param array $attrs */ + public static function attributes(array $attrs): string + { + return strtr(Json::encode($attrs), self::ESCAPES); + } + + private static function opener(Block $block): string + { + $attrs = $block->attrs() === [] ? '' : self::attributes($block->attrs()) . ' '; + + return '' : '-->'); + } + + private static function closer(Block $block): string + { + return ''; + } + + private static function serializedName(string $name): string + { + return str_starts_with($name, 'core/') ? substr($name, 5) : $name; + } + + /** @param list $parts */ + private static function parts(array $parts): string + { + $out = ''; + foreach ($parts as $part) { + $out .= $part instanceof Block ? self::block($part) : $part; + } + + return $out; + } +} diff --git a/tests/Fixtures/divi/converted-features.html b/tests/Fixtures/divi/converted-features.html new file mode 100644 index 0000000..ea4836a --- /dev/null +++ b/tests/Fixtures/divi/converted-features.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/converted-landing.html b/tests/Fixtures/divi/converted-landing.html new file mode 100644 index 0000000..3bac6f6 --- /dev/null +++ b/tests/Fixtures/divi/converted-landing.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/divi4-shortcodes.html b/tests/Fixtures/divi/divi4-shortcodes.html new file mode 100644 index 0000000..5c80067 --- /dev/null +++ b/tests/Fixtures/divi/divi4-shortcodes.html @@ -0,0 +1 @@ +[et_pb_section fb_built="1" _builder_version="4.27.4"][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4"][et_pb_text]

Divi 4

[/et_pb_text][/et_pb_column][/et_pb_row][/et_pb_section] \ No newline at end of file diff --git a/tests/Fixtures/divi/source/features.shortcode b/tests/Fixtures/divi/source/features.shortcode new file mode 100644 index 0000000..b3d6379 --- /dev/null +++ b/tests/Fixtures/divi/source/features.shortcode @@ -0,0 +1 @@ +[et_pb_section fb_built="1" admin_label="Details" _builder_version="4.27.4"][et_pb_row column_structure="2_3,1_3" _builder_version="4.27.4"][et_pb_column type="2_3" _builder_version="4.27.4"][et_pb_tabs _builder_version="4.27.4"][et_pb_tab title="Ablauf" _builder_version="4.27.4"]

Erst reden, dann bauen.

[/et_pb_tab][et_pb_tab title="Preise" _builder_version="4.27.4"]

Ab 99 €.

[/et_pb_tab][/et_pb_tabs][et_pb_toggle title="Garantie" open="off" _builder_version="4.27.4"]

5 Jahre.

[/et_pb_toggle][et_pb_slider _builder_version="4.27.4"][et_pb_slide heading="Neu" button_text="Mehr" button_link="/neu" _builder_version="4.27.4"]

Unsere neue Halle.

[/et_pb_slide][/et_pb_slider][et_pb_gallery gallery_ids="12,33" _builder_version="4.27.4"][/et_pb_gallery][et_pb_code _builder_version="4.27.4"]
Code
[/et_pb_code][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_team_member name="Max Meister" position="Inhaber" _builder_version="4.27.4"]

Meister seit 2001.

[/et_pb_team_member][et_pb_number_counter title="Projekte" number="480" percent_sign="off" _builder_version="4.27.4"][/et_pb_number_counter][et_pb_divider show_divider="on" _builder_version="4.27.4"][/et_pb_divider][et_pb_social_media_follow _builder_version="4.27.4"][et_pb_social_media_follow_network social_network="facebook" url="https://facebook.com/example" _builder_version="4.27.4"]facebook[/et_pb_social_media_follow_network][/et_pb_social_media_follow][et_pb_map address="Domkloster 4, 50667 Köln" address_lat="50.9413" address_lng="6.9583" _builder_version="4.27.4"][et_pb_map_pin title="Büro" pin_address="Domkloster 4, 50667 Köln" pin_address_lat="50.9413" pin_address_lng="6.9583" _builder_version="4.27.4"][/et_pb_map_pin][/et_pb_map][et_pb_video src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" _builder_version="4.27.4"][/et_pb_video][/et_pb_column][/et_pb_row][/et_pb_section] \ No newline at end of file diff --git a/tests/Fixtures/divi/source/landing.shortcode b/tests/Fixtures/divi/source/landing.shortcode new file mode 100644 index 0000000..92b999d --- /dev/null +++ b/tests/Fixtures/divi/source/landing.shortcode @@ -0,0 +1 @@ +[et_pb_section fb_built="1" admin_label="Hero" _builder_version="4.27.4" background_color="#0b3d59"][et_pb_row column_structure="1_2,1_2" _builder_version="4.27.4"][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_heading title="Handwerk mit Herz" title_level="h1" _builder_version="4.27.4"][/et_pb_heading][et_pb_text _builder_version="4.27.4"]

Seit 1987 in Köln – "fair" & pünktlich.

[/et_pb_text][et_pb_button button_text="Termin buchen" button_url="/kontakt" _builder_version="4.27.4"][/et_pb_button][/et_pb_column][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_image src="https://example.test/wp-content/uploads/2026/09/werkstatt.jpg" alt="Werkstatt" title_text="Werkstatt" _builder_version="4.27.4"][/et_pb_image][/et_pb_column][/et_pb_row][/et_pb_section][et_pb_section fb_built="1" admin_label="Leistungen" _builder_version="4.27.4"][et_pb_row column_structure="1_3,1_3,1_3" _builder_version="4.27.4"][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Beratung" use_icon="on" font_icon="||divi||400" _builder_version="4.27.4"]

Persönlich vor Ort.

[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Planung" image="https://example.test/wp-content/uploads/2026/09/plan.png" alt="Plan" _builder_version="4.27.4"]

Mit 3D-Ansicht.

[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Umsetzung" _builder_version="4.27.4"]

Termingerecht.

[/et_pb_blurb][/et_pb_column][/et_pb_row][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4" _builder_version="4.27.4"][et_pb_cta title="Bereit?" button_text="Anfragen" button_url="/kontakt" _builder_version="4.27.4"]

Wir melden uns binnen 24 Stunden.

[/et_pb_cta][et_pb_testimonial author="Anna B." job_title="Bauherrin" company_name="Privat" _builder_version="4.27.4"]

Top Arbeit!

[/et_pb_testimonial][et_pb_accordion _builder_version="4.27.4"][et_pb_accordion_item title="Was kostet das?" open="on" _builder_version="4.27.4"]

Kommt drauf an.

[/et_pb_accordion_item][et_pb_accordion_item title="Wie schnell?" open="off" _builder_version="4.27.4"]

2 Wochen.

[/et_pb_accordion_item][/et_pb_accordion][et_pb_contact_form email="info@example.test" title="Kontakt" _builder_version="4.27.4"][et_pb_contact_field field_id="Name" field_title="Name" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Email" field_title="E-Mail" field_type="email" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Message" field_title="Nachricht" field_type="text" fullwidth_field="on" _builder_version="4.27.4"][/et_pb_contact_field][/et_pb_contact_form][/et_pb_column][/et_pb_row][/et_pb_section] \ No newline at end of file diff --git a/tests/Fixtures/divi/synthetic-basic.html b/tests/Fixtures/divi/synthetic-basic.html new file mode 100644 index 0000000..623ea83 --- /dev/null +++ b/tests/Fixtures/divi/synthetic-basic.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/divi/synthetic-mixed.html b/tests/Fixtures/divi/synthetic-mixed.html new file mode 100644 index 0000000..18341e1 --- /dev/null +++ b/tests/Fixtures/divi/synthetic-mixed.html @@ -0,0 +1,12 @@ + +

Einleitung aus dem Block-Editor mit Link.

+ + + + + + + + + +Freier Text am Ende [et_pb_legacy] diff --git a/tests/Integration/divi-convert-fixture.php b/tests/Integration/divi-convert-fixture.php new file mode 100644 index 0000000..02d8565 --- /dev/null +++ b/tests/Integration/divi-convert-fixture.php @@ -0,0 +1,35 @@ + + * Paths are relative to the plugin's tests/Fixtures/divi directory. + */ + +use ET\Builder\Packages\Conversion\Conversion; + +$args = $args ?? []; +if (count($args) !== 2) { + WP_CLI::error('Usage: divi-convert-fixture.php '); +} +if (! class_exists(Conversion::class)) { + WP_CLI::error('Divi 5 is not active.'); +} + +$dir = dirname(__DIR__) . '/Fixtures/divi/'; +$source = file_get_contents($dir . $args[0]); +if ($source === false) { + WP_CLI::error('Cannot read ' . $dir . $args[0]); +} + +$converted = Conversion::maybeConvertContent(trim($source)); +if (! str_contains($converted, '' . $converted . ''; +if (file_put_contents($dir . $args[1], $markup) === false) { + WP_CLI::error('Cannot write ' . $dir . $args[1]); +} +WP_CLI::success(sprintf('%s: %d bytes', $args[1], strlen($markup))); diff --git a/tests/Integration/divi-export-page.php b/tests/Integration/divi-export-page.php new file mode 100644 index 0000000..acb8e98 --- /dev/null +++ b/tests/Integration/divi-export-page.php @@ -0,0 +1,45 @@ +.html. Every file there is part of the parse → serialize + * round-trip test (tests/Unit/Integrations/Divi/BlockTree/ParserTest.php). + * Usage (DDEV, WordPress root): + * wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-page.php + * : lowercase letters, digits and dashes, e.g. "startseite-mit-global". + * Only use pages without personal data – fixtures are committed. + * No `declare(strict_types=1);` – wp eval-file runs the file through eval(). + */ + +defined('WP_CLI') && WP_CLI || exit; + +$args = $args ?? []; +$postId = (int) ($args[0] ?? 0); +$name = (string) ($args[1] ?? ''); +if ($postId <= 0 || preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $name) !== 1) { + WP_CLI::error('Usage: divi-export-page.php '); +} + +$post = get_post($postId); +if (! $post instanceof WP_Post) { + WP_CLI::error('No post ' . $postId); +} + +$content = (string) $post->post_content; +if (! str_contains($content, '\n

x

\n\n\ntext"); + + self::assertSame('core/paragraph', $document->children()[0]->name()); + self::assertSame(["\n

x

\n"], $document->children()[0]->parts()); + self::assertSame("\n\ntext", $document->parts()[1]); + } + + public function testMarksGlobalAndUndecodableBlocksOpaque(): void + { + $document = (new Parser())->parse($this->fixture('synthetic-mixed.html')); + $placeholder = $document->children()[1]; + $second = $placeholder->children()[1]; + [$globalLayout, $globalRow, $globalWithChild] = $second->children(); + + self::assertTrue($globalLayout->isOpaque()); + self::assertTrue($globalLayout->isGlobal()); + self::assertTrue($globalRow->isOpaque()); + self::assertTrue($globalRow->isGlobal()); + self::assertTrue($globalWithChild->isOpaque()); + self::assertFalse($second->isOpaque()); + + $broken = (new Parser())->parse(''); + self::assertTrue($broken->children()[0]->isOpaque()); + self::assertSame([], $broken->children()[0]->attrs()); + } + + public function testKeepsEmptyAndListLikeObjectsAsObjects(): void + { + $document = (new Parser())->parse($this->fixture('synthetic-mixed.html')); + $attrs = $document->children()[1]->children()[1]->attrs(); + + self::assertInstanceOf(stdClass::class, $attrs['module']['decoration']['spacing']['desktop']['value']); + self::assertSame([], $attrs['module']['decoration']['background']['desktop']['value']); + self::assertInstanceOf(stdClass::class, $attrs['localAttrs']); + } + + public function testDivi4ShortcodesAreOneTextPart(): void + { + $content = $this->fixture('divi4-shortcodes.html'); + $document = (new Parser())->parse($content); + + self::assertSame([], $document->children()); + self::assertSame([$content], $document->parts()); + } + + /** @return array */ + public static function malformed(): array + { + return [ + 'closer without opener' => [''], + 'unclosed block' => [''], + 'crossed blocks' => [''], + ]; + } + + /** @dataProvider malformed */ + public function testRefusesUnbalancedMarkup(string $content): void + { + $this->expectException(MalformedMarkup::class); + + (new Parser())->parse($content); + } + + private function fixture(string $name): string + { + return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php b/tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php new file mode 100644 index 0000000..3cee656 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php @@ -0,0 +1,103 @@ + $variable]) + ); + self::assertSame( + '{"t":"\u003cp\u003eA \u0026 B\u003c/p\u003e \u002d\u002d \u005c/ ä","e":{},"l":[]}', + Serializer::attributes(['t' => '

A & B

-- \\/ ä', 'e' => new stdClass(), 'l' => []]) + ); + } + + public function testRegeneratesOnlyTheChangedOpener(): void + { + $content = ''; + $document = (new Parser())->parse($content); + $text = $document->children()[0]->children()[0]; + + $text->setAttrs(['x' => 'z']); + + self::assertSame( + '', + Serializer::document($document) + ); + } + + public function testNewBlocksAreWrittenAsPairs(): void + { + $block = Block::create('divi/row', ['builderVersion' => '5.2.1'], [Block::create('divi/column')]); + + self::assertSame( + '' + . '', + Serializer::block($block) + ); + self::assertSame('', Serializer::block(Block::create('core/paragraph'))); + } + + public function testVoidBlockGettingAChildBecomesAPair(): void + { + $document = (new Parser())->parse(''); + $column = $document->children()[0]; + + $column->insertChild(0, Block::create('divi/text')); + + self::assertSame( + '', + Serializer::document($document) + ); + } + + public function testInsertAndRemoveKeepSurroundingText(): void + { + $document = (new Parser())->parse("A\nBC"); + [$text, $code] = $document->children(); + $new = Block::create('divi/button'); + + $document->insertChild(1, $new); + self::assertSame( + "A\nBC", + Serializer::document($document) + ); + self::assertSame(2, $document->removeChild($code)); + self::assertSame(0, $document->indexOf($text)); + $document->insertChild(99, $code); + self::assertSame(2, $document->indexOf($code)); + + $empty = new Document(); + $empty->append("\n"); + $empty->insertChild(0, Block::create('divi/section')); + self::assertSame("\n", Serializer::document($empty)); + } + + public function testCopyIsDeepAndSerializesIdentically(): void + { + $content = ''; + $row = (new Parser())->parse($content)->children()[0]; + $copy = $row->copy(); + + $copy->children()[0]->setAttrs(['b' => 2]); + + self::assertSame($content, Serializer::block($row)); + self::assertSame('', Serializer::block($copy)); + } +} From 08d70abfc774d87ab5ac31128133aa7a9798fa89 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 11:34:54 +0200 Subject: [PATCH 092/185] feat: Divi path IDs, attribute paths and outline Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/BlockTree/AttrMap.php | 144 ++++++++++++++++++ src/Integrations/Divi/BlockTree/NodeIndex.php | 126 +++++++++++++++ .../Divi/BlockTree/NodeSummary.php | 138 +++++++++++++++++ src/Integrations/Divi/BlockTree/Outline.php | 84 ++++++++++ .../Divi/BlockTree/NodeIndexTest.php | 60 ++++++++ .../Divi/BlockTree/OutlineTest.php | 102 +++++++++++++ 6 files changed, 654 insertions(+) create mode 100644 src/Integrations/Divi/BlockTree/AttrMap.php create mode 100644 src/Integrations/Divi/BlockTree/NodeIndex.php create mode 100644 src/Integrations/Divi/BlockTree/NodeSummary.php create mode 100644 src/Integrations/Divi/BlockTree/Outline.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php diff --git a/src/Integrations/Divi/BlockTree/AttrMap.php b/src/Integrations/Divi/BlockTree/AttrMap.php new file mode 100644 index 0000000..aa784f6 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/AttrMap.php @@ -0,0 +1,144 @@ + $attrs + * @return array Path => breakpoint map (or the raw value of an odd leaf). + */ + public static function flatten(array $attrs): array + { + $flat = []; + foreach ($attrs as $key => $value) { + if (! in_array($key, self::META_KEYS, true)) { + self::collect((string) $key, $value, $flat); + } + } + ksort($flat); + + return $flat; + } + + public static function isBreakpointMap(mixed $value): bool + { + if (! is_array($value) || $value === [] || array_is_list($value)) { + return false; + } + + return array_diff(array_map('strval', array_keys($value)), self::BREAKPOINTS) === []; + } + + /** @param array $attrs */ + public static function get(array $attrs, string $path): mixed + { + $value = $attrs; + foreach (explode('.', $path) as $segment) { + if (! is_array($value) || ! array_key_exists($segment, $value)) { + return null; + } + $value = $value[$segment]; + } + + return $value; + } + + /** + * Desktop value of a path (state "value"), e.g. the text of "title.innerContent". + * + * @param array $attrs + */ + public static function desktop(array $attrs, string $path): mixed + { + $map = self::get($attrs, $path); + + return is_array($map) && is_array($map['desktop'] ?? null) ? ($map['desktop']['value'] ?? null) : null; + } + + /** + * @param array $attrs + * @return array + */ + public static function set(array $attrs, string $path, mixed $value): array + { + $segments = explode('.', $path); + $key = (string) array_shift($segments); + if ($segments === []) { + $attrs[$key] = $value; + + return $attrs; + } + + $child = $attrs[$key] ?? []; + $child = $child instanceof stdClass ? (array) $child : (is_array($child) ? $child : []); + $attrs[$key] = self::set($child, implode('.', $segments), $value); + + return $attrs; + } + + /** + * Removes the value at $path and every parent that becomes empty. + * + * @param array $attrs + * @return array + */ + public static function remove(array $attrs, string $path): array + { + $segments = explode('.', $path); + $key = (string) array_shift($segments); + if (! array_key_exists($key, $attrs)) { + return $attrs; + } + + if ($segments === []) { + unset($attrs[$key]); + + return $attrs; + } + + if (! is_array($attrs[$key])) { + return $attrs; + } + + $child = self::remove($attrs[$key], implode('.', $segments)); + if ($child === []) { + unset($attrs[$key]); + } else { + $attrs[$key] = $child; + } + + return $attrs; + } + + /** @param array $flat */ + private static function collect(string $path, mixed $value, array &$flat): void + { + if (self::isBreakpointMap($value) || ! is_array($value) || $value === [] || array_is_list($value)) { + $flat[$path] = $value; + + return; + } + + foreach ($value as $key => $item) { + self::collect($path . '.' . $key, $item, $flat); + } + } +} diff --git a/src/Integrations/Divi/BlockTree/NodeIndex.php b/src/Integrations/Divi/BlockTree/NodeIndex.php new file mode 100644 index 0000000..f982cad --- /dev/null +++ b/src/Integrations/Divi/BlockTree/NodeIndex.php @@ -0,0 +1,126 @@ + */ + private array $byId = []; + + /** @var array spl_object_id(block) => id */ + private array $ids = []; + + /** @var array spl_object_id(block) => container holding it */ + private array $parents = []; + + /** @var list */ + private array $entries = []; + + private function __construct(private Container $root) + { + } + + public static function build(Document $document): self + { + $root = $document; + foreach ($document->children() as $block) { + if ($block->name() === 'divi/placeholder') { + $root = $block; + break; + } + } + + $index = new self($root); + $counters = []; + $index->walk($document, '', 0, $counters); + + return $index; + } + + public static function letter(Block $block): string + { + if ($block->isGlobal()) { + return 'g'; + } + + return match ($block->name()) { + 'divi/section' => 's', + 'divi/row', 'divi/row-inner' => 'r', + 'divi/column', 'divi/column-inner' => 'c', + default => $block->isDivi() ? 'm' : 'x', + }; + } + + /** Container that receives new top-level sections: the first divi/placeholder, else the document. */ + public function root(): Container + { + return $this->root; + } + + public function find(string $id): ?Block + { + return $this->byId[$id] ?? null; + } + + public function idOf(Block $block): ?string + { + return $this->ids[spl_object_id($block)] ?? null; + } + + public function parentOf(Block $block): ?Container + { + return $this->parents[spl_object_id($block)] ?? null; + } + + /** ID of the parent node, null for top-level nodes (their container is the root or a placeholder). */ + public function parentId(Block $block): ?string + { + $parent = $this->parentOf($block); + + return $parent instanceof Block ? $this->idOf($parent) : null; + } + + /** @return list In document order. */ + public function entries(): array + { + return $this->entries; + } + + /** @param array $counters */ + private function walk(Container $container, string $prefix, int $depth, array &$counters): void + { + foreach ($container->children() as $block) { + if ($prefix === '' && $block->name() === 'divi/placeholder') { + $this->walk($block, '', $depth, $counters); + continue; + } + + $letter = self::letter($block); + $number = $counters[$letter] ?? 0; + $counters[$letter] = $number + 1; + $id = ($prefix === '' ? '' : $prefix . '.') . $letter . $number; + + $this->byId[$id] = $block; + $this->ids[spl_object_id($block)] = $id; + $this->parents[spl_object_id($block)] = $container; + $this->entries[] = ['id' => $id, 'block' => $block, 'depth' => $depth]; + + if ($block->isDivi() && ! $block->isOpaque()) { + $childCounters = []; + $this->walk($block, $id, $depth + 1, $childCounters); + } + } + } +} diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php new file mode 100644 index 0000000..55b167b --- /dev/null +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -0,0 +1,138 @@ +attrs(), 'module.meta.adminLabel'); + + return is_string($label) ? $label : ''; + } + + public static function excerpt(Block $block, int $max = 60): string + { + $paths = array_filter( + array_keys(AttrMap::flatten($block->attrs())), + static fn (string $path): bool => str_ends_with($path, '.innerContent') + ); + usort($paths, static function (string $a, string $b): int { + return [self::rank($a), $a] <=> [self::rank($b), $b]; + }); + + foreach ($paths as $path) { + $text = self::textOf(AttrMap::desktop($block->attrs(), $path)); + if ($text !== '') { + return self::shorten($text, $max); + } + } + + return ''; + } + + /** @return list */ + public static function presetIds(Block $block): array + { + $presets = $block->attrs()['modulePreset'] ?? []; + if (is_string($presets)) { + $presets = [$presets]; + } + + return is_array($presets) + ? array_values(array_filter($presets, static fn (mixed $id): bool => is_string($id) && $id !== '')) + : []; + } + + /** Layout facts: section type, column widths of a row (e.g. "8_24+16_24"). */ + public static function info(Block $block): string + { + if ($block->name() === 'divi/section') { + $type = AttrMap::desktop($block->attrs(), 'module.advanced.type'); + + return is_string($type) && $type !== 'regular' ? $type : ''; + } + + if (in_array($block->name(), ['divi/row', 'divi/row-inner'], true)) { + $structure = AttrMap::desktop($block->attrs(), 'module.advanced.columnStructure'); + $fallback = is_string($structure) ? explode(',', $structure) : []; + $widths = []; + foreach ($block->children() as $position => $column) { + $widths[] = self::columnWidth($column) ?? ($fallback[$position] ?? '24_24'); + } + + return $widths === [] ? '' : 'Spalten ' . implode('+', $widths); + } + + return ''; + } + + /** flexType (Divi 5 flex layout) or legacy column type; null when the column sets neither. */ + public static function columnWidth(Block $column): ?string + { + $sizing = AttrMap::desktop($column->attrs(), 'module.decoration.sizing'); + if (is_array($sizing) && is_string($sizing['flexType'] ?? null)) { + return $sizing['flexType']; + } + + $type = AttrMap::desktop($column->attrs(), 'module.advanced.type'); + + return is_string($type) ? $type : null; + } + + /** Readable text of a value: HTML stripped, dynamic content shown as {name}. */ + public static function textOf(mixed $value): string + { + if (is_array($value)) { + foreach (self::TEXT_KEYS as $key) { + $text = self::textOf($value[$key] ?? null); + if ($text !== '') { + return $text; + } + } + + return ''; + } + + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { + return ''; + } + + $value = (string) $value; + if (preg_match('/^\$variable\((.*)\)\$$/s', $value, $match) === 1) { + $data = json_decode($match[1], true); + $name = is_array($data) && is_string($data['value']['name'] ?? null) ? $data['value']['name'] : 'dynamisch'; + + return '{' . $name . '}'; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- pure PHP (spec §4.4). + $text = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim((string) preg_replace('/\s+/u', ' ', $text)); + } + + private static function rank(string $path): int + { + $index = array_search(explode('.', $path)[0], self::PRIORITY, true); + + return $index === false ? count(self::PRIORITY) : (int) $index; + } + + private static function shorten(string $text, int $max): string + { + return mb_strlen($text) > $max ? rtrim(mb_substr($text, 0, $max - 1)) . '…' : $text; + } +} diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php new file mode 100644 index 0000000..332b293 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -0,0 +1,84 @@ + $presetNames Preset ID => name. */ + public function __construct(private array $presetNames = []) + { + } + + /** + * @return list, children: int, editable: bool, depth: int}> + */ + public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): array + { + $rows = []; + $baseDepth = null; + foreach ($index->entries() as $entry) { + $id = $entry['id']; + if ($rootId !== null && $id !== $rootId && ! str_starts_with($id, $rootId . '.')) { + continue; + } + + $baseDepth ??= $entry['depth']; + $depth = $entry['depth'] - $baseDepth; + if ($maxDepth !== null && $depth > $maxDepth) { + continue; + } + + $rows[] = $this->row($entry['block'], $id, $depth); + } + + return $rows; + } + + /** @param list> $rows Output of rows(). */ + public function text(array $rows): string + { + $lines = []; + foreach ($rows as $row) { + $line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type']; + $line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : ''; + $line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : ''; + $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; + $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; + $line .= $row['editable'] ? '' : ' (nur lesbar)'; + $lines[] = $line; + } + + return implode("\n", $lines); + } + + /** + * @return array{id: string, type: string, label: string, excerpt: string, info: string, + * presets: list, children: int, editable: bool, depth: int} + */ + private function row(Block $block, string $id, int $depth): array + { + $editable = $block->isDivi() && ! $block->isOpaque(); + + return [ + 'id' => $id, + 'type' => $block->isDivi() ? $block->shortName() : $block->name(), + 'label' => NodeSummary::label($block), + 'excerpt' => NodeSummary::excerpt($block), + 'info' => $editable ? NodeSummary::info($block) : '', + 'presets' => array_map( + fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId, + NodeSummary::presetIds($block) + ), + 'children' => $editable ? count($block->children()) : 0, + 'editable' => $editable, + 'depth' => $depth, + ]; + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php b/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php new file mode 100644 index 0000000..4b050bd --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php @@ -0,0 +1,60 @@ +parse($this->fixture('synthetic-basic.html'))); + + self::assertSame( + [ + 's0', 's0.r0', 's0.r0.c0', 's0.r0.c0.m0', 's0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1', 's0.r0.c1.m2', + 's1', 's1.r0', 's1.r0.c0', 's1.r0.c0.m0', 's1.r0.c1', 's1.r0.c1.m0', 's1.r0.c1.m0.m0', + 's1.r0.c1.m0.m1', + ], + array_column($index->entries(), 'id') + ); + self::assertSame('divi/button', $index->find('s0.r0.c1.m2')?->name()); + self::assertSame('s0.r0.c1', $index->parentId($index->find('s0.r0.c1.m2'))); + self::assertNull($index->parentId($index->find('s1'))); + self::assertSame('divi/placeholder', $index->root() instanceof \LIT\MCP\Integrations\Divi\BlockTree\Block + ? $index->root()->name() : ''); + self::assertSame($index->root(), $index->parentOf($index->find('s0'))); + self::assertNull($index->find('s9')); + } + + public function testGlobalAndForeignBlocksAreLeavesWithOwnLetters(): void + { + $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html'))); + + self::assertSame( + ['x0', 's0', 's0.c0', 's0.c0.m0', 's0.c1', 's0.c1.r0', 's0.c1.r0.c0', 's0.c1.r0.c0.m0', 's0.c1.r0.c1', + 's1', 's1.g0', 's1.g1', 's1.g2'], + array_column($index->entries(), 'id') + ); + self::assertSame(1, preg_match(NodeIndex::ID_PATTERN, 's1.g2')); + self::assertSame(0, preg_match(NodeIndex::ID_PATTERN, 's1..g2')); + } + + public function testDocumentWithoutPlaceholderIsItsOwnRoot(): void + { + $document = (new Parser())->parse(''); + $index = NodeIndex::build($document); + + self::assertSame($document, $index->root()); + self::assertSame('s0', $index->entries()[0]['id']); + } + + private function fixture(string $name): string + { + return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php new file mode 100644 index 0000000..b66abc4 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -0,0 +1,102 @@ +parse($this->fixture('synthetic-basic.html'))); + $outline = new Outline(['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' => 'Hero-Titel']); + + $rows = $outline->rows($index); + + self::assertSame( + ['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne', + 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3], + $rows[5] + ); + self::assertSame('Spalten 8_24+16_24', $rows[1]['info']); + self::assertSame('Seit 1987 zuverlässig – jetzt anfragen.', $rows[6]['excerpt']); + self::assertSame( + 's0 section "Hero"' . "\n" + . ' s0.r0 row "Hero – Zeile" [Spalten 8_24+16_24]' . "\n" + . ' s0.r0.c0 column' . "\n" + . ' s0.r0.c0.m0 image "Hero – Bild" – Unser Team vor dem Büro', + implode("\n", array_slice(explode("\n", $outline->text($rows)), 0, 4)) + ); + } + + public function testSubtreeAndDepthLimit(): void + { + $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); + $outline = new Outline(); + + $rows = $outline->rows($index, 's1.r0.c1', 1); + + self::assertSame(['s1.r0.c1', 's1.r0.c1.m0'], array_column($rows, 'id')); + self::assertSame([0, 1], array_column($rows, 'depth')); + self::assertSame(2, $rows[1]['children']); + } + + public function testReadOnlyNodesAreMarked(): void + { + $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html'))); + $text = (new Outline())->text((new Outline())->rows($index)); + + self::assertStringContainsString('x0 core/paragraph (nur lesbar)', $text); + self::assertStringContainsString('s1.g1 row "Globale Zeile" (nur lesbar)', $text); + self::assertStringContainsString('s0 section [specialty]', $text); + self::assertStringContainsString('s0.c1.r0 row-inner [Spalten 1_2+1_2]', $text); + } + + public function testSummaryHelpers(): void + { + self::assertSame('{post_title}', NodeSummary::textOf( + '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$' + )); + self::assertSame('Mehr', NodeSummary::textOf(['linkUrl' => '/x', 'text' => 'Mehr'])); + self::assertSame('', NodeSummary::textOf(new stdClass())); + } + + public function testAttrMapFlattenGetSetRemove(): void + { + $attrs = [ + 'builderVersion' => '5.2.1', + 'title' => ['innerContent' => ['desktop' => ['value' => 'Hallo']], 'decoration' => ['font' => [ + 'font' => ['desktop' => ['value' => ['headingLevel' => 'h2']], 'phone' => ['value' => ['size' => '20px']]], + ]]], + 'module' => ['decoration' => ['spacing' => ['desktop' => ['value' => new stdClass()]]]], + ]; + + self::assertSame( + ['module.decoration.spacing', 'title.decoration.font.font', 'title.innerContent'], + array_keys(AttrMap::flatten($attrs)) + ); + self::assertSame('Hallo', AttrMap::desktop($attrs, 'title.innerContent')); + self::assertTrue(AttrMap::isBreakpointMap(['desktop' => [], 'phone' => []])); + self::assertFalse(AttrMap::isBreakpointMap(['desktop' => [], 'font' => []])); + + $changed = AttrMap::set($attrs, 'module.decoration.spacing.desktop.value.padding', '10px'); + self::assertSame('10px', AttrMap::get($changed, 'module.decoration.spacing.desktop.value.padding')); + + $removed = AttrMap::remove($attrs, 'title.decoration.font.font'); + self::assertArrayNotHasKey('decoration', $removed['title']); + self::assertSame($attrs, AttrMap::remove($attrs, 'nope.path')); + } + + private function fixture(string $name): string + { + return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name); + } +} From 2f4dcf9ce4bace15c830b977decbdac4ac1cdd23 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 11:47:43 +0200 Subject: [PATCH 093/185] feat: Divi module catalog, attribute paths, short form and lit/divi-catalog Adds per-path attribute kind (text/richtext/code/url/image/color/enum/ onoff/number/list/icon), derived from Divi's field component metadata where an attribute path names one unambiguously (module.paths() itself is unchanged; kinds() is a new, separate lookup exposed as path_kinds on lit/divi-catalog's module detail). This is not in the task-4 brief; it is added because 2b's sanitization routing (ruling R3) needs a metadata-derived field kind per attribute path. Verified live against the full 100-module catalog (564/4358 paths resolve a kind; the rest default to "unknown" and fall back to 2b's key-name routing). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 2 +- src/Integrations/Divi/DiviIntegration.php | 41 + .../Divi/Schema/AttrPathIndex.php | 215 + .../Divi/Schema/DiviCatalogAbility.php | 153 + src/Integrations/Divi/Schema/DiviModules.php | 44 + .../Divi/Schema/ModuleCatalog.php | 150 + src/Integrations/Divi/Schema/ShortformMap.php | 270 ++ src/Plugin.php | 4 +- tests/Fixtures/divi/metadata-sample.json | 3977 +++++++++++++++++ tests/Integration/divi-metadata-fixture.php | 56 + tests/Integration/divi-shortform-check.php | 57 + .../Integrations/Divi/DiviIntegrationTest.php | 37 + .../Divi/Schema/DiviCatalogAbilityTest.php | 70 + .../Divi/Schema/ModuleCatalogTest.php | 153 + 14 files changed, 5227 insertions(+), 2 deletions(-) create mode 100644 src/Integrations/Divi/DiviIntegration.php create mode 100644 src/Integrations/Divi/Schema/AttrPathIndex.php create mode 100644 src/Integrations/Divi/Schema/DiviCatalogAbility.php create mode 100644 src/Integrations/Divi/Schema/DiviModules.php create mode 100644 src/Integrations/Divi/Schema/ModuleCatalog.php create mode 100644 src/Integrations/Divi/Schema/ShortformMap.php create mode 100644 tests/Fixtures/divi/metadata-sample.json create mode 100644 tests/Integration/divi-metadata-fixture.php create mode 100644 tests/Integration/divi-shortform-check.php create mode 100644 tests/Unit/Integrations/Divi/DiviIntegrationTest.php create mode 100644 tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php create mode 100644 tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 15df9ad..ede5509 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,7 +19,7 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php new file mode 100644 index 0000000..30cde14 --- /dev/null +++ b/src/Integrations/Divi/DiviIntegration.php @@ -0,0 +1,41 @@ +modules = new DiviModules(); + } + + public function id(): string + { + return 'divi'; + } + + public function isAvailable(): bool + { + return DiviModules::isAvailable(); + } + + public function abilities(): array + { + return [ + new DiviCatalogAbility($this->modules), + ]; + } +} diff --git a/src/Integrations/Divi/Schema/AttrPathIndex.php b/src/Integrations/Divi/Schema/AttrPathIndex.php new file mode 100644 index 0000000..2a33866 --- /dev/null +++ b/src/Integrations/Divi/Schema/AttrPathIndex.php @@ -0,0 +1,215 @@ + sub paths holding the breakpoint maps. */ + public const NESTED = [ + 'decoration.font' => ['font', 'textShadow'], + 'decoration.bodyFont' => [ + 'body.font', 'body.textShadow', 'link.font', 'link.textShadow', 'ul.font', 'ul.textShadow', 'ul.list', + 'ol.font', 'ol.textShadow', 'ol.list', 'quote.font', 'quote.textShadow', 'quote.border', + ], + 'decoration.headingFont' => [ + 'h1.font', 'h1.textShadow', 'h2.font', 'h2.textShadow', 'h3.font', 'h3.textShadow', + 'h4.font', 'h4.textShadow', 'h5.font', 'h5.textShadow', 'h6.font', 'h6.textShadow', + ], + 'advanced.text' => ['text', 'textShadow'], + ]; + + /** Keys inside a settings category that describe the UI, not an attribute group. */ + private const UI_KEYS = ['groupType', 'item', 'items', 'component', 'groups', 'priority', 'render', 'panel']; + + /** + * Divi field component name => value kind (spec §5.6/ruling R3: 2b routes sanitization by + * this, falling back to key-name matching where a path has no unambiguous scalar kind). + * Structural/style groups (fonts, spacing, shadows, backgrounds, …) rarely name a single + * component for their whole group in the metadata, so they are simply left out here; see + * ModuleCatalog::pathKind() for the "unknown" default. + */ + private const COMPONENT_KINDS = [ + 'divi/text' => 'text', + 'divi/textarea' => 'text', + 'divi/timepicker' => 'text', + 'divi/google-api-key-field' => 'text', + 'divi/richtext' => 'richtext', + 'divi/code' => 'code', + 'divi/link' => 'url', + 'divi/button-link' => 'url', + 'divi/upload' => 'image', + 'divi/upload-gallery' => 'image', + 'divi/color-picker' => 'color', + 'divi/select' => 'enum', + 'divi/select-menu' => 'enum', + 'divi/select-column-class' => 'enum', + 'divi/select-widget-area' => 'enum', + 'divi/checkboxes' => 'list', + 'divi/sortable-list' => 'list', + 'divi/toggle' => 'onoff', + 'divi/range' => 'number', + 'divi/icon-picker' => 'icon', + ]; + + private const ATTR_NAME_PATTERN = '/^[A-Za-z0-9]+(\.[A-Za-z0-9]+)+$/'; + + /** + * @param array $attributes Metadata "attributes" of one module. + * @param array $defaults Its default attributes. + * @return list Sorted. + */ + public static function paths(array $attributes, array $defaults): array + { + $paths = ['module.meta.adminLabel' => true]; + foreach ($attributes as $attrName => $definition) { + $settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : []; + foreach ($settings as $category => $groups) { + if ($category === 'innerContent') { + $paths[$attrName . '.innerContent'] = true; + continue; + } + + if ($category === 'meta' || ! is_array($groups)) { + continue; + } + + foreach (array_keys($groups) as $group) { + if (is_string($group) && ! in_array($group, self::UI_KEYS, true)) { + self::add($paths, $attrName . '.' . $category . '.' . $group); + } + } + } + + self::attrNames($settings, $paths); + } + + self::fromDefaults($defaults, '', $paths); + $list = array_map('strval', array_keys($paths)); + sort($list); + + return $list; + } + + /** + * Known attribute path => value kind (text/richtext/code/url/image/color/enum/onoff/number/ + * list/icon), wherever Divi's metadata names an unambiguous field component for the path. + * Not every known path (see paths()) resolves to a kind; callers default to "unknown". + * + * @param array $attributes Metadata "attributes" of one module. + * @return array + */ + public static function kinds(array $attributes): array + { + $kinds = ['module.meta.adminLabel' => 'text']; + foreach ($attributes as $definition) { + $settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : []; + self::collectKinds($settings, $kinds); + } + + return $kinds; + } + + /** + * Up to $max known paths closest to $path (same attribute group first). + * + * @param list $known + * @return list + */ + public static function suggest(string $path, array $known, int $max = 5): array + { + $first = explode('.', $path)[0]; + $scored = []; + foreach ($known as $candidate) { + $penalty = explode('.', $candidate)[0] === $first ? 0 : 10; + $scored[$candidate] = levenshtein($path, $candidate) + $penalty; + } + asort($scored); + + return array_slice(array_map('strval', array_keys($scored)), 0, $max); + } + + /** @param array $paths */ + private static function add(array &$paths, string $path): void + { + $segments = explode('.', $path); + $nested = count($segments) === 3 ? (self::NESTED[$segments[1] . '.' . $segments[2]] ?? null) : null; + if ($nested === null) { + $paths[$path] = true; + + return; + } + + foreach ($nested as $sub) { + $paths[$path . '.' . $sub] = true; + } + } + + /** + * @param array $settings + * @param array $paths + */ + private static function attrNames(array $settings, array &$paths): void + { + foreach ($settings as $key => $value) { + if ($key === 'attrName' && is_string($value) && preg_match(self::ATTR_NAME_PATTERN, $value)) { + self::add($paths, $value); + } elseif (is_array($value)) { + self::attrNames($value, $paths); + } + } + } + + /** + * @param array $node + * @param array $kinds + */ + private static function collectKinds(array $node, array &$kinds): void + { + $attrName = $node['attrName'] ?? null; + $componentName = $node['component']['name'] ?? null; + if ( + is_string($attrName) + && preg_match(self::ATTR_NAME_PATTERN, $attrName) + && is_string($componentName) + && isset(self::COMPONENT_KINDS[$componentName]) + ) { + $kinds[$attrName] = self::COMPONENT_KINDS[$componentName]; + } + + foreach ($node as $value) { + if (is_array($value)) { + self::collectKinds($value, $kinds); + } + } + } + + /** + * @param array $attrs + * @param array $paths + */ + private static function fromDefaults(array $attrs, string $prefix, array &$paths): void + { + foreach ($attrs as $key => $value) { + if ($prefix === '' && in_array($key, AttrMap::META_KEYS, true)) { + continue; + } + + $path = $prefix === '' ? (string) $key : $prefix . '.' . $key; + if (AttrMap::isBreakpointMap($value)) { + $paths[$path] = true; + } elseif (is_array($value)) { + self::fromDefaults($value, $path, $paths); + } + } + } +} diff --git a/src/Integrations/Divi/Schema/DiviCatalogAbility.php b/src/Integrations/Divi/Schema/DiviCatalogAbility.php new file mode 100644 index 0000000..2451e24 --- /dev/null +++ b/src/Integrations/Divi/Schema/DiviCatalogAbility.php @@ -0,0 +1,153 @@ + 'object', + 'properties' => [ + 'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" oder "divi/button".'], + ], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'modules' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'category' => ['type' => 'string'], + 'children' => ['type' => 'array', 'items' => ['type' => 'string']], + 'shortform' => ['type' => 'boolean'], + ], + 'required' => ['name', 'title', 'category', 'children', 'shortform'], + ], + ], + 'module' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'category' => ['type' => 'string'], + 'children' => ['type' => 'array', 'items' => ['type' => 'string']], + 'item_type' => ['type' => ['string', 'null']], + 'shortform' => ['type' => 'object'], + 'paths' => ['type' => 'array', 'items' => ['type' => 'string']], + 'path_kinds' => [ + 'type' => 'object', + 'description' => 'Bekannter Attributpfad => Feldart (text, richtext, code, url, ' + . 'image, color, enum, onoff, number, list, icon), soweit aus den Divi-Metadaten ' + . 'ableitbar; fehlende Pfade gelten als unbekannt.', + ], + ], + 'required' => ['name', 'title', 'category', 'children', 'shortform', 'paths', 'path_kinds'], + ], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['notes'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_posts') + ? true + : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); + } + + public function execute(array $input): mixed + { + $catalog = $this->modules->catalog(); + $notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".']; + + if (! is_string($input['module'] ?? null) || $input['module'] === '') { + $modules = []; + foreach ($catalog->summary() as $module) { + $modules[] = $module + ['shortform' => ShortformMap::has(substr($module['name'], strlen('divi/')))]; + } + + return ['modules' => $modules, 'notes' => $notes]; + } + + $name = ModuleCatalog::normalize($input['module']); + if (! $catalog->has($name)) { + return Errors::notFound( + sprintf('Modul "%s" gibt es auf dieser Website nicht.', $input['module']), + 'Verfügbare Module mit lit-divi-catalog ohne module abrufen.' + ); + } + + $short = substr($name, strlen('divi/')); + $item = ShortformMap::childType($short); + + return [ + 'module' => [ + 'name' => $name, + 'title' => $catalog->title($name), + 'category' => $catalog->category($name), + 'children' => $catalog->childrenOf($name), + 'item_type' => $item, + 'shortform' => JsonMap::of(ShortformMap::fields($short)), + 'paths' => $catalog->paths($name), + 'path_kinds' => JsonMap::of($catalog->kinds($name)), + ], + 'notes' => $notes, + ]; + } +} diff --git a/src/Integrations/Divi/Schema/DiviModules.php b/src/Integrations/Divi/Schema/DiviModules.php new file mode 100644 index 0000000..2386df2 --- /dev/null +++ b/src/Integrations/Divi/Schema/DiviModules.php @@ -0,0 +1,44 @@ +catalog === null) { + $metadata = ModuleRegistration::get_all_core_modules_metadata(); + $this->catalog = new ModuleCatalog( + is_array($metadata) ? $metadata : [], + static function (string $name): array { + $defaults = ModuleRegistration::get_default_attrs($name, 'default'); + + return is_array($defaults) ? $defaults : []; + } + ); + } + + return $this->catalog; + } + + /** Divi version written into builderVersion of new blocks. */ + public function builderVersion(): string + { + return defined('ET_BUILDER_PRODUCT_VERSION') ? (string) ET_BUILDER_PRODUCT_VERSION : '5.2.1'; + } +} diff --git a/src/Integrations/Divi/Schema/ModuleCatalog.php b/src/Integrations/Divi/Schema/ModuleCatalog.php new file mode 100644 index 0000000..47b7083 --- /dev/null +++ b/src/Integrations/Divi/Schema/ModuleCatalog.php @@ -0,0 +1,150 @@ +> Full name => metadata. */ + private array $modules = []; + + /** @var array> */ + private array $paths = []; + + /** @var array> */ + private array $kinds = []; + + /** @var callable(string): array */ + private $defaults; + + /** + * @param array $metadata Divi's metadata list (keys are ignored). + * @param (callable(string): array)|null $defaults Default attributes per full module name. + */ + public function __construct(array $metadata, ?callable $defaults = null) + { + foreach ($metadata as $module) { + if (is_array($module) && is_string($module['name'] ?? null)) { + $this->modules[$module['name']] = $module; + } + } + ksort($this->modules); + $this->defaults = $defaults ?? static fn (string $name): array => []; + } + + /** "button", "Button" or "divi/button" → "divi/button". */ + public static function normalize(string $type): string + { + $type = strtolower(trim($type)); + + return str_contains($type, '/') ? $type : 'divi/' . $type; + } + + public function has(string $name): bool + { + return isset($this->modules[$name]); + } + + /** @return list */ + public function names(): array + { + return array_map('strval', array_keys($this->modules)); + } + + public function title(string $name): string + { + $title = $this->modules[$name]['title'] ?? $name; + + return is_string($title) ? $title : $name; + } + + /** "module", "structure" or "child-module". */ + public function category(string $name): string + { + $category = $this->modules[$name]['category'] ?? 'module'; + + return is_string($category) ? $category : 'module'; + } + + /** @return list Modules allowed as children (childrenName and childModuleName). */ + public function childrenOf(string $name): array + { + $module = $this->modules[$name] ?? []; + $children = is_array($module['childrenName'] ?? null) ? $module['childrenName'] : []; + if (is_string($module['childModuleName'] ?? null) && $module['childModuleName'] !== '') { + $children[] = $module['childModuleName']; + } + + return array_values(array_unique(array_filter($children, 'is_string'))); + } + + /** @return list */ + public function paths(string $name): array + { + if (! isset($this->paths[$name])) { + $attributes = $this->modules[$name]['attributes'] ?? []; + $defaults = $this->has($name) ? ($this->defaults)($name) : []; + $this->paths[$name] = AttrPathIndex::paths( + is_array($attributes) ? $attributes : [], + is_array($defaults) ? $defaults : [] + ); + } + + return $this->paths[$name]; + } + + public function isKnownPath(string $name, string $path): bool + { + return in_array($path, $this->paths($name), true); + } + + /** @return list */ + public function suggestions(string $name, string $path): array + { + return AttrPathIndex::suggest($path, $this->paths($name)); + } + + /** + * Known attribute path => value kind for this module (spec §5.6, ruling R3), wherever + * Divi's metadata names an unambiguous field component; not every known path resolves. + * + * @return array + */ + public function kinds(string $name): array + { + if (! isset($this->kinds[$name])) { + $attributes = $this->modules[$name]['attributes'] ?? []; + $this->kinds[$name] = AttrPathIndex::kinds(is_array($attributes) ? $attributes : []); + } + + return $this->kinds[$name]; + } + + /** Value kind of one path, "unknown" if Divi's metadata does not resolve one (spec §5.6). */ + public function pathKind(string $name, string $path): string + { + return $this->kinds($name)[$path] ?? 'unknown'; + } + + /** @return list}> */ + public function summary(): array + { + $rows = []; + foreach ($this->names() as $name) { + $rows[] = [ + 'name' => $name, + 'title' => $this->title($name), + 'category' => $this->category($name), + 'children' => $this->childrenOf($name), + ]; + } + + return $rows; + } +} diff --git a/src/Integrations/Divi/Schema/ShortformMap.php b/src/Integrations/Divi/Schema/ShortformMap.php new file mode 100644 index 0000000..8c4ba1b --- /dev/null +++ b/src/Integrations/Divi/Schema/ShortformMap.php @@ -0,0 +1,270 @@ + where Divi 5 keeps + * the value. Paths come from Divi's conversion outlines (…/module-library/src/components/ + * /conversion-outline.json, "*" = breakpoint/state) and are checked against the live + * catalog by tests/Integration/divi-shortform-check.php. + * + * A field is ['path' => attribute path, 'sub' => key inside the desktop value or null, + * 'kind' => value kind, 'description' => German help text], plus 'values' for enums, + * 'keys' ([url, id, alt]) for media and 'with' (fixed extra sub values) where needed. + * Kinds: text, html, code, url, media, enum, onoff, level, number, ids, color. + */ +final class ShortformMap +{ + /** @var array>}>|null */ + private static ?array $modules = null; + + /** @return list Short module names with a short form. */ + public static function types(): array + { + return array_keys(self::modules()); + } + + /** $type is the short name ("button"). */ + public static function has(string $type): bool + { + return isset(self::modules()[$type]); + } + + /** @return array> */ + public static function fields(string $type): array + { + return self::modules()[$type]['fields'] ?? []; + } + + /** Short name of the item type a parent holds in "items" (accordion → accordion-item). */ + public static function childType(string $type): ?string + { + return self::modules()[$type]['child'] ?? null; + } + + /** Field whose emptiness makes the module "empty" (spec §5.5), null if none. */ + public static function primary(string $type): ?string + { + return self::modules()[$type]['primary'] ?? null; + } + + /** + * Current desktop values of the short-form fields (media fields are write-only and left out). + * + * @param array $attrs + * @return array + */ + public static function values(string $type, array $attrs): array + { + $values = []; + foreach (self::fields($type) as $field => $spec) { + if ($spec['kind'] === 'media') { + continue; + } + + $value = AttrMap::desktop($attrs, (string) $spec['path']); + if ($spec['sub'] !== null) { + $value = is_array($value) ? ($value[$spec['sub']] ?? null) : null; + } + + if ($value !== null) { + $values[$field] = $value; + } + } + + return $values; + } + + /** + * @param array $extra keys, values or with. + * @return array + */ + private static function field( + string $path, + ?string $sub, + string $kind, + string $description, + array $extra = [] + ): array { + return array_merge(['path' => $path, 'sub' => $sub, 'kind' => $kind, 'description' => $description], $extra); + } + + /** + * @param array> $fields + * @return array{primary: ?string, child: ?string, fields: array>} + */ + private static function module(?string $primary, array $fields, ?string $child = null): array + { + return ['primary' => $primary, 'child' => $child, 'fields' => $fields]; + } + + /** @return array>}> */ + private static function modules(): array + { + if (self::$modules !== null) { + return self::$modules; + } + + $title = self::field('title.innerContent', null, 'text', 'Titel'); + $content = self::field('content.innerContent', null, 'html', 'Inhalt (HTML erlaubt)'); + $open = self::field('module.advanced.open', null, 'onoff', 'geöffnet (true/false)'); + $level = self::field('title.decoration.font.font', 'headingLevel', 'level', 'Überschriftenebene h1–h6'); + $buttonText = self::field('button.innerContent', 'text', 'text', 'Button-Beschriftung'); + $buttonUrl = self::field('button.innerContent', 'linkUrl', 'url', 'Button-Linkziel'); + $media = static fn (string $path, array $keys, string $description, array $with = []): array => self::field( + $path, + null, + 'media', + $description, + $with === [] ? ['keys' => $keys] : ['keys' => $keys, 'with' => $with] + ); + + self::$modules = [ + 'text' => self::module('content', ['content' => $content]), + 'heading' => self::module('text', [ + 'text' => self::field('title.innerContent', null, 'text', 'Überschrift'), + 'level' => $level, + ]), + 'button' => self::module('text', [ + 'text' => self::field('button.innerContent', 'text', 'text', 'Beschriftung'), + 'url' => self::field('button.innerContent', 'linkUrl', 'url', 'Linkziel'), + 'new_window' => self::field('button.innerContent', 'linkTarget', 'onoff', 'in neuem Fenster öffnen'), + ]), + 'image' => self::module('src', [ + 'image_id' => $media( + 'image.innerContent', + ['src', 'id', 'alt'], + 'Bild aus der Mediathek (Anhang-ID); setzt URL, ID und Alt-Text' + ), + 'src' => self::field('image.innerContent', 'src', 'url', 'Bild-URL'), + 'alt' => self::field('image.innerContent', 'alt', 'text', 'Alt-Text'), + 'title' => self::field('image.innerContent', 'titleText', 'text', 'Titel'), + 'url' => self::field('image.innerContent', 'linkUrl', 'url', 'Linkziel'), + ]), + 'blurb' => self::module('title', [ + 'title' => self::field('title.innerContent', 'text', 'text', 'Titel'), + 'url' => self::field('title.innerContent', 'url', 'url', 'Link des Titels'), + 'content' => $content, + 'image_id' => $media( + 'imageIcon.innerContent', + ['src', null, 'alt'], + 'Bild aus der Mediathek (Anhang-ID)', + ['useIcon' => 'off'] + ), + 'src' => self::field('imageIcon.innerContent', 'src', 'url', 'Bild-URL', [ + 'with' => ['useIcon' => 'off'], + ]), + 'alt' => self::field('imageIcon.innerContent', 'alt', 'text', 'Alt-Text'), + 'level' => $level, + ]), + 'cta' => self::module('title', [ + 'title' => $title, + 'content' => $content, + 'button_text' => $buttonText, + 'button_url' => $buttonUrl, + ]), + 'icon' => self::module(null, [ + 'url' => self::field('icon.innerContent', 'url', 'url', 'Linkziel'), + 'title' => self::field('icon.innerContent', 'title', 'text', 'Titel'), + ]), + 'divider' => self::module(null, [ + 'show' => self::field('divider.advanced.line', 'show', 'onoff', 'Linie anzeigen'), + 'color' => self::field('divider.advanced.line', 'color', 'color', 'Farbe, z. B. {{color:primary}}'), + ]), + 'testimonial' => self::module('content', [ + 'author' => self::field('author.innerContent', null, 'text', 'Name'), + 'job_title' => self::field('jobTitle.innerContent', null, 'text', 'Funktion'), + 'company' => self::field('company.innerContent', 'text', 'text', 'Firma'), + 'company_url' => self::field('company.innerContent', 'linkUrl', 'url', 'Link der Firma'), + 'content' => $content, + 'portrait_id' => $media('portrait.innerContent', ['url', null, null], 'Porträt aus der Mediathek'), + 'portrait_url' => self::field('portrait.innerContent', 'url', 'url', 'Porträt-URL'), + ]), + 'team-member' => self::module('name', [ + 'name' => self::field('name.innerContent', null, 'text', 'Name'), + 'position' => self::field('position.innerContent', null, 'text', 'Position'), + 'content' => $content, + 'image_id' => $media('image.innerContent', ['url', null, null], 'Foto aus der Mediathek'), + 'image_url' => self::field('image.innerContent', 'url', 'url', 'Foto-URL'), + ]), + 'accordion' => self::module(null, [], 'accordion-item'), + 'accordion-item' => self::module('title', ['title' => $title, 'content' => $content, 'open' => $open]), + 'tabs' => self::module(null, [], 'tab'), + 'tab' => self::module('title', ['title' => $title, 'content' => $content]), + 'toggle' => self::module('title', ['title' => $title, 'content' => $content, 'open' => $open]), + 'gallery' => self::module('image_ids', [ + 'image_ids' => self::field('image.advanced.galleryIds', null, 'ids', 'Anhang-IDs, z. B. [12, 33]'), + ]), + 'slider' => self::module(null, [], 'slide'), + 'slide' => self::module('title', [ + 'title' => $title, + 'content' => $content, + 'button_text' => $buttonText, + 'button_url' => $buttonUrl, + 'image_id' => $media('image.innerContent', ['src', null, 'alt'], 'Bild aus der Mediathek'), + 'src' => self::field('image.innerContent', 'src', 'url', 'Bild-URL'), + 'alt' => self::field('image.innerContent', 'alt', 'text', 'Alt-Text'), + ]), + 'contact-form' => self::module(null, [ + 'title' => $title, + 'email' => self::field('email.advanced.receiver', null, 'text', 'Empfänger (leer = Admin-E-Mail)'), + 'success_message' => self::field('module.advanced.successMessage', null, 'text', 'Text nach Absenden'), + 'submit_text' => self::field('button.innerContent', 'text', 'text', 'Beschriftung Absenden'), + ], 'contact-field'), + 'contact-field' => self::module('title', [ + 'title' => self::field('fieldItem.innerContent', null, 'text', 'Feldname'), + 'id' => self::field('fieldItem.advanced.id', null, 'text', 'Feld-ID'), + 'type' => self::field('fieldItem.advanced.type', null, 'enum', 'Feldart (text = mehrzeilig)', [ + 'values' => ['input', 'email', 'text', 'checkbox', 'radio', 'select'], + ]), + 'required' => self::field('fieldItem.advanced.required', null, 'onoff', 'Pflichtfeld'), + ]), + 'map' => self::module('address', [ + 'address' => self::field('map.innerContent', 'address', 'text', 'Adresse'), + 'lat' => self::field('map.innerContent', 'lat', 'number', 'Breitengrad'), + 'lng' => self::field('map.innerContent', 'lng', 'number', 'Längengrad'), + 'zoom' => self::field('map.innerContent', 'zoom', 'number', 'Zoomstufe'), + ], 'map-pin'), + 'map-pin' => self::module('title', [ + 'title' => $title, + 'content' => $content, + 'address' => self::field('pin.innerContent', 'address', 'text', 'Adresse'), + 'lat' => self::field('pin.innerContent', 'lat', 'number', 'Breitengrad'), + 'lng' => self::field('pin.innerContent', 'lng', 'number', 'Längengrad'), + ]), + 'video' => self::module('src', [ + 'src' => self::field('video.innerContent', 'src', 'url', 'Video-URL (YouTube, MP4)'), + 'thumbnail' => self::field('thumbnail.innerContent', 'src', 'url', 'Vorschaubild-URL'), + ]), + 'number-counter' => self::module('number', [ + 'number' => self::field('number.innerContent', null, 'text', 'Zahl'), + 'title' => $title, + 'percent' => self::field('number.advanced.enablePercentSign', null, 'onoff', 'Prozentzeichen anzeigen'), + ]), + 'social-media-follow' => self::module(null, [], 'social-media-follow-network'), + 'social-media-follow-network' => self::module('url', [ + 'network' => self::field( + 'socialNetwork.innerContent', + 'title', + 'text', + 'Netzwerk, z. B. facebook, instagram, linkedin, youtube' + ), + 'url' => self::field('socialNetwork.innerContent', 'link', 'url', 'Profil-URL'), + 'label' => self::field('socialNetwork.innerContent', 'label', 'text', 'Beschriftung'), + ]), + 'icon-list' => self::module(null, [], 'icon-list-item'), + 'icon-list-item' => self::module('text', [ + 'text' => self::field('content.innerContent', null, 'text', 'Text'), + ]), + 'code' => self::module('content', [ + 'content' => self::field('content.innerContent', null, 'code', 'HTML/Code – nur ohne passendes Modul'), + ]), + ]; + + return self::$modules; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 99127ff..9ed09db 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -21,6 +21,7 @@ use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; +use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Pods\PodsFieldSource; @@ -66,7 +67,8 @@ final class Plugin $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog), - new MediaIntegration($schema, $changeLog, $limits) + new MediaIntegration($schema, $changeLog, $limits), + new DiviIntegration($schema) ); $policy = ExposurePolicy::fromOption(); diff --git a/tests/Fixtures/divi/metadata-sample.json b/tests/Fixtures/divi/metadata-sample.json new file mode 100644 index 0000000..0133d13 --- /dev/null +++ b/tests/Fixtures/divi/metadata-sample.json @@ -0,0 +1,3977 @@ +{ + "divi": "5.2.1", + "metadata": [ + { + "name": "divi/section", + "title": "Section", + "category": "structure", + "childrenName": [ + "divi/row" + ], + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": { + "groupType": "group-item", + "item": { + "groupSlug": "contentElements", + "priority": 5, + "render": true, + "component": { + "type": "group", + "name": "divi/elements", + "props": { + "grouped": false + } + } + } + }, + "html": [], + "loop": [], + "link": { + "item": { + "groupSlug": "contentLink", + "component": { + "props": { + "fieldLabel": "Section" + } + } + } + }, + "innerShadow": { + "groupType": "group-item", + "item": { + "groupSlug": "designLayout", + "attrName": "module.advanced.innerShadow", + "label": "Show Inner Shadow", + "description": "Here you can select whether or not your section has an inner shadow. This can look great when you have colored backgrounds or background images.", + "category": "configuration", + "render": true, + "priority": 30, + "features": { + "responsive": false, + "hover": false, + "sticky": false + }, + "component": { + "type": "field", + "name": "divi/toggle" + } + } + }, + "dividers": { + "panel": "design", + "priority": 20, + "groupName": "dividers", + "groupType": "group", + "component": { + "name": "divi/dividers" + } + }, + "gutter": { + "groupType": "group-item", + "item": { + "groupSlug": "designSizing", + "priority": 10, + "render": true, + "component": { + "name": "divi/gutter", + "type": "group", + "props": { + "grouped": false, + "visible": false + } + } + } + } + }, + "decoration": { + "animation": [], + "attributes": [], + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "contentBackground", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/background", + "props": { + "grouped": false + } + } + } + }, + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "layout": { + "groupType": "group-item", + "item": { + "groupSlug": "designLayout", + "attrName": "module.decoration.layout", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/layout", + "props": { + "grouped": false + } + } + } + }, + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": { + "groupType": "group-item", + "item": { + "groupSlug": "designSizing", + "priority": 20, + "render": true, + "component": { + "name": "divi/sizing", + "type": "group", + "props": { + "grouped": false + } + } + } + }, + "spacing": { + "groupType": "group-item", + "item": { + "groupSlug": "designSpacing", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/spacing", + "props": { + "grouped": false + } + } + } + }, + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "column1": { + "settings": { + "advanced": [], + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "contentBackground", + "priority": 60, + "render": true, + "attrName": "column1.decoration.background", + "component": { + "type": "group", + "name": "divi/background", + "props": { + "grouped": false, + "fieldLabel": "Column 1" + } + } + } + }, + "spacing": { + "groupType": "group-item", + "item": { + "groupSlug": "designSpacing", + "priority": 60, + "render": true, + "attrName": "column1.decoration.spacing", + "component": { + "type": "group", + "name": "divi/spacing", + "props": { + "grouped": false, + "fieldLabel": "Column 1", + "fields": { + "margin": { + "render": false + } + } + } + } + } + } + } + } + }, + "column2": { + "settings": { + "advanced": [], + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "contentBackground", + "priority": 60, + "render": true, + "attrName": "column2.decoration.background", + "component": { + "type": "group", + "name": "divi/background", + "props": { + "grouped": false, + "fieldLabel": "Column 2" + } + } + } + }, + "spacing": { + "groupType": "group-item", + "item": { + "groupSlug": "designSpacing", + "priority": 60, + "render": true, + "attrName": "column2.decoration.spacing", + "component": { + "type": "group", + "name": "divi/spacing", + "props": { + "grouped": false, + "fieldLabel": "Column 2", + "fields": { + "margin": { + "render": false + } + } + } + } + } + } + } + } + }, + "column3": { + "settings": { + "advanced": [], + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "contentBackground", + "priority": 60, + "render": true, + "attrName": "column3.decoration.background", + "component": { + "type": "group", + "name": "divi/background", + "props": { + "grouped": false, + "fieldLabel": "Column 3" + } + } + } + }, + "spacing": { + "groupType": "group-item", + "item": { + "groupSlug": "designSpacing", + "priority": 60, + "render": true, + "attrName": "column3.decoration.spacing", + "component": { + "type": "group", + "name": "divi/spacing", + "props": { + "grouped": false, + "fieldLabel": "Column 3", + "fields": { + "margin": { + "render": false + } + } + } + } + } + } + } + } + }, + "innerSizing": { + "settings": { + "decoration": { + "sizing": { + "groupType": "group-items", + "items": { + "width": { + "groupSlug": "designSizing", + "priority": 14, + "render": true, + "subName": "width", + "label": "Inner Width", + "description": "By default, elements will extend the full width of their parent element. If you would like to set a custom static width, you can do so using this option.", + "features": { + "sticky": false, + "dynamicContent": { + "type": "number" + } + }, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "width", + "defaultUnit": "%", + "max": 100 + } + } + }, + "maxWidth": { + "groupSlug": "designSizing", + "priority": 15, + "render": true, + "subName": "maxWidth", + "label": "Inner Max Width", + "description": "Setting a maximum width will prevent your element from ever surpassing the defined width value. Maximum width can be used in combination with the standard width setting. Maximum width supersedes the normal width value.", + "features": { + "sticky": false, + "dynamicContent": { + "type": "number" + } + }, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "max-width", + "defaultUnit": "px", + "max": 1080 + } + } + }, + "alignment": { + "groupSlug": "designSizing", + "priority": 16, + "render": true, + "subName": "alignment", + "label": "Row Alignment", + "description": "Align the module to the left, right or center.", + "features": { + "hover": false, + "sticky": false + }, + "multipleChoices": false, + "component": { + "name": "divi/button-options", + "type": "field", + "props": { + "allowClickToUnset": false, + "options": { + "left": { + "icon": "divi/align-left" + }, + "center": { + "icon": "divi/align-center" + }, + "right": { + "icon": "divi/align-right" + } + }, + "showLabel": false + } + } + } + } + } + } + } + } + } + }, + { + "name": "divi/row", + "title": "Row", + "category": "structure", + "childModuleName": "divi/column", + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": { + "groupType": "group-item", + "item": { + "groupSlug": "contentElements", + "priority": 5, + "render": true, + "component": { + "type": "group", + "name": "divi/elements", + "props": { + "grouped": false + } + } + } + }, + "html": [], + "loop": [], + "link": { + "item": { + "groupSlug": "contentLink", + "component": { + "props": { + "fieldLabel": "Row" + } + } + } + }, + "gutter": { + "groupType": "group-item", + "item": { + "groupSlug": "designLayout", + "priority": 20, + "render": true, + "component": { + "name": "divi/gutter", + "type": "group", + "props": { + "grouped": false + } + } + } + } + }, + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "order": [], + "filters": [], + "interactions": [], + "overflow": [], + "position": [], + "scroll": [], + "layout": { + "groupType": "group-item", + "item": { + "groupSlug": "designLayout", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/layout", + "props": { + "grouped": false + } + } + } + }, + "sizing": { + "groupType": "group-items", + "items": { + "sizing": { + "groupSlug": "sizing", + "priority": 110, + "render": true, + "component": { + "type": "group", + "name": "divi/sizing", + "props": { + "grouped": false, + "fields": { + "maxWidth": { + "component": { + "props": { + "defaultUnit": "px", + "max": 2560 + } + } + } + } + } + } + } + } + }, + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + } + } + }, + { + "name": "divi/column", + "title": "Column", + "category": "structure", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "layout": [], + "overflow": [], + "order": [], + "transition": [], + "position": [], + "scroll": [], + "spacing": [], + "sizing": { + "groupType": "group-item", + "item": { + "groupSlug": "designSizing", + "priority": 20, + "render": true, + "component": { + "type": "group", + "name": "divi/sizing", + "props": { + "grouped": false, + "fields": { + "alignment": { + "render": false + }, + "width": { + "defaultAttr": { + "desktop": { + "value": { + "width": null + } + } + }, + "component": { + "props": { + "defaultUnit": "" + } + } + } + } + } + } + } + }, + "sticky": [], + "transform": [], + "zIndex": [] + }, + "advanced": { + "elements": { + "groupType": "group-item", + "item": { + "groupSlug": "contentElements", + "priority": 5, + "render": true, + "component": { + "type": "group", + "name": "divi/elements", + "props": { + "grouped": false + } + } + } + }, + "html": [], + "loop": [], + "link": { + "item": { + "groupSlug": "contentLink", + "component": { + "props": { + "fieldLabel": "Column" + } + } + } + } + }, + "meta": { + "meta": [] + } + } + } + } + }, + { + "name": "divi/text", + "title": "Text", + "category": "module", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": [], + "html": [], + "link": [], + "loop": [], + "text": { + "groupType": "group-item", + "item": { + "groupSlug": "designText", + "priority": 15, + "render": true, + "component": { + "type": "group", + "name": "divi/text", + "props": { + "grouped": false, + "groupTab": "body", + "groupTabLabel": "Body", + "icon": "divi/text-align-left", + "fieldLabel": "Text", + "fields": { + "textShadowGroup": { + "render": false + } + } + } + } + } + } + }, + "decoration": { + "layout": [], + "animation": [], + "attributes": [], + "background": [], + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "content": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 5, + "render": true, + "attrName": "content.innerContent", + "label": "Body", + "description": "Here you can create the content that will be used within the module.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "text" + }, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/richtext", + "type": "field" + } + } + }, + "decoration": { + "headingFont": [], + "bodyFont": { + "groupType": "group-item", + "item": { + "groupSlug": "designText", + "priority": 5, + "render": true, + "component": { + "name": "divi/font-body", + "type": "group", + "props": { + "grouped": false, + "dynamicSubgroupHost": true, + "groups": { + "body": { + "groupTabLabel": "Body", + "icon": "divi/text-align-left", + "groupTab": "body", + "fieldLabel": "Text" + } + }, + "fields": { + "body": { + "textAlign": { + "render": false + } + } + } + } + } + } + } + } + } + } + } + }, + { + "name": "divi/heading", + "title": "Heading", + "category": "module", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": [], + "html": [], + "link": [], + "loop": [], + "text": { + "groupType": "group", + "groupName": "divi/text", + "panel": "design", + "priority": 10, + "component": { + "props": { + "fields": { + "orientation": { + "render": false + }, + "color": { + "render": false + } + } + } + } + } + }, + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "layout": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "title": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 5, + "render": true, + "attrName": "title.innerContent", + "label": "Heading", + "description": "Input your value to action title here.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "text" + }, + "responsive": true, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + }, + "decoration": { + "font": { + "groupType": "group-item", + "item": { + "groupSlug": "designTitleText", + "priority": 10, + "render": true, + "component": { + "name": "divi/font", + "type": "group", + "props": { + "grouped": false, + "fieldLabel": "Heading", + "dynamicSubgroupHost": true, + "fields": { + "headingLevel": { + "render": true + } + } + } + } + } + } + } + } + } + } + }, + { + "name": "divi/button", + "title": "Button", + "category": "module", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": [], + "html": [], + "loop": [], + "text": { + "component": { + "props": { + "fields": { + "orientation": { + "render": false + }, + "textShadowGroup": { + "render": false + } + } + } + } + }, + "alignment": { + "groupType": "group-item", + "item": { + "groupSlug": "designAlignment", + "priority": 5, + "render": true, + "attrName": "module.advanced.alignment", + "label": "Button Alignment", + "description": "Here you can define the alignment of Button.", + "category": "configuration", + "multipleChoices": false, + "features": { + "hover": false, + "sticky": false, + "preset": [ + "html" + ] + }, + "component": { + "type": "field", + "name": "divi/button-options", + "props": { + "options": { + "left": { + "icon": "divi/align-left" + }, + "center": { + "icon": "divi/align-center" + }, + "right": { + "icon": "divi/align-right" + } + }, + "showLabel": false + } + } + } + } + }, + "decoration": { + "layout": [], + "animation": [], + "attributes": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "button": { + "settings": { + "innerContent": { + "groupType": "into-multiple-groups", + "groups": { + "text": { + "groupType": "group-item", + "item": { + "description": "Input your desired button text.", + "groupSlug": "contentText", + "label": "Button Text", + "priority": 10, + "render": true, + "subName": "text", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "text" + }, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + }, + "link": { + "groupType": "group-item", + "item": { + "groupSlug": "contentLink", + "attrName": "button.innerContent", + "priority": 20, + "render": true, + "component": { + "type": "group", + "name": "divi/button-link", + "props": { + "grouped": false, + "attrName": "button.innerContent", + "fieldLabel": "Button" + } + } + } + } + } + }, + "decoration": { + "background": [], + "border": [], + "boxShadow": [], + "button": { + "component": { + "props": { + "dynamicSubgroupHost": true, + "dynamicSubgroupLockedSubgroupIds": [ + "button.decoration.transform", + "button.decoration.filters", + "button.decoration.animation" + ], + "fields": { + "alignment": { + "render": false + }, + "boxShadowGroup": { + "render": false + }, + "buttonIconGroup": { + "component": { + "props": { + "fields": { + "placement": { + "features": { + "hover": false + } + }, + "onHover": { + "features": { + "hover": false + } + } + } + } + } + }, + "fontGroup": { + "component": { + "props": { + "fields": { + "lineHeight": { + "render": true + }, + "textAlign": { + "render": false + } + } + } + } + }, + "spacingGroup": { + "render": false + } + } + } + } + }, + "font": [], + "sizing": [], + "spacing": { + "component": { + "props": { + "fields": { + "margin": { + "render": false + }, + "padding": { + "render": false + } + } + } + } + } + } + } + } + } + }, + { + "name": "divi/image", + "title": "Image", + "category": "module", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": [], + "html": [], + "loop": [], + "align": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageAlignment", + "label": "Image Alignment", + "description": "Here you can choose the image alignment.", + "category": "layout", + "multipleChoices": false, + "onChangeParams": [ + "multipleChoices" + ], + "render": true, + "priority": 50, + "features": { + "hover": false, + "sticky": false + }, + "component": { + "type": "field", + "name": "divi/button-options", + "props": { + "options": { + "left": { + "icon": "divi/align-left" + }, + "center": { + "icon": "divi/align-center" + }, + "right": { + "icon": "divi/align-right" + } + }, + "showLabel": false + } + } + } + }, + "sizing": { + "groupType": "group-items", + "items": { + "forceFullwidth": { + "groupSlug": "designSizing", + "attrName": "module.advanced.sizing", + "subName": "forceFullwidth", + "label": "Force Fullwidth", + "description": "When enabled, this will force your image to extend 100% of the width of the column it's in.", + "category": "layout", + "render": true, + "priority": 100, + "features": { + "hover": false, + "sticky": false + }, + "component": { + "type": "field", + "name": "divi/toggle" + } + }, + "sizing": { + "groupSlug": "designSizing", + "priority": 110, + "render": true, + "component": { + "type": "group", + "name": "divi/sizing", + "props": { + "grouped": false + } + } + } + } + }, + "spacing": { + "groupType": "group-items", + "items": { + "showBottomSpace": { + "groupSlug": "designSpacing", + "subName": "showBottomSpace", + "label": "Show Space Below The Image", + "description": "Here you can choose whether or not the image should have a space below it.", + "category": "layout", + "render": true, + "priority": 120, + "features": { + "preset": [ + "html" + ] + }, + "component": { + "type": "field", + "name": "divi/toggle" + } + }, + "spacing": { + "groupSlug": "designSpacing", + "priority": 130, + "render": true, + "component": { + "type": "group", + "name": "divi/spacing", + "props": { + "grouped": false + } + } + } + } + } + }, + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "layout": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "image": { + "settings": { + "innerContent": { + "groupType": "group-items", + "items": { + "src": { + "groupSlug": "contentImage", + "subName": "src", + "label": "Image", + "description": "Upload an image.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "image" + }, + "sticky": false, + "responsive": true, + "hover": true, + "preset": "content" + }, + "render": true, + "priority": 10, + "component": { + "type": "field", + "name": "divi/upload", + "props": { + "syncImageData": { + "src": true, + "id": true, + "alt": true, + "titleText": true + } + } + } + }, + "linkUrl": { + "groupSlug": "contentImageLink", + "subName": "linkUrl", + "label": "Image Link URL", + "description": "If you would like your image to be a link, input your destination URL here. No link will be created if this field is left blank.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "url" + }, + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "render": true, + "priority": 20, + "component": { + "type": "field", + "name": "divi/text" + } + }, + "linkTarget": { + "groupSlug": "contentImageLink", + "subName": "linkTarget", + "label": "Image Link Target", + "description": "Here you can choose whether or not your link opens in a new window.", + "category": "configuration", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content" + }, + "render": true, + "priority": 30, + "component": { + "type": "field", + "name": "divi/select", + "props": { + "options": { + "off": { + "label": "In The Current Tab" + }, + "on": { + "label": "In A New Tab" + } + } + } + } + } + } + }, + "advanced": { + "lightbox": { + "groupType": "group-item", + "item": { + "groupSlug": "contentImageLink", + "label": "Open in Lightbox", + "description": "Here you can choose whether or not the image should open in Lightbox. Note: if you select to open the image in Lightbox, url options below will be ignored.", + "category": "configuration", + "render": true, + "priority": 10, + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": "content" + }, + "component": { + "type": "field", + "name": "divi/toggle" + } + } + }, + "overlay": { + "groupType": "group-items", + "items": { + "use": { + "groupSlug": "designImageOverlay", + "subName": "use", + "label": "Image Overlay", + "description": "If enabled, an overlay color and icon will be displayed when a visitors hovers over the image", + "category": "layout", + "render": true, + "priority": 40, + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": [ + "style" + ] + }, + "component": { + "type": "field", + "name": "divi/toggle" + } + }, + "backgroundColor": { + "groupSlug": "designImageOverlay", + "subName": "backgroundColor", + "label": "Hover Overlay Color", + "description": "Here you can define a custom color for the overlay", + "features": { + "hover": false, + "preset": [ + "style" + ], + "dynamicContent": { + "type": "color" + } + }, + "render": true, + "priority": 60, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + } + }, + "overlayIcon": { + "groupType": "group-items", + "items": { + "hoverIcon": { + "groupSlug": "designImageOverlay", + "subName": "hoverIcon", + "label": "Hover Icon Picker", + "description": "Here you can define a custom icon for the overlay.", + "category": "configuration", + "features": { + "sticky": false, + "hover": false, + "preset": [ + "style" + ] + }, + "render": true, + "priority": 70, + "component": { + "type": "field", + "name": "divi/icon-picker" + } + }, + "iconColor": { + "groupSlug": "designImageOverlay", + "subName": "iconColor", + "label": "Overlay Icon Color", + "description": "Here you can define a custom color for the overlay icon", + "render": true, + "priority": 50, + "features": { + "hover": false, + "preset": [ + "style" + ], + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + } + } + }, + "decoration": { + "border": [], + "boxShadow": [] + } + } + } + } + }, + { + "name": "divi/blurb", + "title": "Blurb", + "category": "module", + "childrenName": [], + "attributes": { + "imageIcon": { + "settings": { + "innerContent": { + "groupType": "group-items", + "items": { + "useIcon": { + "groupSlug": "contentImageIcon", + "attrName": "imageIcon.innerContent", + "subName": "useIcon", + "label": "Use Icon", + "description": "Here you can choose whether icon set below should be used.", + "category": "basic_option", + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": "content" + }, + "render": true, + "priority": 10, + "component": { + "type": "field", + "name": "divi/toggle" + } + }, + "icon": { + "groupSlug": "contentImageIcon", + "attrName": "imageIcon.innerContent", + "subName": "icon", + "label": "Icon", + "description": "Choose an icon to display with your blurb.", + "category": "basic_option", + "features": { + "sticky": false, + "preset": "content" + }, + "render": true, + "priority": 10, + "component": { + "type": "field", + "name": "divi/icon-picker" + } + }, + "src": { + "groupSlug": "contentImageIcon", + "attrName": "imageIcon.innerContent", + "subName": "src", + "label": "Image", + "description": "Upload an image to display at the top of your blurb.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "image" + }, + "sticky": false, + "responsive": true, + "hover": true, + "preset": "content" + }, + "render": true, + "priority": 10, + "component": { + "type": "field", + "name": "divi/upload", + "props": { + "syncImageData": { + "src": true, + "id": true, + "alt": true, + "titleText": true + } + } + } + } + } + }, + "advanced": { + "color": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "attrName": "imageIcon.advanced.color", + "label": "Icon Color", + "description": "Here you can define a custom color for your icon.", + "render": true, + "priority": 10, + "features": { + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + }, + "placement": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "attrName": "imageIcon.advanced.placement", + "label": "Image/Icon Placement", + "description": "Here you can choose where to place the icon.", + "category": "layout", + "render": true, + "priority": 30, + "features": { + "hover": false, + "sticky": false + }, + "component": { + "type": "field", + "name": "divi/select", + "props": { + "options": { + "top": { + "label": "Top" + }, + "left": { + "label": "Left" + } + } + } + } + } + } + }, + "decoration": { + "sizing": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "priority": 52, + "render": true, + "component": { + "type": "group", + "name": "divi/sizing", + "props": { + "attrName": "imageIcon.decoration.sizing", + "grouped": true, + "fieldLabel": "Image/Icon", + "dynamicSubgroupHostLayoutStyle": "block", + "fields": { + "iconFontSize": { + "attrName": "imageIcon.decoration.sizing", + "subName": "iconFontSize", + "defaultAttr": { + "desktop": { + "value": { + "iconFontSize": "96px" + } + } + }, + "label": "Icon Font Size", + "description": "Control the size of the icon by increasing or decreasing the font size.", + "priority": 1, + "category": "font_option", + "render": true, + "features": { + "dynamicContent": { + "type": "number" + } + }, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "font-size", + "defaultUnit": "px", + "min": 1, + "minLimit": 1, + "max": 120 + } + } + } + } + } + } + } + }, + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "priority": 55, + "render": true, + "component": { + "type": "group", + "name": "divi/background", + "props": { + "attrName": "imageIcon.decoration.background", + "grouped": true, + "fieldLabel": "Image/Icon" + } + } + } + }, + "border": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "priority": 60, + "render": true, + "component": { + "type": "group", + "name": "divi/border", + "props": { + "grouped": true, + "fieldLabel": "Image/Icon" + } + } + } + }, + "spacing": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "priority": 60, + "render": true, + "component": { + "type": "group", + "name": "divi/spacing", + "props": { + "grouped": true, + "fieldLabel": "Image/Icon" + } + } + } + }, + "boxShadow": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "priority": 60, + "render": true, + "component": { + "type": "group", + "name": "divi/box-shadow", + "props": { + "grouped": true, + "fieldLabel": "Image" + } + } + } + }, + "filters": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "priority": 60, + "render": true, + "component": { + "type": "group", + "name": "divi/filters", + "props": { + "grouped": true, + "fieldLabel": "Image/Icon" + } + } + } + }, + "animation": { + "groupType": "group-item", + "item": { + "groupSlug": "designImageIcon", + "priority": 70, + "render": true, + "component": { + "type": "group", + "name": "divi/animation", + "props": { + "attrName": "imageIcon.decoration.animation", + "grouped": true, + "fieldLabel": "Image/Icon" + } + } + } + } + } + } + }, + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": [], + "html": [], + "link": [], + "loop": [], + "text": [] + }, + "decoration": { + "animation": { + "groupType": "group-item", + "item": { + "groupSlug": "designAnimation", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/animation", + "props": { + "grouped": false + } + } + } + }, + "attributes": [], + "background": [], + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "layout": [], + "order": [], + "overflow": [], + "position": [], + "scroll": [], + "sizing": { + "groupType": "group-item", + "item": { + "groupSlug": "designSizing", + "priority": 20, + "render": true, + "component": { + "type": "group", + "name": "divi/sizing", + "props": { + "grouped": false + } + } + } + }, + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "title": { + "settings": { + "innerContent": [], + "decoration": { + "font": { + "component": { + "props": { + "dynamicSubgroupHost": true, + "dynamicSubgroupHostLayoutStyle": "flex" + } + } + } + } + } + }, + "content": { + "settings": { + "innerContent": { + "item": { + "groupSlug": "contentText", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "text" + } + } + } + }, + "decoration": { + "bodyFont": { + "component": { + "props": { + "dynamicSubgroupHost": true, + "dynamicSubgroupHostLayoutStyle": "flex" + } + } + } + } + } + }, + "contentContainer": { + "settings": { + "decoration": { + "sizing": { + "groupType": "group-items", + "items": { + "maxWidth": { + "groupSlug": "designSizing", + "attrName": "contentContainer.decoration.sizing", + "subName": "maxWidth", + "label": "Content Width", + "description": "Adjust the width of the content within the blurb.", + "category": "layout", + "priority": 10, + "render": true, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "width", + "max": 1100 + } + }, + "features": { + "dynamicContent": { + "type": "number" + } + } + } + } + } + } + } + } + } + }, + { + "name": "divi/code", + "title": "Code", + "category": "module", + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "html": [], + "link": [], + "loop": [], + "text": { + "component": { + "props": { + "fields": { + "color": { + "render": false + }, + "textShadowGroup": { + "render": false + } + } + } + } + } + }, + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "content": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 5, + "render": true, + "attrName": "content.innerContent", + "label": "Code", + "description": "Here you can create the content that will be used within the module.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "text" + }, + "preset": "content" + }, + "component": { + "name": "divi/code", + "type": "field", + "props": { + "mode": "html" + } + } + } + } + } + } + } + }, + { + "name": "divi/accordion", + "title": "Accordion", + "category": "module", + "childrenName": [ + "divi/accordion-item" + ], + "childModuleName": "divi/accordion-item", + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": { + "groupType": "group-item", + "item": { + "groupSlug": "contentElements", + "priority": 5, + "render": true, + "component": { + "type": "group", + "name": "divi/elements", + "props": { + "grouped": false + } + } + } + }, + "html": [], + "link": [], + "loop": [], + "text": { + "priority": 15, + "component": { + "props": { + "fields": { + "color": { + "render": false + }, + "textShadowGroup": { + "render": true + }, + "orientation": { + "render": true + } + } + } + } + } + }, + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "layout": { + "groupType": "group-item", + "item": { + "groupSlug": "designLayout", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/layout", + "props": { + "grouped": false, + "defaultGroupAttr": { + "desktop": { + "value": { + "flexDirection": "column", + "columnGap": "30px", + "rowGap": "30px" + } + } + } + } + } + } + }, + "border": [], + "boxShadow": [], + "overflow": [], + "order": [], + "position": [], + "scroll": { + "groupType": "group-item", + "item": { + "priority": 20, + "render": true, + "groupSlug": "advancedScrollModule", + "component": { + "type": "group", + "name": "divi/scroll", + "props": { + "grouped": false, + "fields": { + "gridMotion": { + "render": true + } + } + } + } + } + }, + "sizing": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "title": { + "settings": { + "decoration": { + "font": { + "groupType": "group-item", + "item": { + "groupSlug": "designTitleText", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/font", + "props": { + "grouped": false, + "groupLabel": "Title Text", + "fieldLabel": "Title", + "dynamicSubgroupHost": true, + "fields": { + "color": { + "render": true, + "priority": 5 + }, + "headingLevel": { + "render": true + } + } + } + } + } + } + } + } + }, + "closedToggleIcon": { + "settings": { + "decoration": { + "icon": { + "groupType": "group-items", + "items": { + "contentIcon": { + "groupSlug": "contentToggleIcon", + "attrName": "closedToggleIcon.decoration.icon", + "priority": 10, + "render": true, + "category": "basic_option", + "component": { + "type": "group", + "name": "divi/icon", + "props": { + "grouped": false, + "groupLabel": "Toggle Icon", + "fields": { + "icon": { + "render": true + }, + "color": { + "render": false + }, + "useSize": { + "render": false + }, + "size": { + "render": false + } + } + } + } + }, + "designIcon": { + "groupSlug": "designToggleIcon", + "attrName": "closedToggleIcon.decoration.icon", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/icon", + "props": { + "grouped": false, + "fieldLabel": "Icon", + "fields": { + "icon": { + "render": false + }, + "color": { + "render": true + }, + "useSize": { + "render": true + }, + "size": { + "render": true, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "icon-font-size", + "defaultUnit": "px", + "min": 1, + "minLimit": 1, + "max": 120 + } + } + } + } + } + } + } + } + } + } + } + }, + "content": { + "settings": { + "decoration": { + "bodyFont": { + "priority": 30, + "component": { + "props": { + "dynamicSubgroupHost": true, + "groups": { + "body": { + "groupLabel": "Body Text", + "fieldLabel": "Body" + } + } + } + } + } + } + } + }, + "openToggle": { + "settings": { + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "designToggle", + "attrName": "openToggle.decoration.background", + "subName": "color", + "label": "Open Toggle Background Color", + "description": "You can pick unique background colors for toggles when they are in their open and closed states. Choose the open state background color here.", + "priority": 10, + "render": true, + "features": { + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + }, + "font": { + "groupType": "group-item", + "item": { + "groupSlug": "designTitleText", + "attrName": "openToggle.decoration.font.font", + "subName": "color", + "label": "Open Title Text Color", + "description": "You can pick unique text colors for toggle titles when they are open and closed. Choose the open state title color here.", + "priority": 5, + "render": true, + "features": { + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + } + } + } + }, + "closedToggle": { + "settings": { + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "designToggle", + "attrName": "closedToggle.decoration.background", + "subName": "color", + "label": "Closed Toggle Background Color", + "description": "You can pick unique background colors for toggles when they are in their open and closed states. Choose the closed state background color here.", + "priority": 10, + "render": true, + "features": { + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + }, + "font": { + "groupType": "group-item", + "item": { + "groupSlug": "designClosedTitleText", + "attrName": "closedToggle.decoration.font", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/font", + "props": { + "grouped": false, + "groupLabel": "Closed Title Text", + "fieldLabel": "Closed Title", + "dynamicSubgroupHost": true, + "fields": { + "color": { + "priority": 5, + "render": true + } + } + } + } + } + } + } + } + } + } + }, + { + "name": "divi/accordion-item", + "title": "Accordion Item", + "category": "child-module", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "advanced": { + "elements": [], + "html": [], + "link": [], + "loop": [], + "text": { + "priority": 15, + "component": { + "props": { + "fields": { + "color": { + "render": false + } + } + } + } + } + }, + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "border": [], + "boxShadow": [], + "layout": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "title": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "attrName": "title.innerContent", + "label": "Title", + "description": "The title will appear above the content and when the toggle is closed.", + "category": "basic_option", + "priority": 10, + "render": true, + "features": { + "dynamicContent": { + "type": "text" + }, + "sticky": false, + "preset": "content" + }, + "component": { + "type": "field", + "name": "divi/text" + } + } + }, + "decoration": { + "font": { + "groupType": "group-item", + "item": { + "groupSlug": "designTitleText", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/font", + "props": { + "grouped": false, + "groupLabel": "Title Text", + "fieldLabel": "Title", + "dynamicSubgroupHost": true, + "dynamicSubgroupHostLayoutStyle": "moduleLayout", + "fields": { + "color": { + "priority": 5, + "render": true + }, + "headingLevel": { + "render": true + } + } + } + } + } + } + } + } + }, + "closedToggleIcon": { + "settings": { + "decoration": { + "icon": { + "groupType": "group-items", + "items": { + "icon": { + "groupSlug": "designToggleIcon", + "attrName": "closedToggleIcon.decoration.icon", + "label": "Closed Icon", + "priority": 10, + "render": true, + "category": "basic_option", + "features": { + "preset": [ + "style", + "html" + ] + }, + "component": { + "type": "field", + "name": "divi/icon-picker" + } + }, + "iconAttributes": { + "groupSlug": "designToggleIcon", + "attrName": "closedToggleIcon.decoration.icon", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/icon", + "props": { + "grouped": false, + "fieldLabel": "Icon", + "fields": { + "icon": { + "render": false + }, + "color": { + "render": true + }, + "useSize": { + "render": true + }, + "size": { + "render": true, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "icon-font-size", + "defaultUnit": "px", + "min": 1, + "minLimit": 1, + "max": 120 + } + } + } + } + } + } + } + } + } + } + } + }, + "openToggle": { + "settings": { + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "designToggle", + "attrName": "openToggle.decoration.background", + "subName": "color", + "label": "Open Toggle Background Color", + "description": "You can pick unique background colors for toggles when they are in their open and closed states. Choose the open state background color here.", + "priority": 10, + "render": true, + "features": { + "sticky": false, + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + }, + "font": { + "groupType": "group-item", + "item": { + "groupSlug": "designTitleText", + "attrName": "openToggle.decoration.font.font", + "subName": "color", + "label": "Open Title Text Color", + "description": "You can pick unique text colors for toggle titles when they are open and closed. Choose the open state title color here.", + "priority": 5, + "render": true, + "features": { + "sticky": false, + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + } + } + } + }, + "closedToggle": { + "settings": { + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "designToggle", + "attrName": "closedToggle.decoration.background", + "subName": "color", + "label": "Closed Toggle Background Color", + "description": "You can pick unique background colors for toggles when they are in their open and closed states. Choose the open state background color here.", + "priority": 10, + "render": true, + "features": { + "sticky": false, + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + }, + "font": { + "groupType": "group-items", + "items": { + "color": { + "groupSlug": "designClosedTitleText", + "attrName": "closedToggle.decoration.font.font", + "subName": "color", + "label": "Closed Title Text Color", + "description": "You can pick unique text colors for toggle titles when they are open and closed. Choose the closed state title color here.", + "priority": 5, + "render": true, + "features": { + "sticky": false, + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + }, + "font": { + "groupSlug": "designClosedTitleText", + "attrName": "closedToggle.decoration.font", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/font", + "props": { + "grouped": false, + "groupLabel": "Closed Title Text", + "fieldLabel": "Closed Title", + "dynamicSubgroupHost": true, + "fields": { + "color": { + "render": false + } + } + } + } + } + } + } + } + } + }, + "content": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "attrName": "content.innerContent", + "label": "Body", + "description": "Input the main text content for your module here.", + "category": "basic_option", + "priority": 10, + "render": true, + "features": { + "sticky": false, + "preset": "content", + "dynamicContent": { + "type": "text" + } + }, + "component": { + "type": "field", + "name": "divi/richtext" + } + } + }, + "decoration": { + "bodyFont": { + "priority": 30, + "component": { + "props": { + "dynamicSubgroupHost": true, + "dynamicSubgroupHostLayoutStyle": "moduleLayout", + "groups": { + "body": { + "groupLabel": "Body Text", + "fieldLabel": "Body" + } + } + } + } + } + } + } + } + } + }, + { + "name": "divi/fullwidth-header", + "title": "Hero", + "category": "fullwidth-module", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": [], + "html": [], + "link": [], + "loop": [], + "text": { + "groupType": "group-items", + "items": { + "text": { + "groupSlug": "designLayout", + "attrName": "module.advanced.text.text", + "subName": "orientation", + "label": "Text & Logo Alignment", + "description": "This controls how your text is aligned within the module.", + "category": "layout", + "priority": 10, + "render": true, + "multipleChoices": false, + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": [ + "html" + ] + }, + "component": { + "name": "divi/button-options", + "type": "field", + "props": { + "allowClickToUnset": false, + "defaultValue": "left", + "options": { + "left": { + "icon": "divi/text-align-left", + "label": "Left" + }, + "center": { + "icon": "divi/text-align-center", + "label": "Center" + }, + "right": { + "icon": "divi/text-align-right", + "label": "Right" + } + }, + "showLabel": false + } + } + }, + "textGroup": { + "groupSlug": "designText", + "priority": 20, + "render": true, + "component": { + "type": "group", + "name": "divi/text", + "props": { + "fields": { + "orientation": { + "render": false + } + }, + "grouped": false + } + } + } + } + }, + "headerFullscreen": { + "groupType": "group-item", + "item": { + "groupSlug": "designLayout", + "attrName": "module.advanced.headerFullscreen", + "label": "Make Fullscreen", + "description": "Here you can choose whether the header is expanded to fullscreen size.", + "category": "configuration", + "priority": 20, + "render": true, + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": [ + "html" + ] + }, + "component": { + "name": "divi/toggle", + "type": "field" + } + } + } + }, + "decoration": { + "layout": { + "groupType": "group-item", + "item": { + "groupSlug": "designLayout", + "priority": 30, + "render": true, + "component": { + "type": "group", + "name": "divi/layout", + "props": { + "grouped": false + } + } + } + }, + "animation": [], + "attributes": [], + "border": [], + "background": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": { + "groupType": "group-items", + "items": { + "sizing": { + "groupSlug": "designSizing", + "priority": 20, + "render": true, + "component": { + "type": "group", + "name": "divi/sizing", + "props": { + "grouped": false + } + } + } + } + }, + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "image": { + "settings": { + "innerContent": { + "groupType": "group-items", + "items": { + "imageInnerContentSrc": { + "priority": 20, + "groupSlug": "contentImage", + "render": true, + "attrName": "image.innerContent", + "subName": "src", + "label": "Header Image", + "description": "Upload your desired image, or type in the URL to the image you would like to display.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "image" + }, + "preset": "content" + }, + "component": { + "type": "field", + "name": "divi/upload", + "props": { + "syncImageData": { + "src": true, + "id": true, + "alt": true, + "titleText": true + } + } + } + } + } + }, + "decoration": { + "border": { + "groupType": "group-item", + "item": { + "groupSlug": "designImage", + "attrName": "image.decoration.border", + "priority": 20, + "render": true, + "component": { + "type": "group", + "name": "divi/border", + "props": { + "grouped": true + } + } + } + }, + "boxShadow": { + "groupType": "group-item", + "item": { + "groupSlug": "designImage", + "attrName": "image.decoration.boxShadow", + "priority": 30, + "render": true, + "component": { + "type": "group", + "name": "divi/box-shadow", + "props": { + "grouped": true + } + } + } + }, + "filters": { + "groupType": "group-item", + "item": { + "groupSlug": "designImage", + "attrName": "image.decoration.filters", + "priority": 30, + "render": true, + "component": { + "type": "group", + "name": "divi/filters", + "props": { + "grouped": true + } + } + } + } + }, + "advanced": { + "orientation": { + "groupType": "group-item", + "item": { + "groupSlug": "designImage", + "attrName": "image.advanced.orientation", + "label": "Image Alignment", + "description": "This controls the orientation of the image within the module.", + "category": "layout", + "priority": 10, + "render": true, + "multipleChoices": false, + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": [ + "html" + ] + }, + "component": { + "name": "divi/select", + "type": "field" + } + } + } + } + } + }, + "logo": { + "settings": { + "innerContent": { + "groupType": "group-items", + "items": { + "logoInnerContentSrc": { + "priority": 10, + "groupSlug": "contentImage", + "render": true, + "attrName": "logo.innerContent", + "subName": "src", + "label": "Logo Image", + "description": "Upload your desired image, or type in the URL to the image you would like to display.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "image" + }, + "preset": "content" + }, + "component": { + "type": "field", + "name": "divi/upload", + "props": { + "syncImageData": { + "src": true, + "id": true, + "alt": true, + "titleText": true, + "width": true, + "height": true + } + } + } + } + } + } + } + }, + "title": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "priority": 10, + "groupSlug": "contentText", + "render": true, + "attrName": "title.innerContent", + "label": "Title", + "description": "Enter your page title here.", + "category": "basic_option", + "features": { + "sticky": false, + "dynamicContent": { + "type": "text" + }, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + }, + "decoration": { + "font": { + "priority": 30, + "groupLabel": "Title Text", + "component": { + "type": "group", + "name": "divi/font", + "props": { + "dynamicSubgroupHost": true, + "fields": { + "headingLevel": { + "render": true + } + } + } + } + } + } + } + }, + "subhead": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "priority": 20, + "groupSlug": "contentText", + "render": true, + "attrName": "subhead.innerContent", + "label": "Subtitle", + "description": "If you would like to use a subhead, add it here. Your subhead will appear below your title in a small font.", + "category": "basic_option", + "features": { + "sticky": false, + "dynamicContent": { + "type": "text" + }, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + }, + "decoration": { + "font": { + "groupType": "group-item", + "item": { + "groupSlug": "designSubtitleText", + "priority": 10, + "render": true, + "component": { + "type": "group", + "name": "divi/font", + "props": { + "fieldLabel": "Subtitle", + "grouped": false, + "dynamicSubgroupHost": true + } + } + } + } + } + } + }, + "content": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "priority": 50, + "groupSlug": "contentText", + "render": true, + "attrName": "content.innerContent", + "label": "Body", + "description": "Content entered here will appear below the subheading text.", + "category": "basic_option", + "features": { + "sticky": false, + "preset": "content", + "dynamicContent": { + "type": "text" + } + } + } + }, + "advanced": { + "orientation": { + "groupType": "group-item", + "item": { + "priority": 10, + "groupSlug": "designText", + "render": true, + "attrName": "content.advanced.orientation", + "label": "Text Vertical Alignment", + "description": "This setting determines the vertical alignment of your content. Your content can either be vertically centered, or aligned to the bottom.", + "category": "layout", + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": [ + "html" + ] + }, + "component": { + "type": "field", + "name": "divi/select" + } + } + }, + "maxWidth": { + "groupType": "group-item", + "item": { + "priority": 10, + "groupSlug": "designSizing", + "render": true, + "attrName": "content.advanced.maxWidth", + "label": "Content Width", + "description": "By default, elements will extend the full width of their parent element. If you would like to set a custom static width, you can do so using this option.", + "category": "layout", + "features": { + "hover": false, + "sticky": false, + "dynamicContent": { + "type": "number" + } + }, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "max-width", + "defaultUnit": "%", + "max": 100 + } + } + } + } + }, + "decoration": { + "bodyFont": { + "priority": 35, + "component": { + "type": "group", + "name": "divi/font-body", + "props": { + "dynamicSubgroupHost": true, + "groups": { + "body": { + "fieldLabel": "Body" + } + } + } + } + } + } + } + }, + "buttonOne": { + "settings": { + "innerContent": { + "groupType": "group-items", + "items": { + "buttonOneText": { + "priority": 40, + "groupSlug": "contentText", + "render": true, + "attrName": "buttonOne.innerContent", + "subName": "text", + "label": "Button #1", + "description": "Enter the text for the Button.", + "category": "basic_option", + "features": { + "sticky": false, + "dynamicContent": { + "type": "text" + }, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + }, + "buttonOneLinkUrl": { + "groupSlug": "contentLink", + "priority": 5, + "render": true, + "attrName": "buttonOne.innerContent", + "subName": "linkUrl", + "label": "Button #1 Link URL", + "description": "Enter the URL for the Button.", + "category": "basic_option", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "dynamicContent": { + "type": "url" + }, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + } + }, + "decoration": { + "background": [], + "border": [], + "boxShadow": [], + "button": { + "priority": 40, + "component": { + "props": { + "dynamicSubgroupHost": true, + "fields": { + "sizingGroup": { + "component": { + "props": { + "fields": { + "alignment": { + "render": false + }, + "alignSelf": { + "render": false + }, + "gridAlignSelf": { + "render": false + }, + "gridJustifySelf": { + "render": false + } + } + } + } + } + }, + "groupLabel": "Button One" + } + } + }, + "font": [], + "sizing": [], + "spacing": [] + } + } + }, + "buttonTwo": { + "settings": { + "innerContent": { + "groupType": "group-items", + "items": { + "buttonTwoText": { + "groupSlug": "contentText", + "attrName": "buttonTwo.innerContent", + "subName": "text", + "label": "Button #2", + "description": "Enter the text for the Button.", + "category": "basic_option", + "priority": 40, + "render": true, + "features": { + "sticky": false, + "dynamicContent": { + "type": "text" + }, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + }, + "buttonTwoLinkUrl": { + "groupSlug": "contentLink", + "priority": 10, + "render": true, + "attrName": "buttonTwo.innerContent", + "subName": "linkUrl", + "label": "Button #2 Link URL", + "description": "Enter the URL for the Button.", + "category": "basic_option", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "dynamicContent": { + "type": "url" + }, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + } + }, + "decoration": { + "background": [], + "border": [], + "boxShadow": [], + "button": { + "groupType": "group", + "priority": 45, + "component": { + "props": { + "dynamicSubgroupHost": true, + "fields": { + "sizingGroup": { + "component": { + "props": { + "fields": { + "alignment": { + "render": false + }, + "alignSelf": { + "render": false + }, + "gridAlignSelf": { + "render": false + }, + "gridJustifySelf": { + "render": false + } + } + } + } + } + }, + "groupLabel": "Button Two" + } + } + }, + "font": [], + "sizing": [], + "spacing": [] + } + } + }, + "scrollDown": { + "settings": { + "decoration": { + "icon": { + "groupType": "group-items", + "items": { + "scrollDownShow": { + "groupSlug": "designScrollDown", + "attrName": "scrollDown.decoration.icon", + "subName": "show", + "label": "Show Scroll Down Button", + "description": "Here you can choose whether the scroll down button is shown.", + "category": "configuration", + "render": true, + "priority": 10, + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "preset": [ + "html" + ] + }, + "component": { + "type": "field", + "name": "divi/toggle" + } + }, + "scrollDownIcon": { + "groupSlug": "designScrollDown", + "attrName": "scrollDown.decoration.icon", + "label": "Icon", + "description": "Choose an icon to display for the scroll down button.", + "category": "configuration", + "render": true, + "priority": 20, + "features": { + "hover": false, + "sticky": false, + "responsive": true, + "preset": [ + "html", + "style" + ] + }, + "component": { + "type": "field", + "name": "divi/icon-picker", + "props": { + "rowCount": 1, + "showSearch": false, + "showModal": false, + "showFilter": false + } + } + }, + "scrollDownIconColor": { + "groupSlug": "designScrollDown", + "attrName": "scrollDown.decoration.icon", + "subName": "color", + "label": "Scroll Down Icon Color", + "description": "Here you can define a custom color for the scroll down icon.", + "render": true, + "priority": 30, + "features": { + "hover": true, + "sticky": false, + "responsive": true, + "preset": [ + "html", + "style" + ], + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + }, + "scrollDownIconSize": { + "groupSlug": "designScrollDown", + "attrName": "scrollDown.decoration.icon", + "subName": "size", + "label": "Scroll Down Icon Size", + "description": "Increase or decrease the size of the scroll down arrow that appears towards the bottom of the module.", + "category": "layout", + "render": true, + "priority": 40, + "features": { + "hover": true, + "sticky": false, + "responsive": true, + "preset": [ + "html", + "style" + ] + }, + "component": { + "type": "field", + "name": "divi/range", + "props": { + "cssProperty": "font-size" + } + } + } + } + } + } + } + }, + "overlay": { + "settings": { + "decoration": { + "background": { + "groupType": "group-item", + "item": { + "groupSlug": "designOverlay", + "attrName": "overlay.decoration.background", + "subName": "color", + "label": "Background Overlay Color", + "description": "Pick a color to use for the background overlay. Decreasing the opacity will allow background images and gradients to show through while still keeping the text readable.", + "render": true, + "priority": 10, + "features": { + "hover": false, + "sticky": false, + "responsive": false, + "dynamicContent": { + "type": "color" + } + }, + "component": { + "type": "field", + "name": "divi/color-picker" + } + } + } + } + } + } + } + }, + { + "name": "divi/group", + "title": "Group", + "category": "structure", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "decoration": { + "animation": [], + "attributes": [], + "background": [], + "border": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "overflow": [], + "order": [], + "transition": [], + "position": [], + "scroll": [], + "spacing": [], + "sizing": [], + "sticky": [], + "transform": [], + "zIndex": [], + "layout": [] + }, + "advanced": { + "elements": { + "groupType": "group-item", + "item": { + "groupSlug": "contentElements", + "priority": 5, + "render": true, + "component": { + "type": "group", + "name": "divi/elements", + "props": { + "grouped": false + } + } + } + }, + "html": [], + "loop": [], + "link": { + "item": { + "groupSlug": "contentLink", + "component": { + "props": { + "fieldLabel": "Group" + } + } + } + } + }, + "meta": { + "meta": [] + } + } + } + } + } + ], + "defaults": { + "divi/section": { + "module": { + "advanced": { + "innerShadow": { + "desktop": { + "value": "off" + } + } + }, + "meta": { + "adminLabel": { + "desktop": { + "value": "Section" + } + } + } + } + }, + "divi/row": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Row" + } + } + } + } + }, + "divi/column": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Column" + } + } + }, + "decoration": { + "sizing": { + "desktop": { + "value": { + "flexType": "24_24" + } + } + } + } + } + }, + "divi/text": { + "module": { + "advanced": { + "text": { + "text": { + "desktop": { + "value": { + "color": "light" + } + } + } + } + } + } + }, + "divi/heading": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Heading" + } + } + } + }, + "title": { + "decoration": { + "font": { + "font": { + "desktop": { + "value": { + "headingLevel": "h1" + } + } + } + } + } + } + }, + "divi/button": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Button" + } + } + }, + "advanced": { + "html": { + "desktop": { + "value": { + "elementType": "a" + } + } + }, + "text": { + "text": { + "desktop": { + "value": { + "color": "light" + } + } + } + } + } + }, + "button": { + "decoration": { + "button": { + "desktop": { + "value": { + "icon": { + "enable": "on" + } + } + } + } + } + } + }, + "divi/image": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Image" + } + } + }, + "advanced": { + "spacing": { + "desktop": { + "value": { + "showBottomSpace": "on" + } + } + }, + "forceFullwidth": { + "desktop": { + "value": "off" + } + } + } + }, + "image": { + "innerContent": { + "desktop": { + "value": { + "linkTarget": "off" + } + } + }, + "advanced": { + "lightbox": { + "desktop": { + "value": "off" + } + }, + "overlay": { + "desktop": { + "value": { + "use": "off" + } + } + } + } + } + }, + "divi/blurb": { + "imageIcon": { + "innerContent": { + "desktop": { + "value": { + "useIcon": "off" + } + } + }, + "advanced": { + "color": { + "desktop": { + "value": "$variable({\"type\":\"color\",\"value\":{\"name\":\"gcid-primary-color\",\"settings\":{}}})$" + } + }, + "placement": { + "desktop": { + "value": "top" + } + } + } + }, + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Blurb" + } + } + }, + "advanced": { + "text": { + "text": { + "desktop": { + "value": { + "color": "light" + } + } + } + } + } + }, + "title": { + "decoration": { + "font": { + "font": { + "desktop": { + "value": { + "headingLevel": "h4" + } + } + } + } + } + } + }, + "divi/code": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Code" + } + } + }, + "decoration": { + "spacing": { + "desktop": { + "value": { + "margin": { + "bottom": "0px" + } + } + } + } + } + } + }, + "divi/accordion": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Accordion" + } + } + } + }, + "title": { + "decoration": { + "font": { + "font": { + "desktop": { + "value": { + "headingLevel": "h5" + } + } + } + } + } + }, + "closedToggleIcon": { + "decoration": { + "icon": { + "desktop": { + "value": { + "useSize": "off" + } + } + } + } + } + }, + "divi/accordion-item": { + "module": { + "advanced": { + "open": { + "desktop": { + "value": "off" + } + } + } + }, + "closedToggleIcon": { + "decoration": { + "icon": { + "desktop": { + "value": { + "useSize": "off" + } + } + } + } + } + }, + "divi/fullwidth-header": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Hero" + } + } + }, + "advanced": { + "html": { + "desktop": { + "value": { + "elementType": "section" + } + } + }, + "text": { + "text": { + "desktop": { + "value": { + "color": "dark", + "orientation": "left" + } + } + } + }, + "headerFullscreen": { + "desktop": { + "value": "off" + } + } + }, + "decoration": { + "background": { + "desktop": { + "value": { + "color": "$variable({\"type\":\"color\",\"value\":{\"name\":\"gcid-primary-color\",\"settings\":{}}})$" + } + } + } + } + }, + "image": { + "advanced": { + "orientation": { + "desktop": { + "value": "center" + } + } + } + }, + "title": { + "decoration": { + "font": { + "font": { + "desktop": { + "value": { + "headingLevel": "h1" + } + } + } + } + } + }, + "content": { + "advanced": { + "orientation": { + "desktop": { + "value": "center" + } + } + } + }, + "buttonOne": { + "decoration": { + "button": { + "desktop": { + "value": { + "icon": { + "enable": "on" + } + } + } + } + } + }, + "buttonTwo": { + "decoration": { + "button": { + "desktop": { + "value": { + "icon": { + "enable": "on" + } + } + } + } + } + }, + "scrollDown": { + "decoration": { + "icon": { + "desktop": { + "value": { + "show": "off", + "unicode": ";", + "type": "divi", + "weight": "400", + "size": "50px" + } + } + } + } + } + }, + "divi/group": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Group" + } + } + } + } + } + } +} diff --git a/tests/Integration/divi-metadata-fixture.php b/tests/Integration/divi-metadata-fixture.php new file mode 100644 index 0000000..02d1ef7 --- /dev/null +++ b/tests/Integration/divi-metadata-fixture.php @@ -0,0 +1,56 @@ + $definition) { + $attributes[$attrName] = ['settings' => $definition['settings'] ?? []]; + } + + $metadata[] = array_filter([ + 'name' => $module['name'], + 'title' => $module['title'] ?? '', + 'category' => $module['category'] ?? '', + 'childrenName' => $module['childrenName'] ?? null, + 'childModuleName' => $module['childModuleName'] ?? null, + 'attributes' => $attributes, + ], static fn ($value): bool => $value !== null); + $defaults[$module['name']] = ModuleRegistration::get_default_attrs($module['name'], 'default'); +} + +$file = dirname(__DIR__) . '/Fixtures/divi/metadata-sample.json'; +$json = wp_json_encode( + ['divi' => ET_BUILDER_PRODUCT_VERSION, 'metadata' => $metadata, 'defaults' => $defaults], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE +); +if (! is_string($json) || file_put_contents($file, $json . "\n") === false) { + WP_CLI::error('Cannot write ' . $file); +} +WP_CLI::success(sprintf('metadata-sample.json: %d modules, %d bytes', count($metadata), strlen($json))); diff --git a/tests/Integration/divi-shortform-check.php b/tests/Integration/divi-shortform-check.php new file mode 100644 index 0000000..2489ed7 --- /dev/null +++ b/tests/Integration/divi-shortform-check.php @@ -0,0 +1,57 @@ +catalog(); +$problems = []; +foreach (ShortformMap::types() as $type) { + $name = ModuleCatalog::normalize($type); + if (! $catalog->has($name)) { + $problems[] = $name . ': unknown module'; + continue; + } + + foreach (ShortformMap::fields($type) as $field => $spec) { + if (! $catalog->isKnownPath($name, (string) $spec['path'])) { + $problems[] = sprintf( + '%s.%s: unknown path %s (closest: %s)', + $type, + $field, + $spec['path'], + implode(', ', $catalog->suggestions($name, (string) $spec['path'])) + ); + } + } + + $child = ShortformMap::childType($type); + if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) { + $problems[] = sprintf('%s: %s is no allowed child (%s)', $type, $child, implode(', ', $catalog->childrenOf($name))); + } +} + +if ($problems !== []) { + WP_CLI::error("Short form does not match the catalog:\n" . implode("\n", $problems)); +} + +WP_CLI::success(sprintf( + '%d short-form modules match the catalog of %d modules (%.0f ms).', + count(ShortformMap::types()), + count($catalog->names()), + (microtime(true) - $started) * 1000 +)); diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php new file mode 100644 index 0000000..e8760ef --- /dev/null +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -0,0 +1,37 @@ +id()); + self::assertFalse($integration->isAvailable(), 'no Divi classes in unit tests'); + self::assertSame( + self::EXPECTED, + array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities()) + ); + + foreach ($integration->abilities() as $ability) { + self::assertSame('object', $ability->inputSchema()['type'], $ability->name()); + self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name()); + self::assertNotNull($ability->outputSchema(), $ability->name()); + } + } +} diff --git a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php new file mode 100644 index 0000000..5b1b3b6 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php @@ -0,0 +1,70 @@ +allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + + return new DiviCatalogAbility($modules); + } + + public function testListsModulesWithShortformFlag(): void + { + $result = $this->ability()->execute([]); + + $button = array_values(array_filter($result['modules'], static fn (array $m): bool => $m['name'] === 'divi/button')); + self::assertSame( + [['name' => 'divi/button', 'title' => 'Button', 'category' => 'module', 'children' => [], 'shortform' => true]], + $button + ); + $header = array_values(array_filter( + $result['modules'], + static fn (array $m): bool => $m['name'] === 'divi/fullwidth-header' + )); + self::assertFalse($header[0]['shortform']); + self::assertSame(DiviCatalogAbility::CODE_NOTE, $result['notes'][0]); + } + + public function testDetailOfOneModule(): void + { + $result = $this->ability()->execute(['module' => 'Accordion']); + + self::assertSame('divi/accordion', $result['module']['name']); + self::assertSame(['divi/accordion-item'], $result['module']['children']); + self::assertSame('accordion-item', $result['module']['item_type']); + self::assertContains('title.decoration.font.font', $result['module']['paths']); + + $button = $this->ability()->execute(['module' => 'divi/button'])['module']; + self::assertSame('linkUrl', $button['shortform']['url']['sub']); + } + + public function testDetailExposesTheAttributePathKindsForSanitizationRouting(): void + { + $button = $this->ability()->execute(['module' => 'divi/button'])['module']; + + self::assertSame('url', $button['path_kinds']['button.innerContent']); + self::assertArrayNotHasKey('no.such.path', $button['path_kinds']); + } + + public function testUnknownModuleAndPermission(): void + { + self::assertSame('lit_not_found', $this->ability()->execute(['module' => 'slider-pro'])->get_error_code()); + + Functions\when('current_user_can')->justReturn(false); + self::assertSame('lit_forbidden', $this->ability()->permission([])->get_error_code()); + self::assertTrue($this->ability()->annotations()['readonly']); + self::assertTrue($this->ability()->isDirect()); + } +} diff --git a/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php b/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php new file mode 100644 index 0000000..cd4fbd3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php @@ -0,0 +1,153 @@ + (array) ($sample['defaults'][$name] ?? []) + ); + } + + public function testKnowsModulesTitlesCategoriesAndChildren(): void + { + $catalog = self::sampleCatalog(); + + self::assertTrue($catalog->has('divi/button')); + self::assertFalse($catalog->has('button')); + self::assertSame('divi/button', ModuleCatalog::normalize(' Button ')); + self::assertSame('divi/accordion-item', ModuleCatalog::normalize('divi/accordion-item')); + self::assertSame('structure', $catalog->category('divi/section')); + self::assertSame(['divi/accordion-item'], $catalog->childrenOf('divi/accordion')); + self::assertSame(['divi/column'], $catalog->childrenOf('divi/row')); + self::assertContains( + ['name' => 'divi/heading', 'title' => 'Heading', 'category' => 'module', 'children' => []], + $catalog->summary() + ); + } + + public function testDerivesAttributePathsFromSettingsAndDefaults(): void + { + $catalog = self::sampleCatalog(); + + foreach ( + [ + 'button.innerContent', 'button.decoration.button', 'button.decoration.background', + 'module.advanced.alignment', 'module.decoration.spacing', 'module.meta.adminLabel', + 'module.advanced.text.text', + ] as $path + ) { + self::assertTrue($catalog->isKnownPath('divi/button', $path), $path); + } + + self::assertTrue($catalog->isKnownPath('divi/heading', 'title.decoration.font.font')); + self::assertFalse($catalog->isKnownPath('divi/heading', 'title.decoration.font')); + self::assertTrue($catalog->isKnownPath('divi/text', 'content.decoration.bodyFont.body.font')); + self::assertTrue($catalog->isKnownPath('divi/accordion-item', 'module.advanced.open')); + self::assertTrue($catalog->isKnownPath('divi/column', 'module.decoration.sizing')); + self::assertFalse($catalog->isKnownPath('divi/button', 'button.innerContent.text')); + self::assertSame('title.decoration.font.font', $catalog->suggestions('divi/heading', 'title.decoration.fnt.font')[0]); + } + + public function testPathIndexFromHandwrittenMetadata(): void + { + $paths = AttrPathIndex::paths( + [ + 'title' => ['settings' => [ + 'innerContent' => ['groupType' => 'group-item', 'item' => ['attrName' => 'title.innerContent']], + 'decoration' => ['font' => [], 'groupType' => 'x'], + ]], + 'module' => ['settings' => [ + 'meta' => ['meta' => []], + 'advanced' => ['text' => ['item' => ['attrName' => 'module.advanced.alignment']]], + ]], + ], + ['module' => ['advanced' => ['open' => ['desktop' => ['value' => 'off']]]], 'builderVersion' => '5'] + ); + + self::assertSame( + [ + 'module.advanced.alignment', 'module.advanced.open', 'module.advanced.text.text', + 'module.advanced.text.textShadow', 'module.meta.adminLabel', 'title.decoration.font.font', + 'title.decoration.font.textShadow', 'title.innerContent', + ], + $paths + ); + } + + public function testEveryShortformPathIsKnownForTheSampledModules(): void + { + $catalog = self::sampleCatalog(); + foreach (['text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion-item'] as $type) { + foreach (ShortformMap::fields($type) as $field => $spec) { + self::assertTrue( + $catalog->isKnownPath('divi/' . $type, $spec['path']), + sprintf('%s.%s → %s', $type, $field, $spec['path']) + ); + } + } + } + + public function testShortformTableIsConsistent(): void + { + $kinds = ['text', 'html', 'code', 'url', 'media', 'enum', 'onoff', 'level', 'number', 'ids', 'color']; + foreach (ShortformMap::types() as $type) { + $fields = ShortformMap::fields($type); + $primary = ShortformMap::primary($type); + self::assertTrue($primary === null || isset($fields[$primary]), $type . ' primary'); + $child = ShortformMap::childType($type); + self::assertTrue($child === null || ShortformMap::has($child), $type . ' child'); + foreach ($fields as $field => $spec) { + self::assertContains($spec['kind'], $kinds, $type . '.' . $field); + self::assertSame(1, preg_match('/^[a-zA-Z]+(\.[a-zA-Z]+)+$/', $spec['path']), $type . '.' . $field); + self::assertSame($spec['kind'] === 'media', isset($spec['keys']), $type . '.' . $field); + self::assertSame($spec['kind'] === 'enum', isset($spec['values']), $type . '.' . $field); + } + } + + self::assertSame( + ['text' => 'Termin', 'url' => '/kontakt'], + ShortformMap::values('button', ['button' => ['innerContent' => ['desktop' => ['value' => [ + 'text' => 'Termin', 'linkUrl' => '/kontakt', + ]]]]]) + ); + } + + public function testDerivesAttributePathKindsFromComponentMetadataWherePossible(): void + { + $catalog = self::sampleCatalog(); + + self::assertSame('richtext', $catalog->pathKind('divi/text', 'content.innerContent')); + self::assertSame('text', $catalog->pathKind('divi/heading', 'title.innerContent')); + self::assertSame('code', $catalog->pathKind('divi/code', 'content.innerContent')); + self::assertSame('url', $catalog->pathKind('divi/button', 'button.innerContent')); + self::assertSame('text', $catalog->pathKind('divi/button', 'module.meta.adminLabel')); + // Structural/style groups (fonts, backgrounds, …) carry no single scalar component in + // Divi's metadata; 2b falls back to key-name routing for them (spec §5.6, ruling R3). + self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.decoration.background')); + self::assertSame('unknown', $catalog->pathKind('divi/button', 'no.such.path')); + + // Every kind in the module's kinds() map is a string, keyed by a known attribute path. + $paths = $catalog->paths('divi/heading'); + foreach ($catalog->kinds('divi/heading') as $path => $kind) { + self::assertIsString($kind); + self::assertContains($path, $paths); + } + } +} From 02ae76d4f32d66346e5b25b6da35c644631a1ea2 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:00:19 +0200 Subject: [PATCH 094/185] fix: stop guessing a kind for composite Divi attribute paths Review fix round 1 on the lit/divi-catalog task: - AttrPathIndex::collectKinds() no longer records a kind from a component.type === "group" node (a composite of several sub-kinds, e.g. button.innerContent = text + link + target), and kinds() now drops any attrName with more than one distinct candidate kind instead of last-write-wins (e.g. imageIcon.innerContent had onoff + icon + image sub-fields sharing one attrName). Both previously produced a wrong-but-present kind, which is more dangerous for 2b's sanitization routing than "unknown". Verified against the committed blurb/button/fullwidth-header fixture entries and live against the full 100-module catalog. - AttrPathIndex::fromDefaults() now builds on AttrMap::flatten() instead of a parallel walker. - ModuleCatalog::shortName() replaces the duplicated substr($name, strlen('divi/')) in DiviCatalogAbility. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../Divi/Schema/AttrPathIndex.php | 59 ++++++++++--------- .../Divi/Schema/DiviCatalogAbility.php | 5 +- .../Divi/Schema/ModuleCatalog.php | 6 ++ .../Divi/Schema/DiviCatalogAbilityTest.php | 4 +- .../Divi/Schema/ModuleCatalogTest.php | 19 +++++- 5 files changed, 62 insertions(+), 31 deletions(-) diff --git a/src/Integrations/Divi/Schema/AttrPathIndex.php b/src/Integrations/Divi/Schema/AttrPathIndex.php index 2a33866..896e7a3 100644 --- a/src/Integrations/Divi/Schema/AttrPathIndex.php +++ b/src/Integrations/Divi/Schema/AttrPathIndex.php @@ -93,7 +93,7 @@ final class AttrPathIndex self::attrNames($settings, $paths); } - self::fromDefaults($defaults, '', $paths); + self::fromDefaults($defaults, $paths); $list = array_map('strval', array_keys($paths)); sort($list); @@ -102,18 +102,30 @@ final class AttrPathIndex /** * Known attribute path => value kind (text/richtext/code/url/image/color/enum/onoff/number/ - * list/icon), wherever Divi's metadata names an unambiguous field component for the path. - * Not every known path (see paths()) resolves to a kind; callers default to "unknown". + * list/icon), wherever Divi's metadata names exactly one unambiguous field component for the + * path. A path is left out (callers default to "unknown") when Divi's own component is a + * "group" (a composite of several sub-kinds, e.g. button.innerContent = text + link + target) + * or when several distinct "field" components share the same attrName with different kinds + * (e.g. imageIcon.innerContent = onoff useIcon + icon icon + image src) – a wrong-but-present + * kind would be more dangerous for 2b's sanitization routing than an honest "unknown". * * @param array $attributes Metadata "attributes" of one module. * @return array */ public static function kinds(array $attributes): array { - $kinds = ['module.meta.adminLabel' => 'text']; + $candidates = []; foreach ($attributes as $definition) { $settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : []; - self::collectKinds($settings, $kinds); + self::collectKinds($settings, $candidates); + } + + $kinds = ['module.meta.adminLabel' => 'text']; + foreach ($candidates as $attrName => $found) { + $unique = array_unique($found); + if (count($unique) === 1) { + $kinds[$attrName] = $unique[0]; + } } return $kinds; @@ -170,46 +182,39 @@ final class AttrPathIndex } /** - * @param array $node - * @param array $kinds + * Collects every (attrName => kind) candidate found, without yet resolving conflicts – a + * "group" component (a composite of several sub-kinds) never contributes a candidate. + * + * @param array $node + * @param array> $candidates */ - private static function collectKinds(array $node, array &$kinds): void + private static function collectKinds(array $node, array &$candidates): void { $attrName = $node['attrName'] ?? null; - $componentName = $node['component']['name'] ?? null; + $component = is_array($node['component'] ?? null) ? $node['component'] : []; + $componentName = $component['name'] ?? null; if ( is_string($attrName) && preg_match(self::ATTR_NAME_PATTERN, $attrName) + && ($component['type'] ?? null) === 'field' && is_string($componentName) && isset(self::COMPONENT_KINDS[$componentName]) ) { - $kinds[$attrName] = self::COMPONENT_KINDS[$componentName]; + $candidates[$attrName][] = self::COMPONENT_KINDS[$componentName]; } foreach ($node as $value) { if (is_array($value)) { - self::collectKinds($value, $kinds); + self::collectKinds($value, $candidates); } } } - /** - * @param array $attrs - * @param array $paths - */ - private static function fromDefaults(array $attrs, string $prefix, array &$paths): void + /** @param array $attrs */ + private static function fromDefaults(array $attrs, array &$paths): void { - foreach ($attrs as $key => $value) { - if ($prefix === '' && in_array($key, AttrMap::META_KEYS, true)) { - continue; - } - - $path = $prefix === '' ? (string) $key : $prefix . '.' . $key; - if (AttrMap::isBreakpointMap($value)) { - $paths[$path] = true; - } elseif (is_array($value)) { - self::fromDefaults($value, $path, $paths); - } + foreach (array_keys(AttrMap::flatten($attrs)) as $path) { + $paths[$path] = true; } } } diff --git a/src/Integrations/Divi/Schema/DiviCatalogAbility.php b/src/Integrations/Divi/Schema/DiviCatalogAbility.php index 2451e24..263a725 100644 --- a/src/Integrations/Divi/Schema/DiviCatalogAbility.php +++ b/src/Integrations/Divi/Schema/DiviCatalogAbility.php @@ -119,7 +119,8 @@ final class DiviCatalogAbility implements Ability if (! is_string($input['module'] ?? null) || $input['module'] === '') { $modules = []; foreach ($catalog->summary() as $module) { - $modules[] = $module + ['shortform' => ShortformMap::has(substr($module['name'], strlen('divi/')))]; + $shortName = ModuleCatalog::shortName($module['name']); + $modules[] = $module + ['shortform' => ShortformMap::has($shortName)]; } return ['modules' => $modules, 'notes' => $notes]; @@ -133,7 +134,7 @@ final class DiviCatalogAbility implements Ability ); } - $short = substr($name, strlen('divi/')); + $short = ModuleCatalog::shortName($name); $item = ShortformMap::childType($short); return [ diff --git a/src/Integrations/Divi/Schema/ModuleCatalog.php b/src/Integrations/Divi/Schema/ModuleCatalog.php index 47b7083..d94066b 100644 --- a/src/Integrations/Divi/Schema/ModuleCatalog.php +++ b/src/Integrations/Divi/Schema/ModuleCatalog.php @@ -46,6 +46,12 @@ final class ModuleCatalog return str_contains($type, '/') ? $type : 'divi/' . $type; } + /** "divi/button" → "button" (inverse of normalize(), for ShortformMap's short names). */ + public static function shortName(string $name): string + { + return str_starts_with($name, 'divi/') ? substr($name, strlen('divi/')) : $name; + } + public function has(string $name): bool { return isset($this->modules[$name]); diff --git a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php index 5b1b3b6..ebcb333 100644 --- a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php @@ -54,8 +54,10 @@ final class DiviCatalogAbilityTest extends TestCase { $button = $this->ability()->execute(['module' => 'divi/button'])['module']; - self::assertSame('url', $button['path_kinds']['button.innerContent']); + self::assertSame('text', $button['path_kinds']['module.meta.adminLabel']); self::assertArrayNotHasKey('no.such.path', $button['path_kinds']); + // Composite ("group") paths are left out rather than guessing a single kind. + self::assertArrayNotHasKey('button.innerContent', $button['path_kinds']); } public function testUnknownModuleAndPermission(): void diff --git a/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php b/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php index cd4fbd3..2e5e2d0 100644 --- a/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php +++ b/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php @@ -136,12 +136,13 @@ final class ModuleCatalogTest extends TestCase self::assertSame('richtext', $catalog->pathKind('divi/text', 'content.innerContent')); self::assertSame('text', $catalog->pathKind('divi/heading', 'title.innerContent')); self::assertSame('code', $catalog->pathKind('divi/code', 'content.innerContent')); - self::assertSame('url', $catalog->pathKind('divi/button', 'button.innerContent')); self::assertSame('text', $catalog->pathKind('divi/button', 'module.meta.adminLabel')); // Structural/style groups (fonts, backgrounds, …) carry no single scalar component in // Divi's metadata; 2b falls back to key-name routing for them (spec §5.6, ruling R3). self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.decoration.background')); self::assertSame('unknown', $catalog->pathKind('divi/button', 'no.such.path')); + // divi/image has no explicit attrName for its innerContent sub-fields at all. + self::assertSame('unknown', $catalog->pathKind('divi/image', 'image.innerContent')); // Every kind in the module's kinds() map is a string, keyed by a known attribute path. $paths = $catalog->paths('divi/heading'); @@ -150,4 +151,20 @@ final class ModuleCatalogTest extends TestCase self::assertContains($path, $paths); } } + + public function testLeavesCompositeAttributePathsUnknownRatherThanGuessing(): void + { + $catalog = self::sampleCatalog(); + + // button.innerContent's only attrName-annotated node is the "link" sub-group itself + // (component.type "group": link text + linkUrl + linkTarget) – not a single scalar kind. + self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.innerContent')); + + // imageIcon.innerContent carries three "field" components under the same attrName with + // different kinds (useIcon: onoff, icon: icon, src: image) – a genuine conflict. + self::assertSame('unknown', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent')); + + // fullwidth-header's scrollDown.decoration.icon is the same pattern (onoff/icon/color/number). + self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'scrollDown.decoration.icon')); + } } From a54a337e0b170b39bdada7193f548ea52957348b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:05:37 +0200 Subject: [PATCH 095/185] =?UTF-8?q?feat:=20read=20the=20Divi=20design=20sy?= =?UTF-8?q?stem=20live=20=E2=80=93=20lit/divi-design-system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 2 +- src/Integrations/Divi/Design/DesignSystem.php | 249 ++++++++++++++++++ .../Divi/Design/DesignSystemReader.php | 35 +++ .../Divi/Design/DiviDesignSystemAbility.php | 132 ++++++++++ src/Integrations/Divi/DiviIntegration.php | 5 + .../Divi/Design/DesignSystemTest.php | 95 +++++++ .../Design/DiviDesignSystemAbilityTest.php | 43 +++ .../Integrations/Divi/DiviIntegrationTest.php | 1 + 8 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Divi/Design/DesignSystem.php create mode 100644 src/Integrations/Divi/Design/DesignSystemReader.php create mode 100644 src/Integrations/Divi/Design/DiviDesignSystemAbility.php create mode 100644 tests/Unit/Integrations/Divi/Design/DesignSystemTest.php create mode 100644 tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index ede5509..a6ba907 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,7 +19,7 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`, `lit-divi-design-system`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Integrations/Divi/Design/DesignSystem.php b/src/Integrations/Divi/Design/DesignSystem.php new file mode 100644 index 0000000..ceaa554 --- /dev/null +++ b/src/Integrations/Divi/Design/DesignSystem.php @@ -0,0 +1,249 @@ + 'gcid-primary-color', + 'secondary' => 'gcid-secondary-color', + 'heading' => 'gcid-heading-color', + 'body' => 'gcid-body-color', + 'link' => 'gcid-link-color', + ]; + + /** Theme fonts as Divi stores them among the global font variables. */ + public const FONT_VARIABLES = ['heading' => '--et_global_heading_font', 'body' => '--et_global_body_font']; + + /** Theme option => Divi's default, read from the et_divi option. */ + public const SIZE_OPTIONS = [ + 'body_font_size' => '14', + 'body_header_size' => '30', + 'body_font_height' => '1.7', + 'body_header_height' => '1', + 'content_width' => '1080', + 'gutter_width' => '3', + ]; + + /** + * @param list $colors + * @param list $variables + * @param list $presets + * @param array{heading: string, body: string} $fonts + * @param array $sizes + */ + public function __construct( + private array $colors, + private array $variables, + private array $presets, + private array $fonts, + private array $sizes + ) { + } + + /** + * @param array $colors GlobalData::get_global_colors() + * @param array $variables GlobalData::get_global_variables() + * @param array $presets GlobalPreset::get_data() + * @param array $options get_option('et_divi') + */ + public static function fromDivi(array $colors, array $variables, array $presets, array $options): self + { + // Divi returns some groups as stdClass (e.g. every variable type) – work on plain arrays. + $colors = (array) self::plain($colors); + $variables = (array) self::plain($variables); + $presets = (array) self::plain($presets); + $colorList = []; + foreach ($colors as $id => $color) { + if (is_array($color) && ($color['status'] ?? 'active') === 'active' && is_string($color['color'] ?? null)) { + $colorList[] = [ + 'id' => (string) $id, + 'label' => is_string($color['label'] ?? null) ? $color['label'] : (string) $id, + 'value' => $color['color'], + ]; + } + } + + $variableList = []; + $fonts = ['heading' => '', 'body' => '']; + foreach ($variables as $type => $items) { + foreach (is_array($items) ? $items : [] as $id => $item) { + if (! is_array($item) || ($item['status'] ?? 'active') !== 'active') { + continue; + } + + $value = is_scalar($item['value'] ?? null) ? (string) $item['value'] : ''; + $font = array_search((string) $id, self::FONT_VARIABLES, true); + if ($font !== false) { + $fonts[$font] = $value; + continue; + } + + $variableList[] = [ + 'id' => (string) $id, + 'type' => (string) $type, + 'label' => is_string($item['label'] ?? null) ? $item['label'] : (string) $id, + 'value' => $value, + ]; + } + } + + $sizes = []; + foreach (self::SIZE_OPTIONS as $option => $default) { + $value = $options[$option] ?? null; + $sizes[$option] = is_scalar($value) && (string) $value !== '' ? (string) $value : $default; + } + + return new self($colorList, $variableList, self::presetList($presets), $fonts, $sizes); + } + + /** @return list */ + public function colors(): array + { + return $this->colors; + } + + /** @return list */ + public function variables(): array + { + return $this->variables; + } + + /** @return list */ + public function presets(): array + { + return $this->presets; + } + + /** @return array{heading: string, body: string} */ + public function fonts(): array + { + return $this->fonts; + } + + /** @return array */ + public function sizes(): array + { + return $this->sizes; + } + + /** gcid of an alias (primary …), an ID or a label (case-insensitive); null when unknown. */ + public function colorId(string $name): ?string + { + $key = mb_strtolower(trim($name)); + $alias = self::COLOR_ALIASES[$key] ?? null; + foreach ($this->colors as $color) { + if ($color['id'] === $alias || $color['id'] === trim($name) || mb_strtolower($color['label']) === $key) { + return $color['id']; + } + } + + return null; + } + + /** @return array{id: string, type: string, label: string, value: string}|null By label or ID. */ + public function variable(string $name): ?array + { + $key = mb_strtolower(trim($name)); + foreach ($this->variables as $variable) { + if ($variable['id'] === trim($name) || mb_strtolower($variable['label']) === $key) { + return $variable; + } + } + + return null; + } + + /** Module preset ID by name or ID for a full module name ("divi/button"); null when unknown. */ + public function modulePresetId(string $module, string $name): ?string + { + $key = mb_strtolower(trim($name)); + foreach ($this->presets as $preset) { + if ( + $preset['kind'] === 'module' + && $preset['module'] === $module + && ($preset['id'] === trim($name) || mb_strtolower($preset['name']) === $key) + ) { + return $preset['id']; + } + } + + return null; + } + + /** @return list Names of the module presets of $module. */ + public function modulePresetNames(string $module): array + { + $names = []; + foreach ($this->presets as $preset) { + if ($preset['kind'] === 'module' && $preset['module'] === $module) { + $names[] = $preset['name']; + } + } + + return $names; + } + + /** @return array Preset ID => name (all presets). */ + public function presetNames(): array + { + $names = []; + foreach ($this->presets as $preset) { + $names[$preset['id']] = $preset['name']; + } + + return $names; + } + + /** Placeholder that stands for a color in new content (spec §5.4). */ + public static function colorPlaceholder(string $id, string $label): string + { + $alias = array_search($id, self::COLOR_ALIASES, true); + + return '{{color:' . ($alias !== false ? $alias : $label) . '}}'; + } + + private static function plain(mixed $value): mixed + { + if (is_object($value)) { + $value = get_object_vars($value); + } + + return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; + } + + /** + * @param array $data GlobalPreset::get_data(): + * {module|group: {name: {default, items: {id: item}}}} + * @return list + */ + private static function presetList(array $data): array + { + $list = []; + foreach (['module', 'group'] as $kind) { + foreach (is_array($data[$kind] ?? null) ? $data[$kind] : [] as $module => $record) { + $default = is_array($record) && is_string($record['default'] ?? null) ? $record['default'] : ''; + $items = is_array($record) && is_array($record['items'] ?? null) ? $record['items'] : []; + foreach ($items as $id => $item) { + $list[] = [ + 'kind' => $kind, + 'module' => (string) $module, + 'id' => (string) $id, + 'name' => is_array($item) && is_string($item['name'] ?? null) ? $item['name'] : (string) $id, + 'default' => (string) $id === $default, + ]; + } + } + } + + return $list; + } +} diff --git a/src/Integrations/Divi/Design/DesignSystemReader.php b/src/Integrations/Divi/Design/DesignSystemReader.php new file mode 100644 index 0000000..f23dfa1 --- /dev/null +++ b/src/Integrations/Divi/Design/DesignSystemReader.php @@ -0,0 +1,35 @@ +designSystem === null) { + $colors = GlobalData::get_global_colors(); + $variables = GlobalData::get_global_variables(); + $presets = GlobalPreset::get_data(); + $options = get_option('et_divi', []); + $this->designSystem = DesignSystem::fromDivi( + is_array($colors) ? $colors : [], + is_array($variables) ? $variables : [], + is_array($presets) ? $presets : [], + is_array($options) ? $options : [] + ); + } + + return $this->designSystem; + } +} diff --git a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php new file mode 100644 index 0000000..76c30d5 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php @@ -0,0 +1,132 @@ + 'object', 'properties' => [], 'additionalProperties' => false]; + } + + public function outputSchema(): ?array + { + $entry = static fn (array $properties): array => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => $properties, + 'required' => array_keys($properties), + ], + ]; + + return [ + 'type' => 'object', + 'properties' => [ + 'colors' => $entry([ + 'id' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'value' => ['type' => 'string'], + 'placeholder' => ['type' => 'string'], + ]), + 'variables' => $entry([ + 'id' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'value' => ['type' => 'string'], + 'placeholder' => ['type' => 'string'], + ]), + 'fonts' => [ + 'type' => 'object', + 'properties' => ['heading' => ['type' => 'string'], 'body' => ['type' => 'string']], + 'required' => ['heading', 'body'], + ], + 'sizes' => ['type' => 'object'], + 'presets' => $entry([ + 'kind' => ['type' => 'string'], + 'module' => ['type' => 'string'], + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'default' => ['type' => 'boolean'], + ]), + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['colors', 'variables', 'fonts', 'sizes', 'presets', 'notes'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_posts') + ? true + : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); + } + + public function execute(array $input): mixed + { + $design = $this->reader->read(); + + return [ + 'colors' => array_map( + static fn (array $color): array => $color + [ + 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), + ], + $design->colors() + ), + 'variables' => array_map( + static fn (array $variable): array => $variable + + ['placeholder' => '{{var:' . $variable['label'] . '}}'], + $design->variables() + ), + 'fonts' => $design->fonts(), + 'sizes' => $design->sizes(), + 'presets' => $design->presets(), + 'notes' => [ + 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ], + ]; + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 30cde14..eb351d2 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; +use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -16,10 +18,12 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; final class DiviIntegration implements Integration { private DiviModules $modules; + private DesignSystemReader $design; public function __construct(private ContentSchema $schema) { $this->modules = new DiviModules(); + $this->design = new DesignSystemReader(); } public function id(): string @@ -36,6 +40,7 @@ final class DiviIntegration implements Integration { return [ new DiviCatalogAbility($this->modules), + new DiviDesignSystemAbility($this->design), ]; } } diff --git a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php new file mode 100644 index 0000000..34f78e3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php @@ -0,0 +1,95 @@ + ['color' => '#2ea3f2', 'label' => 'Primärfarbe', 'status' => 'active'], + 'gcid-body-color' => ['color' => '#666666', 'label' => 'Fließtextfarbe', 'status' => 'active'], + 'gcid-a1b2' => ['color' => '#0b3d59', 'label' => 'Nachtblau', 'status' => 'active'], + 'gcid-old' => ['color' => '#ff0000', 'label' => 'Alt', 'status' => 'inactive'], + ], + [ + 'numbers' => ['gvid-r1' => ['id' => 'gvid-r1', 'label' => 'Radius', 'value' => '8px', 'status' => 'active']], + 'strings' => ['gvid-s1' => ['id' => 'gvid-s1', 'label' => 'Telefon', 'value' => '0221 123', 'status' => 'active']], + // Divi returns every variable type as stdClass (verified in DDEV). + 'fonts' => (object) [ + '--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', 'value' => 'Open Sans', 'status' => 'active'], + '--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', 'value' => 'Lato', 'status' => 'active'], + ], + 'links' => new \stdClass(), + ], + [ + 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ], + ['body_font_size' => '16'] + ); + } + + public function testReadsColorsVariablesFontsSizesAndPresets(): void + { + $design = self::sample(); + + self::assertSame( + ['id' => 'gcid-a1b2', 'label' => 'Nachtblau', 'value' => '#0b3d59'], + $design->colors()[2] + ); + self::assertCount(3, $design->colors()); + self::assertSame(['heading' => 'Open Sans', 'body' => 'Lato'], $design->fonts()); + self::assertSame( + [['id' => 'gvid-r1', 'type' => 'numbers', 'label' => 'Radius', 'value' => '8px'], + ['id' => 'gvid-s1', 'type' => 'strings', 'label' => 'Telefon', 'value' => '0221 123']], + $design->variables() + ); + self::assertSame('16', $design->sizes()['body_font_size']); + self::assertSame('30', $design->sizes()['body_header_size']); + self::assertSame( + ['kind' => 'module', 'module' => 'divi/button', 'id' => 'p-1', 'name' => 'Standard', 'default' => true], + $design->presets()[0] + ); + self::assertSame(['p-1' => 'Standard', 'p-2' => 'CTA primär', 'g-1' => 'Fließtext groß'], $design->presetNames()); + } + + public function testLooksUpByAliasLabelOrId(): void + { + $design = self::sample(); + + self::assertSame('gcid-primary-color', $design->colorId('primary')); + self::assertSame('gcid-a1b2', $design->colorId('nachtblau')); + self::assertSame('gcid-a1b2', $design->colorId('gcid-a1b2')); + self::assertNull($design->colorId('secondary')); + self::assertNull($design->colorId('Alt')); + self::assertSame('gvid-s1', $design->variable('telefon')['id'] ?? null); + self::assertSame('p-2', $design->modulePresetId('divi/button', 'cta PRIMÄR')); + self::assertNull($design->modulePresetId('divi/text', 'Standard')); + self::assertSame(['Standard', 'CTA primär'], $design->modulePresetNames('divi/button')); + self::assertSame('{{color:primary}}', DesignSystem::colorPlaceholder('gcid-primary-color', 'Primärfarbe')); + self::assertSame('{{color:Nachtblau}}', DesignSystem::colorPlaceholder('gcid-a1b2', 'Nachtblau')); + } + + public function testToleratesEmptyOrOddInput(): void + { + $design = DesignSystem::fromDivi([], ['fonts' => 'x'], ['module' => 'y'], []); + + self::assertSame([], $design->colors()); + self::assertSame(['heading' => '', 'body' => ''], $design->fonts()); + self::assertSame([], $design->presets()); + self::assertSame('14', $design->sizes()['body_font_size']); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php new file mode 100644 index 0000000..a26ed17 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php @@ -0,0 +1,43 @@ +allows('read')->andReturn(DesignSystemTest::sample()); + Functions\when('current_user_can')->justReturn(true); + $ability = new DiviDesignSystemAbility($reader); + + self::assertTrue($ability->permission([])); + $result = $ability->execute([]); + + self::assertSame('{{color:primary}}', $result['colors'][0]['placeholder']); + self::assertSame('{{color:Nachtblau}}', $result['colors'][2]['placeholder']); + self::assertSame('{{var:Telefon}}', $result['variables'][1]['placeholder']); + self::assertSame(['heading' => 'Open Sans', 'body' => 'Lato'], $result['fonts']); + self::assertSame('CTA primär', $result['presets'][1]['name']); + self::assertStringContainsString('{{font:heading}}', $result['notes'][0]); + self::assertTrue($ability->isDirect()); + self::assertSame(['colors', 'variables', 'fonts', 'sizes', 'presets', 'notes'], $ability->outputSchema()['required']); + } + + public function testNeedsEditPosts(): void + { + Functions\when('current_user_can')->justReturn(false); + + $result = (new DiviDesignSystemAbility(Mockery::mock(DesignSystemReader::class)))->permission([]); + + self::assertSame('lit_forbidden', $result->get_error_code()); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index e8760ef..04ca224 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -15,6 +15,7 @@ final class DiviIntegrationTest extends TestCase /** Grows with every task that adds Divi abilities – keep in the order of abilities(). */ private const EXPECTED = [ 'lit/divi-catalog', + 'lit/divi-design-system', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void From d27504a750e5dcea136c21af7a2406b9cc612d51 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:13:58 +0200 Subject: [PATCH 096/185] feat: lit/divi-outline and lit/divi-node-get with tree_rev Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 3 +- src/Integrations/Divi/DiviIntegration.php | 7 + .../Divi/Pages/DiviNodeGetAbility.php | 144 ++++++++++++++++ .../Divi/Pages/DiviOutlineAbility.php | 160 ++++++++++++++++++ src/Integrations/Divi/Pages/DiviPage.php | 43 +++++ .../Divi/Pages/DiviPageLoader.php | 102 +++++++++++ .../Integrations/Divi/DiviIntegrationTest.php | 2 + .../Divi/Pages/DiviPageLoaderTest.php | 118 +++++++++++++ .../Divi/Pages/DiviReadAbilitiesTest.php | 99 +++++++++++ 9 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Divi/Pages/DiviNodeGetAbility.php create mode 100644 src/Integrations/Divi/Pages/DiviOutlineAbility.php create mode 100644 src/Integrations/Divi/Pages/DiviPage.php create mode 100644 src/Integrations/Divi/Pages/DiviPageLoader.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index a6ba907..95b9a6d 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,7 +19,8 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`, `lit-divi-design-system`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-outline`, +`lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index eb351d2..52b66fa 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -38,7 +41,11 @@ final class DiviIntegration implements Integration public function abilities(): array { + $loader = new DiviPageLoader($this->schema); + return [ + new DiviOutlineAbility($loader, $this->design), + new DiviNodeGetAbility($loader, $this->design), new DiviCatalogAbility($this->modules), new DiviDesignSystemAbility($this->design), ]; diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php new file mode 100644 index 0000000..be8400a --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -0,0 +1,144 @@ + breakpoint map – plus the current short-form values. + */ +final class DiviNodeGetAbility implements Ability +{ + public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design) + { + } + + public function name(): string + { + return 'lit/divi-node-get'; + } + + public function label(): string + { + return 'Divi-Knoten lesen'; + } + + public function description(): string + { + return 'Liest einen Knoten der Divi-Seite vollständig: attrs als Attributpfad → Werte je Breakpoint ' + . '(Vollform, z. B. "title.decoration.font.font": {"desktop": {"value": {…}}}), die Kurzform-Werte, ' + . 'Presets und die IDs der Kinder. Pfad-IDs aus lit-divi-outline.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'id' => ['type' => 'string', 'description' => 'Pfad-ID aus lit-divi-outline, z. B. "s0.r0.c1.m2".'], + ], + 'required' => ['post_id', 'id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'tree_rev' => ['type' => 'string'], + 'id' => ['type' => 'string'], + 'parent' => ['type' => ['string', 'null']], + 'type' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'editable' => ['type' => 'boolean'], + 'attrs' => ['type' => 'object'], + 'shortform' => ['type' => 'object'], + 'presets' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'string'], 'name' => ['type' => 'string']], + 'required' => ['id', 'name'], + ], + ], + 'builder_version' => ['type' => ['string', 'null']], + 'children' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'tree_rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $page = $this->loader->load($input['post_id'] ?? null, false); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $page = $this->loader->load($input['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + return $page; + } + + $id = is_string($input['id'] ?? null) ? $input['id'] : ''; + if (preg_match(NodeIndex::ID_PATTERN, $id) !== 1) { + return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']); + } + + $block = $page->index->find($id); + if ($block === null) { + return DiviPageLoader::unknownNode($id, $page->post); + } + + $names = $this->design->read()->presetNames(); + $editable = $block->isDivi() && ! $block->isOpaque(); + $version = $block->attrs()['builderVersion'] ?? null; + + return [ + 'post_id' => (int) $page->post->ID, + 'tree_rev' => $page->treeRev(), + 'id' => $id, + 'parent' => $page->index->parentId($block), + 'type' => $block->name(), + 'label' => NodeSummary::label($block), + 'editable' => $editable, + 'attrs' => JsonMap::of(AttrMap::flatten($block->attrs())), + 'shortform' => JsonMap::of(ShortformMap::values($block->shortName(), $block->attrs())), + 'presets' => array_map( + static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''], + NodeSummary::presetIds($block) + ), + 'builder_version' => is_string($version) ? $version : null, + 'children' => $editable + ? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children()))) + : [], + ]; + } +} diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php new file mode 100644 index 0000000..2587483 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -0,0 +1,160 @@ + 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'id' => ['type' => 'string', 'description' => 'Nur diesen Teilbaum, z. B. "s1".'], + 'depth' => [ + 'type' => 'integer', + 'minimum' => 0, + 'maximum' => 10, + 'description' => 'Maximale Tiefe unterhalb des Startknotens (0 = nur der Knoten).', + ], + ], + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'status' => ['type' => 'string'], + 'tree_rev' => ['type' => 'string'], + 'rev' => ['type' => 'string'], + 'outline' => ['type' => 'string'], + 'nodes' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'excerpt' => ['type' => 'string'], + 'info' => ['type' => 'string'], + 'presets' => ['type' => 'array', 'items' => ['type' => 'string']], + 'children' => ['type' => 'integer'], + 'editable' => ['type' => 'boolean'], + 'depth' => ['type' => 'integer'], + ], + 'required' => ['id', 'type', 'label', 'excerpt', 'editable', 'depth'], + ], + ], + 'edit_url' => ['type' => 'string'], + 'hints' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'hints'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $page = $this->loader->load($input['post_id'] ?? null, false); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $page = $this->loader->load($input['post_id'] ?? null, false); + if ($page instanceof WP_Error) { + return $page; + } + + $rootId = isset($input['id']) && is_string($input['id']) && $input['id'] !== '' ? $input['id'] : null; + if ($rootId !== null && $page->index->find($rootId) === null) { + return DiviPageLoader::unknownNode($rootId, $page->post); + } + + $outline = new Outline($this->design->read()->presetNames()); + $rows = $outline->rows($page->index, $rootId, isset($input['depth']) ? (int) $input['depth'] : null); + + return [ + 'post_id' => (int) $page->post->ID, + 'title' => (string) $page->post->post_title, + 'status' => (string) $page->post->post_status, + 'tree_rev' => $page->treeRev(), + 'rev' => Rev::ofPost($page->post), + 'outline' => $outline->text($rows), + 'nodes' => $rows, + 'edit_url' => $page->editUrl(), + 'hints' => $this->hints($page, $rows), + ]; + } + + /** + * @param list> $rows + * @return list + */ + private function hints(DiviPage $page, array $rows): array + { + $hints = []; + if ($rows === []) { + $hints[] = $page->isDiviLayout() + ? 'Die Seite hat noch keinen Divi-Inhalt.' + : 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.'; + } + + if (in_array(false, array_column($rows, 'editable'), true)) { + $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; ' + . 'ihren Inhalt nur im Divi-Builder bearbeiten.'; + } + + return $hints; + } +} diff --git a/src/Integrations/Divi/Pages/DiviPage.php b/src/Integrations/Divi/Pages/DiviPage.php new file mode 100644 index 0000000..6ac6b67 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviPage.php @@ -0,0 +1,43 @@ +post->post_content); + } + + /** Whether post_content is a Divi 5 layout (or empty, so a first section can be added). */ + public function isDiviLayout(): bool + { + $content = (string) $this->post->post_content; + + return trim($content) === '' || str_contains($content, ''); + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $result = (new DiviPageLoader(self::schema()))->load(7, false); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertStringContainsString('kein gültiges Block-Markup', $result->get_error_message()); + self::assertStringContainsString('divi parse of post 7 failed', (string) file_get_contents($log)); + unlink($log); + } + + public function testTrashedEntriesCanBeReadButNotWritten(): void + { + self::givenPost(self::fixture('synthetic-basic.html'), ['post_status' => 'trash']); + $loader = new DiviPageLoader(self::schema()); + + self::assertInstanceOf(DiviPage::class, $loader->load(7, false)); + self::assertSame('lit_validation_failed', $loader->load(7, true)->get_error_code()); + } + + public function testWritableChecks(): void + { + $loader = new DiviPageLoader(self::schema(false)); + self::givenPost(''); + $empty = $loader->load(7, true); + self::assertSame('lit_unsupported', $loader->writable($empty)->get_error_code()); + + $loader = new DiviPageLoader(self::schema()); + self::givenPost("\n

Gutenberg

\n"); + $gutenberg = $loader->load(7, true); + self::assertFalse($gutenberg->isDiviLayout()); + self::assertStringContainsString('kein Divi-Layout', $loader->writable($gutenberg)->get_error_message()); + + self::givenPost('', ['post_status' => 'future', 'post_date_gmt' => '2020-01-01 00:00:00']); + $missed = $loader->load(7, true); + self::assertTrue($missed->isDiviLayout()); + self::assertSame('lit_validation_failed', $loader->writable($missed)->get_error_code()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php new file mode 100644 index 0000000..76b52a5 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -0,0 +1,99 @@ +allows('read')->andReturn(DesignSystemTest::sample()); + + return $reader; + } + + public function testOutlineListsNodesWithTreeRevAndPostRev(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + self::assertTrue($ability->permission(['post_id' => 7])); + $result = $ability->execute(['post_id' => 7]); + + self::assertSame(substr(hash('sha256', $content), 0, 12), $result['tree_rev']); + self::assertSame(12, strlen($result['rev'])); + self::assertNotSame($result['tree_rev'], $result['rev']); + self::assertCount(16, $result['nodes']); + self::assertStringStartsWith('s0 section "Hero"', $result['outline']); + self::assertSame([], $result['hints']); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } + + public function testOutlineOfASubtreeAndUnknownNode(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + $result = $ability->execute(['post_id' => 7, 'id' => 's1', 'depth' => 1]); + self::assertSame(['s1', 's1.r0'], array_column($result['nodes'], 'id')); + + $missing = $ability->execute(['post_id' => 7, 'id' => 's7']); + self::assertSame('lit_not_found', $missing->get_error_code()); + self::assertStringContainsString('tree_rev', $missing->get_error_message()); + } + + public function testOutlineHintsForEmptyAndReadOnlyContent(): void + { + DiviPageLoaderTest::givenPost(''); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']); + + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html')); + $hints = $ability->execute(['post_id' => 7])['hints']; + self::assertStringContainsString('nur lesbar', $hints[0]); + } + + public function testNodeGetReturnsFullFormShortformAndPresets(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + $heading = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1.m0']); + + self::assertSame('divi/heading', $heading['type']); + self::assertSame('s0.r0.c1', $heading['parent']); + self::assertSame( + ['desktop' => ['value' => ['headingLevel' => 'h1', 'color' => '#1a1a1a']], 'phone' => ['value' => ['size' => '28px']]], + $heading['attrs']['title.decoration.font.font'] + ); + self::assertSame(['text' => 'Willkommen bei Müller & Söhne', 'level' => 'h1'], $heading['shortform']); + self::assertSame([['id' => 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', 'name' => '']], $heading['presets']); + self::assertSame('5.2.1', $heading['builder_version']); + + $accordion = $ability->execute(['post_id' => 7, 'id' => 's1.r0.c1.m0']); + self::assertSame(['s1.r0.c1.m0.m0', 's1.r0.c1.m0.m1'], $accordion['children']); + self::assertInstanceOf(stdClass::class, $accordion['shortform']); + } + + public function testNodeGetValidatesTheId(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + self::assertSame('lit_validation_failed', $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code()); + self::assertSame('lit_not_found', $ability->execute(['post_id' => 7, 'id' => 's5.r0'])->get_error_code()); + } +} From 2e9acd686a34069aebe4fdeef0213cb25143ae36 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:21:15 +0200 Subject: [PATCH 097/185] =?UTF-8?q?feat:=20lit/page-render-summary=20?= =?UTF-8?q?=E2=80=93=20rendered=20content=20as=20structured=20excerpt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 4 +- .../Render/PageRenderSummaryAbility.php | 104 ++++++++++++++ .../WordPress/Render/PageRenderer.php | 36 +++++ .../WordPress/Render/RenderSummary.php | 133 ++++++++++++++++++ .../WordPress/WordPressIntegration.php | 6 +- .../Render/PageRenderSummaryAbilityTest.php | 67 +++++++++ .../WordPress/Render/PageRendererTest.php | 64 +++++++++ .../WordPress/WordPressIntegrationTest.php | 1 + 8 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 src/Integrations/WordPress/Render/PageRenderSummaryAbility.php create mode 100644 src/Integrations/WordPress/Render/PageRenderer.php create mode 100644 src/Integrations/WordPress/Render/RenderSummary.php create mode 100644 tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php create mode 100644 tests/Unit/Integrations/WordPress/Render/PageRendererTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 95b9a6d..b7dfe5d 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,8 +19,8 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-outline`, -`lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, +`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php new file mode 100644 index 0000000..970b7b7 --- /dev/null +++ b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php @@ -0,0 +1,104 @@ + 'object', + 'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'url' => ['type' => 'string'], + 'headings' => ['type' => 'array', 'items' => ['type' => 'object']], + 'texts' => ['type' => 'array', 'items' => ['type' => 'string']], + 'links' => ['type' => 'array', 'items' => ['type' => 'object']], + 'images' => ['type' => 'array', 'items' => ['type' => 'object']], + 'warnings' => ['type' => 'array', 'items' => ['type' => 'string']], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'headings', 'texts', 'links', 'images', 'warnings', 'notes'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); + if ($post instanceof WP_Error) { + return $post; + } + + $notes = ['Nur der Inhaltsbereich; Header, Footer und Theme-Builder-Vorlagen fehlen.']; + if ((string) $post->post_password !== '') { + $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; + } + + return array_merge( + ['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)], + RenderSummary::fromHtml($this->renderer->render($post)), + ['notes' => $notes] + ); + } +} diff --git a/src/Integrations/WordPress/Render/PageRenderer.php b/src/Integrations/WordPress/Render/PageRenderer.php new file mode 100644 index 0000000..ceaca30 --- /dev/null +++ b/src/Integrations/WordPress/Render/PageRenderer.php @@ -0,0 +1,36 @@ +post_content); + } finally { + $echoed = (string) ob_get_clean(); + $GLOBALS['post'] = $previous; + if ($previous instanceof WP_Post) { + setup_postdata($previous); + } else { + wp_reset_postdata(); + } + } + + return $echoed . $html; + } +} diff --git a/src/Integrations/WordPress/Render/RenderSummary.php b/src/Integrations/WordPress/Render/RenderSummary.php new file mode 100644 index 0000000..cc8fbf8 --- /dev/null +++ b/src/Integrations/WordPress/Render/RenderSummary.php @@ -0,0 +1,133 @@ +, texts: list, + * links: list, images: list, + * warnings: list} + */ + public static function fromHtml(string $html, int $max = 50): array + { + $dom = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $dom->loadHTML( + '' . $html . '', + LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING + ); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $xpath = new DOMXPath($dom); + + $headings = []; + foreach (self::elements($xpath, '//h1|//h2|//h3|//h4|//h5|//h6', $max) as $element) { + $headings[] = ['level' => (int) substr($element->nodeName, 1), 'text' => self::text($element)]; + } + + $texts = []; + foreach (self::elements($xpath, '//p|//li|//blockquote', $max) as $element) { + $text = self::text($element); + if ($text !== '') { + $texts[] = mb_strlen($text) > self::TEXT_LIMIT + ? mb_substr($text, 0, self::TEXT_LIMIT - 1) . '…' + : $text; + } + } + + $links = []; + foreach (self::elements($xpath, '//a[@href]', $max) as $element) { + $links[] = ['text' => self::text($element), 'href' => $element->getAttribute('href')]; + } + + $images = []; + foreach (self::elements($xpath, '//img', $max) as $element) { + $images[] = [ + 'src' => $element->getAttribute('src'), + 'alt' => $element->hasAttribute('alt') ? $element->getAttribute('alt') : null, + ]; + } + + return [ + 'headings' => $headings, + 'texts' => $texts, + 'links' => $links, + 'images' => $images, + 'warnings' => self::warnings($headings, $images, $links), + ]; + } + + /** + * @param list $headings + * @param list $images + * @param list $links + * @return list + */ + private static function warnings(array $headings, array $images, array $links): array + { + $warnings = []; + $previous = null; + foreach ($headings as $heading) { + if ($previous !== null && $heading['level'] > $previous + 1) { + $warnings[] = sprintf( + 'Sprung in der Überschriftenhierarchie: h%d → h%d ("%s").', + $previous, + $heading['level'], + $heading['text'] + ); + } + $previous = $heading['level']; + } + + $h1 = count(array_filter($headings, static fn (array $heading): bool => $heading['level'] === 1)); + if ($h1 > 1) { + $warnings[] = sprintf('%d Überschriften der Ebene h1 im Inhalt.', $h1); + } + + foreach ($images as $image) { + if ($image['alt'] === null || trim($image['alt']) === '') { + $warnings[] = 'Bild ohne Alt-Text: ' . $image['src']; + } + } + + foreach ($links as $link) { + if ($link['href'] === '' || $link['href'] === '#') { + $warnings[] = sprintf('Link ohne Ziel: "%s".', $link['text']); + } + } + + return $warnings; + } + + /** @return list */ + private static function elements(DOMXPath $xpath, string $query, int $max): array + { + $nodes = $xpath->query($query); + $elements = []; + foreach ($nodes === false ? [] : $nodes as $node) { + if ($node instanceof DOMElement && count($elements) < $max) { + $elements[] = $node; + } + } + + return $elements; + } + + private static function text(DOMElement $element): string + { + return trim((string) preg_replace('/\s+/u', ' ', $element->textContent)); + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index b08912d..3e6e31b 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -20,6 +20,8 @@ use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuOperations; use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuWriter; +use LIT\MCP\Integrations\WordPress\Render\PageRenderer; +use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -37,7 +39,8 @@ use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility; use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; /** - * WordPress basics (spec §6): content, status, copies, revisions, menus, settings. + * WordPress basics (spec §6): content, status, copies, revisions, menus, settings, and the + * rendered-content summary lit/page-render-summary (spec §5.7 – useful for every entry). * Always available. */ final class WordPressIntegration implements Integration @@ -84,6 +87,7 @@ final class WordPressIntegration implements Integration new MenuAssignLocationAbility($this->log), new WpSettingsGetAbility($whitelist), new WpSettingsUpdateAbility($whitelist, $this->log), + new PageRenderSummaryAbility($this->schema, new PageRenderer()), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php new file mode 100644 index 0000000..afa3b3a --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php @@ -0,0 +1,67 @@ +

Willkommen

Leistungen

' + . '

Seit 1987 in Köln.

  • Beratung
' + . 'TeamMehr'; + + public function testSummarizesHtml(): void + { + $summary = RenderSummary::fromHtml(self::HTML); + + self::assertSame([['level' => 1, 'text' => 'Willkommen'], ['level' => 3, 'text' => 'Leistungen']], $summary['headings']); + self::assertSame(['Seit 1987 in Köln.', 'Beratung'], $summary['texts']); + self::assertSame(['text' => 'in Köln', 'href' => '/kontakt'], $summary['links'][0]); + self::assertSame( + [['src' => 'https://k.test/a.jpg', 'alt' => 'Team'], ['src' => 'https://k.test/b.jpg', 'alt' => null]], + $summary['images'] + ); + self::assertSame( + [ + 'Sprung in der Überschriftenhierarchie: h1 → h3 ("Leistungen").', + 'Bild ohne Alt-Text: https://k.test/b.jpg', + 'Link ohne Ziel: "Mehr".', + ], + $summary['warnings'] + ); + self::assertSame([], RenderSummary::fromHtml('')['headings']); + self::assertCount(2, RenderSummary::fromHtml(str_repeat('

x

', 5), 2)['texts']); + } + + public function testRendersTheEntryAndAddsNotes(): void + { + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_password' => 'geheim']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://k.test/start/'); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->expects('render')->with($post)->andReturn(self::HTML); + $ability = new PageRenderSummaryAbility($schema, $renderer); + + self::assertTrue($ability->permission(['post_id' => 7])); + $result = $ability->execute(['post_id' => 7]); + + self::assertSame(7, $result['post_id']); + self::assertSame('https://k.test/start/', $result['url']); + self::assertCount(2, $result['headings']); + self::assertCount(2, $result['notes']); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } +} diff --git a/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php b/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php new file mode 100644 index 0000000..4975376 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php @@ -0,0 +1,64 @@ + 1]); + $GLOBALS['post'] = $previous; + $post = new WP_Post(['ID' => 7, 'post_content' => '']); + $setup = []; + Functions\when('setup_postdata')->alias(static function (WP_Post $p) use (&$setup): bool { + $setup[] = $p->ID; + + return true; + }); + Filters\expectApplied('the_content')->once()->with('')->andReturnUsing( + static function () use ($post): string { + echo '

echoed

'; + + return $GLOBALS['post'] === $post ? '
' : 'wrong post'; + } + ); + + $html = (new PageRenderer())->render($post); + + self::assertSame('

echoed

', $html); + self::assertSame($previous, $GLOBALS['post']); + self::assertSame([7, 1], $setup); + } + + public function testRestoresEvenWhenRenderingThrows(): void + { + Functions\when('setup_postdata')->justReturn(true); + Functions\expect('wp_reset_postdata')->once(); + Filters\expectApplied('the_content')->once()->andReturnUsing(static function (): string { + throw new RuntimeException('module crashed'); + }); + + try { + (new PageRenderer())->render(new WP_Post(['ID' => 7])); + self::fail('exception expected'); + } catch (RuntimeException) { + self::assertArrayHasKey('post', $GLOBALS); + self::assertNull($GLOBALS['post']); + } + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index 1c8fb28..dd64491 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -35,6 +35,7 @@ final class WordPressIntegrationTest extends TestCase 'lit/menu-assign-location', 'lit/wp-settings-get', 'lit/wp-settings-update', + 'lit/page-render-summary', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void From b3cd0eee623cab35aa3a321bd047c690e0464e87 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:31:11 +0200 Subject: [PATCH 098/185] fix: bound render-summary strings, restore postdata globals, drain buffers Review fix round 1 for lit/page-render-summary: extract shared Text::shorten() (reused by NodeSummary), replace data: URIs with a short marker, prefer data-src/data-lazy-src over placeholder src, dedup nested p/li/blockquote text and ignore script/style/noscript content, restore every setup_postdata global (not just $post), drain output buffers robustly around the_content, and disclose that rendering can trigger oEmbed/network requests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Text.php | 19 +++ .../Divi/BlockTree/NodeSummary.php | 9 +- .../Render/PageRenderSummaryAbility.php | 12 +- .../WordPress/Render/PageRenderer.php | 50 ++++-- .../WordPress/Render/RenderSummary.php | 146 +++++++++++++++--- .../Render/PageRenderSummaryAbilityTest.php | 77 ++++++++- .../WordPress/Render/PageRendererTest.php | 79 +++++++++- 7 files changed, 345 insertions(+), 47 deletions(-) create mode 100644 src/Abilities/Text.php diff --git a/src/Abilities/Text.php b/src/Abilities/Text.php new file mode 100644 index 0000000..afbdef2 --- /dev/null +++ b/src/Abilities/Text.php @@ -0,0 +1,19 @@ + $max ? rtrim(mb_substr($text, 0, $max - 1)) . '…' : $text; + } +} diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php index 55b167b..8de562f 100644 --- a/src/Integrations/Divi/BlockTree/NodeSummary.php +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\BlockTree; +use LIT\MCP\Abilities\Text; + /** * Short human-readable facts about a node for the outline: admin label, first text, * preset IDs, column layout. Pure PHP. @@ -36,7 +38,7 @@ final class NodeSummary foreach ($paths as $path) { $text = self::textOf(AttrMap::desktop($block->attrs(), $path)); if ($text !== '') { - return self::shorten($text, $max); + return Text::shorten($text, $max); } } @@ -130,9 +132,4 @@ final class NodeSummary return $index === false ? count(self::PRIORITY) : (int) $index; } - - private static function shorten(string $text, int $max): string - { - return mb_strlen($text) > $max ? rtrim(mb_substr($text, 0, $max - 1)) . '…' : $text; - } } diff --git a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php index 970b7b7..c05f8c6 100644 --- a/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php +++ b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php @@ -34,8 +34,10 @@ final class PageRenderSummaryAbility implements Ability { return 'Rendert den Inhaltsbereich eines Eintrags wie im Frontend und liefert einen strukturierten Auszug: ' . 'Überschriften, Texte, Links, Bilder mit Alt-Text und Warnungen (Hierarchie-Sprünge, fehlende ' - . 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Gut zum Prüfen nach ' - . 'Änderungen.'; + . 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Das Rendern führt die ' + . 'üblichen Inhalts-Filter aus (Shortcodes, Divi, andere Plugins) und kann dabei Netzwerkanfragen ' + . 'auslösen, z. B. oEmbed-Abrufe, die von WordPress oder anderen Plugins zwischengespeichert werden. ' + . 'Gut zum Prüfen nach Änderungen.'; } public function inputSchema(): array @@ -90,7 +92,11 @@ final class PageRenderSummaryAbility implements Ability return $post; } - $notes = ['Nur der Inhaltsbereich; Header, Footer und Theme-Builder-Vorlagen fehlen.']; + $notes = [ + 'Nur der Inhaltsbereich; Header, Footer und Theme-Builder-Vorlagen fehlen.', + 'Das Rendern führt Plugin-/Theme-Filter aus (Shortcodes, oEmbed u. Ä.) und kann dabei ' + . 'Netzwerkanfragen auslösen, die von WordPress oder anderen Plugins zwischengespeichert werden.', + ]; if ((string) $post->post_password !== '') { $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; } diff --git a/src/Integrations/WordPress/Render/PageRenderer.php b/src/Integrations/WordPress/Render/PageRenderer.php index ceaca30..5e87d26 100644 --- a/src/Integrations/WordPress/Render/PageRenderer.php +++ b/src/Integrations/WordPress/Render/PageRenderer.php @@ -9,28 +9,60 @@ use WP_Post; /** * Renders the content area of an entry in-process, like the front end does for the_content * (Divi 5 renders its blocks there – verified in DDEV). Header, footer and Theme Builder - * layouts are not part of it. Not final so abilities can be unit-tested with a mock. + * layouts are not part of it. Rendering runs every plugin's/theme's content filters and + * shortcodes exactly as the front end would, which can trigger network requests (e.g. oEmbed + * lookups) that get cached by WordPress or other plugins as a side effect. Not final so + * abilities can be unit-tested with a mock. */ class PageRenderer { + /** + * Globals setup_postdata() populates from the post loop (WP_Query::the_post()); all of + * them are restored to their pre-render value afterwards, not just $post itself. + */ + private const POSTDATA_GLOBALS = ['id', 'authordata', 'pages', 'page', 'more', 'numpages', 'multipage']; + public function render(WP_Post $post): string { - $previous = $GLOBALS['post'] ?? null; + $previousPost = $GLOBALS['post'] ?? null; + $previousGlobals = []; + foreach (self::POSTDATA_GLOBALS as $name) { + $previousGlobals[$name] = $GLOBALS[$name] ?? null; + } + $GLOBALS['post'] = $post; - setup_postdata($post); + $level = ob_get_level(); ob_start(); try { + setup_postdata($post); $html = (string) apply_filters('the_content', (string) $post->post_content); } finally { - $echoed = (string) ob_get_clean(); - $GLOBALS['post'] = $previous; - if ($previous instanceof WP_Post) { - setup_postdata($previous); - } else { - wp_reset_postdata(); + $echoed = self::drain($level); + $GLOBALS['post'] = $previousPost; + foreach ($previousGlobals as $name => $value) { + $GLOBALS[$name] = $value; } } return $echoed . $html; } + + /** + * Pops every output buffer opened while rendering, including one a rogue shortcode/filter + * left open (drained here instead of leaking into the caller) – but never one it already + * closed for us: the loop only pops down to the level recorded before our own ob_start(), + * so a filter that closes one buffer too many simply loses whatever was already echoed + * (nothing left to drain) rather than us popping a buffer that belongs to the caller. + * Buffers are combined in the order their content was written (innermost popped first, + * then prepended) so a leaked nested buffer's output stays after what came before it. + */ + private static function drain(int $level): string + { + $output = ''; + while (ob_get_level() > $level) { + $output = (string) ob_get_clean() . $output; + } + + return $output; + } } diff --git a/src/Integrations/WordPress/Render/RenderSummary.php b/src/Integrations/WordPress/Render/RenderSummary.php index cc8fbf8..f8278be 100644 --- a/src/Integrations/WordPress/Render/RenderSummary.php +++ b/src/Integrations/WordPress/Render/RenderSummary.php @@ -6,15 +6,34 @@ namespace LIT\MCP\Integrations\WordPress\Render; use DOMDocument; use DOMElement; +use DOMNode; use DOMXPath; +use LIT\MCP\Abilities\Text; /** * Structured excerpt of rendered HTML (spec §5.7 lit/page-render-summary): headings, text - * blocks, links, images with alt text and the obvious problems. Pure PHP (ext-dom). + * blocks, links, images with alt text and the obvious problems. Every extracted string is + * bounded in length through the shared Text::shorten() helper (also used by + * Divi\BlockTree\NodeSummary) – a rendered page can otherwise embed arbitrarily large text, + * a huge inline data: URI, or a long tracking URL, none of which belong in an MCP response. + * Pure PHP (ext-dom). */ final class RenderSummary { private const TEXT_LIMIT = 300; + private const HEADING_LIMIT = 200; + private const LINK_TEXT_LIMIT = 150; + private const ALT_LIMIT = 200; + private const URL_LIMIT = 300; + + private const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + private const TEXT_TAGS = ['p', 'li', 'blockquote']; + + /** Never real page content: script/style source and noscript fallback markup. */ + private const IGNORED_ANCESTORS = ['script', 'style', 'noscript']; + + /** Attributes lazy-load libraries put the real image URL in; `src` is then a placeholder. */ + private const LAZY_SRC_ATTRIBUTES = ['data-src', 'data-lazy-src']; /** * @return array{headings: list, texts: list, @@ -34,32 +53,48 @@ final class RenderSummary $xpath = new DOMXPath($dom); $headings = []; - foreach (self::elements($xpath, '//h1|//h2|//h3|//h4|//h5|//h6', $max) as $element) { - $headings[] = ['level' => (int) substr($element->nodeName, 1), 'text' => self::text($element)]; - } - - $texts = []; - foreach (self::elements($xpath, '//p|//li|//blockquote', $max) as $element) { + $query = self::xpathFor(self::HEADING_TAGS, self::IGNORED_ANCESTORS); + foreach (self::elements($xpath, $query) as $element) { $text = self::text($element); if ($text !== '') { - $texts[] = mb_strlen($text) > self::TEXT_LIMIT - ? mb_substr($text, 0, self::TEXT_LIMIT - 1) . '…' - : $text; + $headings[] = [ + 'level' => (int) substr($element->nodeName, 1), + 'text' => Text::shorten($text, self::HEADING_LIMIT), + ]; } } + $headings = array_slice($headings, 0, $max); + + // p/li/blockquote nested inside another one of these (a

inside a

  • , a nested + // list item, …) are excluded, otherwise their text would be extracted twice: once as + // part of the outer element's textContent, once again on its own. + $texts = []; + $query = self::xpathFor(self::TEXT_TAGS, array_merge(self::IGNORED_ANCESTORS, self::TEXT_TAGS)); + foreach (self::elements($xpath, $query) as $element) { + $text = self::text($element); + if ($text !== '') { + $texts[] = Text::shorten($text, self::TEXT_LIMIT); + } + } + $texts = array_slice($texts, 0, $max); $links = []; - foreach (self::elements($xpath, '//a[@href]', $max) as $element) { - $links[] = ['text' => self::text($element), 'href' => $element->getAttribute('href')]; - } - - $images = []; - foreach (self::elements($xpath, '//img', $max) as $element) { - $images[] = [ - 'src' => $element->getAttribute('src'), - 'alt' => $element->hasAttribute('alt') ? $element->getAttribute('alt') : null, + $query = self::xpathFor(['a[@href]'], self::IGNORED_ANCESTORS); + foreach (self::elements($xpath, $query) as $element) { + $links[] = [ + 'text' => Text::shorten(self::text($element), self::LINK_TEXT_LIMIT), + 'href' => self::shortenUrl($element->getAttribute('href')), ]; } + $links = array_slice($links, 0, $max); + + $images = []; + $query = self::xpathFor(['img'], self::IGNORED_ANCESTORS); + foreach (self::elements($xpath, $query) as $element) { + $alt = $element->hasAttribute('alt') ? Text::shorten($element->getAttribute('alt'), self::ALT_LIMIT) : null; + $images[] = ['src' => self::shortenUrl(self::imageSrc($element)), 'alt' => $alt]; + } + $images = array_slice($images, 0, $max); return [ 'headings' => $headings, @@ -112,13 +147,25 @@ final class RenderSummary return $warnings; } + /** Builds an XPath union of $tags, each excluding descendants of $excludeAncestors. */ + private static function xpathFor(array $tags, array $excludeAncestors): string + { + $conditions = array_map( + static fn (string $tag): string => "not(ancestor::{$tag})", + $excludeAncestors + ); + $guard = $conditions === [] ? '' : '[' . implode(' and ', $conditions) . ']'; + + return implode(' | ', array_map(static fn (string $tag): string => "//{$tag}{$guard}", $tags)); + } + /** @return list */ - private static function elements(DOMXPath $xpath, string $query, int $max): array + private static function elements(DOMXPath $xpath, string $query): array { $nodes = $xpath->query($query); $elements = []; foreach ($nodes === false ? [] : $nodes as $node) { - if ($node instanceof DOMElement && count($elements) < $max) { + if ($node instanceof DOMElement) { $elements[] = $node; } } @@ -126,8 +173,63 @@ final class RenderSummary return $elements; } + /** + * Real lazy-loaded images keep the true URL in data-src/data-lazy-src and only put a + * placeholder (often a tiny data: URI) in src – prefer the real URL when present. + */ + private static function imageSrc(DOMElement $element): string + { + foreach (self::LAZY_SRC_ATTRIBUTES as $attribute) { + if ($element->hasAttribute($attribute) && trim($element->getAttribute($attribute)) !== '') { + return $element->getAttribute($attribute); + } + } + + return $element->getAttribute('src'); + } + + /** A data: URI is replaced by a short marker instead of being truncated mid-payload. */ + private static function shortenUrl(string $url): string + { + if (stripos($url, 'data:') === 0) { + return self::dataUriMarker($url); + } + + return Text::shorten($url, self::URL_LIMIT); + } + + private static function dataUriMarker(string $url): string + { + $mime = 'unknown'; + if (preg_match('/^data:([^;,]*)/i', $url, $match) === 1 && $match[1] !== '') { + $mime = $match[1]; + } + + return sprintf('data:%s;… (%d Bytes)', $mime, strlen($url)); + } + + /** Text of the element, ignoring script/style/noscript content, whitespace-normalized. */ private static function text(DOMElement $element): string { - return trim((string) preg_replace('/\s+/u', ' ', $element->textContent)); + return trim((string) preg_replace('/\s+/u', ' ', self::plainText($element))); + } + + private static function plainText(DOMNode $node): string + { + $text = ''; + foreach ($node->childNodes as $child) { + if ($child->nodeType === XML_TEXT_NODE || $child->nodeType === XML_CDATA_SECTION_NODE) { + $text .= $child->nodeValue; + continue; + } + + if ($child instanceof DOMElement && in_array(strtolower($child->nodeName), self::IGNORED_ANCESTORS, true)) { + continue; + } + + $text .= self::plainText($child); + } + + return $text; } } diff --git a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php index afa3b3a..8621c4d 100644 --- a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php @@ -42,6 +42,81 @@ final class PageRenderSummaryAbilityTest extends TestCase self::assertCount(2, RenderSummary::fromHtml(str_repeat('

    x

    ', 5), 2)['texts']); } + public function testTruncatesLongExtractedStrings(): void + { + $longHeading = str_repeat('Ü', 250); + $longText = str_repeat('a', 400); + $longLinkText = str_repeat('b', 200); + $longHref = 'https://k.test/' . str_repeat('c', 400); + $longAlt = str_repeat('d', 300); + $html = "

    {$longHeading}

    {$longText}

    " + . "{$longLinkText}" + . "\"{$longAlt}\""; + + $summary = RenderSummary::fromHtml($html); + + self::assertSame(200, mb_strlen($summary['headings'][0]['text'])); + self::assertStringEndsWith('…', $summary['headings'][0]['text']); + self::assertSame(300, mb_strlen($summary['texts'][0])); + self::assertStringEndsWith('…', $summary['texts'][0]); + self::assertSame(150, mb_strlen($summary['links'][0]['text'])); + self::assertStringEndsWith('…', $summary['links'][0]['text']); + self::assertSame(300, mb_strlen($summary['links'][0]['href'])); + self::assertStringEndsWith('…', $summary['links'][0]['href']); + self::assertSame(200, mb_strlen($summary['images'][0]['alt'])); + self::assertStringEndsWith('…', $summary['images'][0]['alt']); + } + + public function testRepresentsDataUrisAsAShortMarkerInsteadOfTruncatingThem(): void + { + $dataUri = 'data:image/png;base64,' . base64_encode(str_repeat('x', 5000)); + $html = 'Anhang'; + + $summary = RenderSummary::fromHtml($html); + + $marker = 'data:image/png;… (' . strlen($dataUri) . ' Bytes)'; + self::assertSame($marker, $summary['images'][0]['src']); + self::assertSame($marker, $summary['links'][0]['href']); + } + + public function testPrefersDataSrcOrDataLazySrcOverAPlaceholderSrc(): void + { + $html = 'Team' + . ''; + + $summary = RenderSummary::fromHtml($html); + + self::assertSame('https://k.test/real.jpg', $summary['images'][0]['src']); + self::assertSame('https://k.test/lazy.jpg', $summary['images'][1]['src']); + } + + public function testWarnsAboutMultipleH1Headings(): void + { + $summary = RenderSummary::fromHtml('

    Erste

    Zweite

    '); + + self::assertContains('2 Überschriften der Ebene h1 im Inhalt.', $summary['warnings']); + } + + public function testDoesNotDuplicateTextFromNestedParagraphsListItemsOrBlockquotes(): void + { + $html = '
    • Punkt

      eingebettet

    ' + . '
    Zitat
    innen
    '; + + $summary = RenderSummary::fromHtml($html); + + self::assertSame(['Punkt eingebettet', 'Zitat innen'], $summary['texts']); + } + + public function testIgnoresScriptStyleAndNoscriptText(): void + { + $html = '

    Sichtbar' + . ' Ende

    '; + + $summary = RenderSummary::fromHtml($html); + + self::assertSame(['Sichtbar Ende'], $summary['texts']); + } + public function testRendersTheEntryAndAddsNotes(): void { $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_password' => 'geheim']); @@ -60,7 +135,7 @@ final class PageRenderSummaryAbilityTest extends TestCase self::assertSame(7, $result['post_id']); self::assertSame('https://k.test/start/', $result['url']); self::assertCount(2, $result['headings']); - self::assertCount(2, $result['notes']); + self::assertCount(3, $result['notes']); self::assertTrue($ability->isDirect()); self::assertTrue($ability->annotations()['readonly']); } diff --git a/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php b/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php index 4975376..3a58f38 100644 --- a/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php +++ b/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php @@ -13,20 +13,40 @@ use WP_Post; final class PageRendererTest extends TestCase { + private const POSTDATA_GLOBALS = ['id', 'authordata', 'pages', 'page', 'more', 'numpages', 'multipage']; + protected function tearDown(): void { unset($GLOBALS['post']); + foreach (self::POSTDATA_GLOBALS as $name) { + unset($GLOBALS[$name]); + } parent::tearDown(); } - public function testRendersTheContentFilterAndRestoresTheGlobalPost(): void + public function testRendersTheContentFilterAndRestoresGlobalPostAndPostdata(): void { - $previous = new WP_Post(['ID' => 1]); - $GLOBALS['post'] = $previous; + $previousPost = new WP_Post(['ID' => 1]); + $GLOBALS['post'] = $previousPost; + $GLOBALS['id'] = 1; + $GLOBALS['authordata'] = 'previous-author'; + $GLOBALS['pages'] = ['previous']; + $GLOBALS['page'] = 1; + $GLOBALS['more'] = 1; + $GLOBALS['numpages'] = 1; + $GLOBALS['multipage'] = 0; + $post = new WP_Post(['ID' => 7, 'post_content' => '']); $setup = []; Functions\when('setup_postdata')->alias(static function (WP_Post $p) use (&$setup): bool { $setup[] = $p->ID; + $GLOBALS['id'] = $p->ID; + $GLOBALS['authordata'] = 'author-of-' . $p->ID; + $GLOBALS['pages'] = ['page-of-' . $p->ID]; + $GLOBALS['page'] = 2; + $GLOBALS['more'] = 0; + $GLOBALS['numpages'] = 3; + $GLOBALS['multipage'] = 1; return true; }); @@ -41,14 +61,20 @@ final class PageRendererTest extends TestCase $html = (new PageRenderer())->render($post); self::assertSame('

    echoed

    ', $html); - self::assertSame($previous, $GLOBALS['post']); - self::assertSame([7, 1], $setup); + self::assertSame([7], $setup); + self::assertSame($previousPost, $GLOBALS['post']); + self::assertSame(1, $GLOBALS['id']); + self::assertSame('previous-author', $GLOBALS['authordata']); + self::assertSame(['previous'], $GLOBALS['pages']); + self::assertSame(1, $GLOBALS['page']); + self::assertSame(1, $GLOBALS['more']); + self::assertSame(1, $GLOBALS['numpages']); + self::assertSame(0, $GLOBALS['multipage']); } public function testRestoresEvenWhenRenderingThrows(): void { Functions\when('setup_postdata')->justReturn(true); - Functions\expect('wp_reset_postdata')->once(); Filters\expectApplied('the_content')->once()->andReturnUsing(static function (): string { throw new RuntimeException('module crashed'); }); @@ -59,6 +85,47 @@ final class PageRendererTest extends TestCase } catch (RuntimeException) { self::assertArrayHasKey('post', $GLOBALS); self::assertNull($GLOBALS['post']); + foreach (self::POSTDATA_GLOBALS as $name) { + self::assertArrayHasKey($name, $GLOBALS); + self::assertNull($GLOBALS[$name]); + } } } + + public function testDrainsAnExtraOutputBufferAShortcodeLeavesOpen(): void + { + Functions\when('setup_postdata')->justReturn(true); + Filters\expectApplied('the_content')->once()->andReturnUsing(static function (): string { + echo 'before-'; + ob_start(); // a buggy shortcode opens a buffer and never closes it + echo 'leaked'; + + return 'html'; + }); + $levelBefore = ob_get_level(); + + $html = (new PageRenderer())->render(new WP_Post(['ID' => 7])); + + self::assertSame('before-leakedhtml', $html); + self::assertSame($levelBefore, ob_get_level()); + } + + public function testToleratesAShortcodeThatClosesOneOutputBufferTooMany(): void + { + Functions\when('setup_postdata')->justReturn(true); + Filters\expectApplied('the_content')->once()->andReturnUsing(static function (): string { + ob_start(); // shortcode's own nested buffer + echo 'own'; + ob_end_clean(); // correct: closes its own buffer + ob_end_clean(); // bug: also closes PageRenderer's buffer + + return 'html'; + }); + $levelBefore = ob_get_level(); + + $html = (new PageRenderer())->render(new WP_Post(['ID' => 7])); + + self::assertSame('html', $html); + self::assertSame($levelBefore, ob_get_level()); + } } From f433fef8f75e56be9a04dc0ab986db2314260d50 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:40:22 +0200 Subject: [PATCH 099/185] feat: Divi hints point to the read tools; guides divi-structure/attrs/design-system; test: Divi read end-to-end Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/content-types.md | 7 +- docs/guides/divi-attrs.md | 32 ++++++ docs/guides/divi-design-system.md | 20 ++++ docs/guides/divi-structure.md | 37 +++++++ docs/guides/overview.md | 5 +- .../notes/2026-09-26-etappe-2a-abnahme.md | 64 +++++++++++ src/Guides/InstructionsBuilder.php | 7 +- .../Content/ContentUpdateAbility.php | 2 +- .../WordPress/Content/PostPresenter.php | 33 +++++- .../Revisions/RevisionsListAbility.php | 4 +- tests/Integration/divi-read-smoke.sh | 100 ++++++++++++++++++ tests/Unit/Guides/InstructionsBuilderTest.php | 16 +++ .../Content/ContentUpdateAbilityTest.php | 5 +- .../WordPress/Content/PostPresenterTest.php | 11 +- .../Revisions/RevisionsListAbilityTest.php | 2 +- 15 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 docs/guides/divi-attrs.md create mode 100644 docs/guides/divi-design-system.md create mode 100644 docs/guides/divi-structure.md create mode 100644 docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md create mode 100755 tests/Integration/divi-read-smoke.sh diff --git a/docs/guides/content-types.md b/docs/guides/content-types.md index cd93d80..213800c 100644 --- a/docs/guides/content-types.md +++ b/docs/guides/content-types.md @@ -23,8 +23,8 @@ Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities, Trefferliste. Geliefert werden nur Einträge, die der verbundene Nutzer selbst bearbeiten darf (fremde Entwürfe/private Inhalte ohne die passende Berechtigung erscheinen nicht). - `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. - Bei Divi-Seiten fehlt `content`; Divi-Werkzeuge folgen in einer späteren Version, bis dahin werden - Divi-Inhalte im Divi-Builder bearbeitet (Titel, Auszug, Felder und Begriffe gehen weiter über + Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert + wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über `lit-content-update`). ## Anlegen und Ändern @@ -59,7 +59,8 @@ Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities, ## Sicher überarbeiten 1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. -2. Kopie mit `lit-content-update` (Divi-Inhalte bis zu den Divi-Werkzeugen im Divi-Builder) bearbeiten und dem Nutzer die Vorschau zeigen. +2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die + Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen. 3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). Voraussetzungen: die Kopie muss noch Entwurf oder ausstehend sein, `rev` des Originals aus `lit-content-get` ist Pflicht (sie deckt Titel, Inhalt, Auszug und Status ab – Felder/Meta, die direkt am diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md new file mode 100644 index 0000000..e4e5a8f --- /dev/null +++ b/docs/guides/divi-attrs.md @@ -0,0 +1,32 @@ +# Divi-Einstellungen (Vollform) + +Jedes Divi-5-Modul speichert seine Einstellungen unter **Attributpfaden**, z. B. + +- `title.innerContent` – Text der Überschrift, +- `title.decoration.font.font` – Schrift der Überschrift (Farbe, Größe, Ebene `headingLevel`), +- `button.innerContent` – Beschriftung und Link eines Buttons (`text`, `linkUrl`, `linkTarget`), +- `module.decoration.background` – Hintergrund, `module.decoration.spacing` – Innen-/Außenabstand, +- `module.meta.adminLabel` – Admin-Label im Divi-Builder. + +Der Wert eines Pfads ist eine Zuordnung **Breakpoint → Zustand → Wert**: + +```json +{ "desktop": { "value": { "color": "#1a1a1a", "headingLevel": "h2" }, "hover": { "color": "#000000" } }, + "phone": { "value": { "size": "28px" } } } +``` + +Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `phoneWide`, `widescreen`, +`ultraWide`); Zustände: `value`, `hover`, `sticky`. + +## Pfade finden + +- `lit-divi-catalog` mit `module` (z. B. `"heading"`) listet alle gültigen Pfade dieses Moduls auf dieser + Website (live aus Divi) und die Kurzform-Felder mit ihrem Pfad. `path_kinds` nennt die Feldart eines Pfads + (`text`, `richtext`, `code`, `url`, `image`, `color`, `enum`, `onoff`, `number`, `list`, `icon`), soweit aus + den Divi-Metadaten ableitbar; zusammengesetzte Pfade (mehrere mögliche Feldarten je nach Unterschlüssel) + gelten als unbekannt, statt eine Feldart zu raten. +- `lit-divi-node-get` zeigt die gesetzten Werte eines vorhandenen Knotens in genau dieser Form (`attrs`). +- Schrift-Gruppen liegen eine Ebene tiefer: `…decoration.font.font`, `…decoration.bodyFont.body.font`, + `…decoration.headingFont.h2.font`, `module.advanced.text.text`. +- Werte wie `$variable({…})$` sind Verweise auf globale Farben, Variablen oder dynamische Inhalte – nicht + durch feste Werte ersetzen (siehe `divi-design-system`). diff --git a/docs/guides/divi-design-system.md b/docs/guides/divi-design-system.md new file mode 100644 index 0000000..ab092c9 --- /dev/null +++ b/docs/guides/divi-design-system.md @@ -0,0 +1,20 @@ +# Divi-Designsystem + +`lit-divi-design-system` liest das Designsystem der Website live aus Divi: + +- **Globale Farben** (`colors`): ID (`gcid-…`), Name, Wert und der Platzhalter für neue Inhalte, z. B. + `{{color:primary}}` für die Primärfarbe oder `{{color:Nachtblau}}` für eine eigene globale Farbe. Die fünf + Theme-Farben heißen `primary`, `secondary`, `heading`, `body` und `link`. +- **Globale Variablen** (`variables`): Zahlen, Texte, Bilder, Links (`gvid-…`), Platzhalter `{{var:}}`. +- **Theme-Schriften** (`fonts`): Überschrift und Fließtext, Platzhalter `{{font:heading}}` / `{{font:body}}`. +- **Größen** (`sizes`): Schriftgrößen, Inhaltsbreite und Spaltenabstand aus den Theme-Optionen. +- **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und + Gruppen-Presets (`kind: group`); `default: true` markiert das Standard-Preset eines Moduls. + +Regeln: + +1. Vor dem Gestalten einer Seite das Designsystem lesen. +2. Globale Farben, Variablen und Presets vor festen Werten verwenden; feste Farben oder Schriften nur, wenn + der Nutzer es ausdrücklich möchte. +3. Gibt es für einen Zweck noch keine Farbe, Variable oder kein Preset, dem Nutzer vorschlagen, es im + Divi-Builder anzulegen, statt feste Werte zu verteilen. diff --git a/docs/guides/divi-structure.md b/docs/guides/divi-structure.md new file mode 100644 index 0000000..51e74f0 --- /dev/null +++ b/docs/guides/divi-structure.md @@ -0,0 +1,37 @@ +# Divi-Seiten: Aufbau lesen + +Divi 5 speichert eine Seite als Block-Markup: Abschnitte (`section`) enthalten Zeilen (`row`), Zeilen +Spalten (`column`), Spalten Module (`text`, `heading`, `button`, …). Manche Module haben Kind-Module +(`accordion` → `accordion-item`). Die Divi-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. + +## Seitenbaum + +- `lit-divi-outline` mit `post_id` liefert jeden Knoten mit **Pfad-ID**, Modultyp, Admin-Label, Textauszug, + Layout-Info (z. B. `Spalten 8_24+16_24`) und Preset-Namen – als kompakten Text (`outline`) und als Liste + (`nodes`). Mit `id` nur ein Teilbaum, mit `depth` begrenzte Tiefe. +- Pfad-IDs: `s0` = erster Abschnitt, `s0.r1` = zweite Zeile darin, `s0.r1.c0` = erste Spalte, + `s0.r1.c0.m2` = drittes Modul, `…m2.m0` = erstes Kind-Modul. `g` = globales Modul, `x` = fremder Block + (z. B. aus dem Block-Editor). Gezählt wird je Buchstabe innerhalb des Elternknotens. +- `lit-divi-node-get` mit `post_id` und `id` liefert alle Einstellungen eines Knotens (siehe `divi-attrs`), + die aktuellen Kurzform-Werte (`shortform`) und die IDs der Kinder. + +## Stand und Sperre + +- `tree_rev` ist der Stand des Seiteninhalts. Pfad-IDs gelten nur für diesen Stand – ändert jemand die Seite + (auch im Divi-Builder), verschieben sich die IDs. Divi-Änderungen verlangen deshalb immer die `tree_rev` + des letzten Lesevorgangs. +- `rev` ist davon getrennt: der Stand des ganzen Eintrags (Titel, Auszug, Status …) für `lit-content-update`. + +## Was nicht bearbeitet wird + +- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht. +- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder + öffnen und speichern, Divi 5 wandelt sie dabei um. +- Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und + speichern. + +## Ergebnis prüfen + +`lit-page-render-summary` rendert den Inhaltsbereich wie im Frontend und listet Überschriften, Texte, Links +und Bilder samt Warnungen (Sprünge in der Überschriftenhierarchie, fehlende Alt-Texte, Links ohne Ziel). +Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. diff --git a/docs/guides/overview.md b/docs/guides/overview.md index b7dfe5d..f453861 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -33,7 +33,10 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, `lit/media-update`, `lit/media-delete`). -Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows` oder `media`. +Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. + +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-attrs` oder `divi-design-system`. ## Rückmeldungen diff --git a/docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md b/docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md new file mode 100644 index 0000000..6049b32 --- /dev/null +++ b/docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md @@ -0,0 +1,64 @@ +# Etappe 2a – Manuelle Abnahme (Divi lesen) + +Vorbereitet von Task 8 (Hinweise/Guides/Serveranweisungen/Ende-zu-Ende-Test). Automatisiert geprüft ist +bereits: alle Unit-Tests (`composer test`, 692 Tests), Lint (`composer lint`) und alle vier +Integrationsskripte (`tests/Integration/divi-read-smoke.sh`, `tests/Integration/mcp-smoke.sh`, +`tests/Integration/wp-basis-smoke.sh`, `tests/Integration/media-admin-smoke.sh`) – Details im Report von +Task 8 (`.superpowers/sdd/2026-09-26-etappe-2a-divi-lesen/task-8-report.md`). + +Die folgenden Punkte lassen sich nicht automatisiert prüfen (echter Divi Visual Builder, +claude.ai-Connector); das führt Jan-Niclas selbst durch. + +## 1. Builder-Fixtures exportieren (Roundtrip mit echten Seiten) + +Alle bisherigen Divi-Fixtures sind entweder handgeschrieben (`synthetic-*.html`), von Divis eigener +Shortcode→Block-Konvertierung erzeugt (`converted-*.html`) oder reine Modul-Metadaten +(`metadata-sample.json`). Es fehlt noch mindestens eine echte, im Visual Builder gebaute Seite als +Gegenprobe für Parser/Serializer. + +- [ ] 2–3 Seiten im Divi Visual Builder bauen. Zusammen sollten sie enthalten: + - ein **globales Modul** (aus der Divi-Bibliothek eingefügt), + - ein **Modul-Preset**, + - eine **globale Farbe** und eine **globale Variable**, + - **Dynamic Content** (z. B. Beitragstitel, ein Meta-Feld), + - einen **Spezialabschnitt** (Specialty Section) und eine **verschachtelte Zeile**. + - Keine personenbezogenen Daten verwenden – die Fixtures werden eingecheckt. +- [ ] Je Seite exportieren und den Roundtrip prüfen: + ```bash + cd /home/janniclas/Projekte/WordPress + timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-page.php + cd wp-content/plugins/WP-LIT-MCP && composer test + ``` + Erwartet: `Success: builder-.html: bytes, blocks, globalModule: ja/nein, tree_rev .`, + danach `composer test` weiterhin grün (der Test läuft automatisch über alle `builder-*.html`-Dateien mit). +- [ ] Schlägt der Roundtrip fehl (Parser/Serializer erzeugen nicht mehr das byte-identische Markup): das + ist ein **Fehler im Plugin** (`Parser`/`Serializer`), nie ein Grund, die Fixture anzupassen. Als + Folgeaufgabe melden statt selbst zu reparieren. +- [ ] Fixtures ohne personenbezogene Daten einchecken: + ```bash + git add tests/Fixtures/divi/builder-*.html + git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: Divi builder fixtures" + ``` + +## 2. claude.ai: Divi-Lese-Werkzeuge im Gespräch + +Mit dem claude.ai-Connector gegen eine Website mit aktivem Divi-5-Theme (z. B. dieses DDEV-Setup oder +Staging) verbinden und prüfen, dass die Antworten mit dem tatsächlichen Aufbau im Divi-Builder +übereinstimmen: + +- [ ] „Zeig mir den Aufbau der Startseite“ → sollte `lit-divi-outline` aufrufen und einen Seitenbaum mit + Pfad-IDs, Modultypen und Textauszügen liefern, der zum Divi-Builder passt. +- [ ] „Welche Farben und Presets hat die Website?“ → sollte `lit-divi-design-system` aufrufen und die + globalen Farben/Presets nennen, die auch im Divi-Builder (Designsystem-Bereich) zu sehen sind. +- [ ] „Was sieht ein Besucher auf der Seite?“ → sollte `lit-page-render-summary` aufrufen und + Überschriften/Texte/Links/Bilder liefern, die dem Frontend entsprechen. +- [ ] Bei einer der zuvor im Visual Builder gebauten Seiten (Schritt 1): stichprobenartig prüfen, dass + globales Modul, Preset, Dynamic Content und Spezialabschnitt im Outline/Node-Get sinnvoll + wiedergegeben werden (globales Modul und Spezialabschnitt ggf. als „nur lesbar“ markiert – das ist + in dieser Etappe so vorgesehen, siehe `divi-structure`-Guide). + +## Ergebnis + +Kurze Rückmeldung an den Ausführenden (Task 8 / Controller): welche Punkte grün sind, welche +Builder-Fixtures committet wurden, und was ggf. als Folgeaufgabe (Bugfix im Parser/Serializer oder +Doku-Korrektur) aufgenommen werden muss. diff --git a/src/Guides/InstructionsBuilder.php b/src/Guides/InstructionsBuilder.php index 81f830f..073fa14 100644 --- a/src/Guides/InstructionsBuilder.php +++ b/src/Guides/InstructionsBuilder.php @@ -52,6 +52,11 @@ final class InstructionsBuilder . ' (Felder und Taxonomien: lit/content-types, direkt: lit-content-types).'; } + $divi = str_contains(', ' . $active . ',', ', divi,') + ? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' + . 'topic "divi-structure".'] + : []; + return implode("\n", array_merge($lines, [ 'Regeln:', '- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide ' @@ -63,6 +68,6 @@ final class InstructionsBuilder '- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).', '- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).', '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', - ])); + ], $divi)); } } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 687401e..2c6dc7f 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -121,7 +121,7 @@ final class ContentUpdateAbility implements Ability if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) { return Errors::unsupported( 'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.', - PostPresenter::DIVI_NOTE . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.' + PostPresenter::diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.' ); } diff --git a/src/Integrations/WordPress/Content/PostPresenter.php b/src/Integrations/WordPress/Content/PostPresenter.php index ea8f3f1..f26c812 100644 --- a/src/Integrations/WordPress/Content/PostPresenter.php +++ b/src/Integrations/WordPress/Content/PostPresenter.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Abilities\Rev; +use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition; use WP_Post; @@ -13,14 +14,36 @@ use WP_Post; /** * Output format of an entry for the content tools. Not final so abilities can be * unit-tested with a mock. + * + * Layering exception: diviNote()/diviHint() call DiviModules::isAvailable() (Divi + * integration) to decide whether the Divi tools exist – the WordPress integration + * otherwise never depends on Divi. Kept because the alternative (injecting + * availability through every constructor that reaches PostPresenter) would spread the + * same one-line check across content-get, content-update and revisions-list for no + * behavioral gain; $toolsAvailable can still be passed in explicitly for tests. */ class PostPresenter { - public const DIVI_NOTE = 'Divi-Werkzeuge folgen in einer späteren Version; bis dahin im Divi-Builder bearbeiten.'; + /** How Divi content is read and changed with the Divi tools. */ + public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit ' + . 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.'; - public const DIVI_HINT = 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. ' - . self::DIVI_NOTE . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update ' - . '(direkt: lit-content-update) änderbar.'; + /** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */ + public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine ' + . 'Divi-Werkzeuge; Divi-Inhalte nur im Divi-Builder bearbeiten.'; + + /** $toolsAvailable defaults to whether the Divi integration is available (Divi 5 loaded). */ + public static function diviNote(?bool $toolsAvailable = null): string + { + return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE; + } + + public static function diviHint(?bool $toolsAvailable = null): string + { + return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. ' + . self::diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update ' + . '(direkt: lit-content-update) änderbar.'; + } public function __construct(private ContentSchema $schema) { @@ -63,7 +86,7 @@ class PostPresenter 'fields' => JsonMap::of($this->fields($post)), 'featured_image' => $this->featuredImage($post), 'rev' => Rev::ofPost($post), - 'hints' => $divi ? [self::DIVI_HINT] : [], + 'hints' => $divi ? [self::diviHint()] : [], ]); } diff --git a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php index cf374ec..cab1380 100644 --- a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php @@ -19,7 +19,7 @@ use WP_Post; * the same detection lit/content-get uses) because a Divi/block-built revision's * post_content is shortcode or block-comment markup, not prose – excerpt is then a * stripped fragment of that markup rather than a real preview; hints repeats - * PostPresenter::DIVI_HINT once when any item is flagged. + * PostPresenter::diviHint() once when any item is flagged. */ final class RevisionsListAbility implements Ability { @@ -124,7 +124,7 @@ final class RevisionsListAbility implements Ability 'post_id' => (int) $post->ID, 'items' => $items, 'next_cursor' => $page['next_cursor'], - 'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::DIVI_HINT] : [], + 'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::diviHint()] : [], ]; } diff --git a/tests/Integration/divi-read-smoke.sh b/tests/Integration/divi-read-smoke.sh new file mode 100755 index 0000000..14d0864 --- /dev/null +++ b/tests/Integration/divi-read-smoke.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# End-to-end checks of the Etappe-2a Divi read tools through the OAuth-protected endpoint. +# Runs INSIDE the DDEV web container (Divi 5 must be the active theme): +# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh +# Creates draft pages from tests/Fixtures/divi and deletes them again. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +FIXTURES=$PLUGIN/tests/Fixtures/divi +CREATED_POSTS=() + +cleanup() { + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + cleanup_app_passwords_for "$ADMIN_LOGIN" +} +trap cleanup EXIT + +# Creates a draft page from a fixture file as the admin (no kses re-serialization) and prints its ID. +create_page() { # $1 = fixture file, $2 = title + LIT_FILE="$FIXTURES/$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;' +} + +content_hash() { # $1 = post ID -> tree_rev of the stored content + wp eval "echo substr(hash('sha256', (string) get_post($1)->post_content), 0, 12);" +} + +[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" +wp eval-file "$PLUGIN/tests/Integration/divi-shortform-check.php" >/dev/null || fail "short form does not match the Divi catalog" +pass "short form matches the live Divi catalog" + +PAGE_ID=$(create_page converted-landing.html "LIT Divi Lesen") +CREATED_POSTS+=("$PAGE_ID") +D4_ID=$(create_page divi4-shortcodes.html "LIT Divi 4") +CREATED_POSTS+=("$D4_ID") +BEFORE=$(content_hash "$PAGE_ID") +connect "$ADMIN_LOGIN" + +# 1. Direct read tools with readOnlyHint +LIST=$(rpc tools/list '{}') +for t in lit-divi-outline lit-divi-node-get lit-divi-catalog lit-divi-design-system lit-page-render-summary; do + echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t) | .annotations.readOnlyHint == true' >/dev/null \ + || fail "tools/list lacks $t with readOnlyHint" +done +pass "tools/list: Divi read tools are direct and read-only" + +# 2. Outline with tree_rev = hash of post_content (ruling P16) and path IDs +OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}") +[ "$(echo "$OUTLINE" | jq -r '.tree_rev')" = "$BEFORE" ] || fail "tree_rev is not the hash of post_content: $OUTLINE" +echo "$OUTLINE" | jq -e '.rev != .tree_rev' >/dev/null || fail "rev and tree_rev must differ: $OUTLINE" +echo "$OUTLINE" | jq -e '.nodes[] | select(.id == "s1.r1.c0.m2.m0" and .type == "accordion-item")' >/dev/null \ + || fail "outline lacks the accordion item: $OUTLINE" +echo "$OUTLINE" | jq -r '.outline' | head -1 | grep -q '^s0 section "Hero"' || fail "outline text: $OUTLINE" +pass "lit-divi-outline: path IDs, tree_rev, rev" + +# 3. Node in full form plus short-form values +NODE=$(tool lit-divi-node-get "{\"post_id\":$PAGE_ID,\"id\":\"s0.r0.c0.m0\"}") +echo "$NODE" | jq -e '.type == "divi/heading" and .shortform.text == "Handwerk mit Herz"' >/dev/null || fail "node-get: $NODE" +echo "$NODE" | jq -e '.attrs["title.innerContent"].desktop.value == "Handwerk mit Herz"' >/dev/null || fail "node-get attrs: $NODE" +MISSING=$(tool lit-divi-node-get "{\"post_id\":$PAGE_ID,\"id\":\"s9\"}") +[ "$(error_code "$MISSING")" = "not_found" ] || fail "unknown node: $MISSING" +pass "lit-divi-node-get: full form, short form, unknown node" + +# 4. Catalog and design system live from Divi +CATALOG=$(tool lit-divi-catalog '{}') +echo "$CATALOG" | jq -e '(.modules | length) >= 90' >/dev/null || fail "catalog too small: $CATALOG" +BUTTON=$(tool lit-divi-catalog '{"module":"button"}') +echo "$BUTTON" | jq -e '.module.paths | index("button.innerContent") != null' >/dev/null || fail "catalog button: $BUTTON" +DESIGN=$(tool lit-divi-design-system '{}') +echo "$DESIGN" | jq -e '.colors[] | select(.id == "gcid-primary-color" and .placeholder == "{{color:primary}}")' >/dev/null \ + || fail "design system: $DESIGN" +pass "lit-divi-catalog and lit-divi-design-system read live data" + +# 5. Rendered summary +SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}") +echo "$SUMMARY" | jq -e '.headings[0].text == "Handwerk mit Herz"' >/dev/null || fail "render summary: $SUMMARY" +echo "$SUMMARY" | jq -e '[.texts[] | select(contains("Seit 1987"))] | length == 1' >/dev/null || fail "render texts: $SUMMARY" +pass "lit-page-render-summary renders Divi modules" + +# 6. Hints and refusals +GET=$(tool lit-content-get "{\"post_id\":$PAGE_ID}") +echo "$GET" | jq -e '.content == null and (.hints[0] | contains("lit-divi-outline"))' >/dev/null || fail "content-get hint: $GET" +D4=$(tool lit-divi-outline "{\"post_id\":$D4_ID}") +[ "$(error_code "$D4")" = "unsupported" ] || fail "Divi 4 page must be unsupported: $D4" +pass "content-get points to the Divi tools; Divi 4 content is refused" + +# 7. Reading changed nothing +[ "$(content_hash "$PAGE_ID")" = "$BEFORE" ] || fail "reading changed post_content" +pass "post_content unchanged after reading" + +echo "ALL CHECKS PASSED" diff --git a/tests/Unit/Guides/InstructionsBuilderTest.php b/tests/Unit/Guides/InstructionsBuilderTest.php index cd63be2..1b6c9ed 100644 --- a/tests/Unit/Guides/InstructionsBuilderTest.php +++ b/tests/Unit/Guides/InstructionsBuilderTest.php @@ -45,6 +45,22 @@ final class InstructionsBuilderTest extends TestCase self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.'); } + public function testAddsTheDiviRuleOnlyWhenDiviIsActive(): void + { + $divi = Mockery::mock(Integration::class); + $divi->allows('id')->andReturn('divi'); + $divi->allows('isAvailable')->andReturn(true); + $divi->allows('abilities')->andReturn([$this->ability('lit/divi-outline', true)]); + $core = $this->coreWith($this->ability('lit/guide', true)); + + $with = (new InstructionsBuilder(new IntegrationRegistry($core, $divi)))->build(); + $without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); + + self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); + self::assertStringNotContainsString('lit-divi-outline und', $without); + self::assertLessThan(2000, strlen($with)); + } + public function testListsPostTypesWhenAProviderIsGiven(): void { $core = $this->coreWith($this->ability('lit/guide', true)); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php index 9db7dd5..dfd1033 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility; +use LIT\MCP\Integrations\WordPress\Content\PostPresenter; use LIT\MCP\Integrations\WordPress\Content\PostWriter; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Unit\TestCase; @@ -116,9 +117,7 @@ final class ContentUpdateAbilityTest extends TestCase ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'content' => '

    Neu

    ']); self::assertSame('lit_unsupported', $result->get_error_code()); - self::assertStringNotContainsString('lit-divi', $result->get_error_message()); - self::assertStringContainsString('Divi-Werkzeuge folgen in einer späteren Version; bis dahin im Divi-Builder ' - . 'bearbeiten.', $result->get_error_data()['hint']); + self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, $result->get_error_data()['hint']); } public function testNothingToChangeIsAValidationError(): void diff --git a/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php b/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php index cd1b3d9..3daefcb 100644 --- a/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php +++ b/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php @@ -87,13 +87,20 @@ final class PostPresenterTest extends TestCase self::assertTrue($full['divi']); self::assertNull($full['content']); self::assertNull($full['featured_image']); - self::assertStringNotContainsString('lit-divi', $full['hints'][0]); - self::assertStringContainsString('Divi-Werkzeuge folgen in einer späteren Version', $full['hints'][0]); + self::assertSame([PostPresenter::diviHint(false)], $full['hints']); self::assertInstanceOf(stdClass::class, $full['terms']); self::assertInstanceOf(stdClass::class, $full['fields']); self::assertSame(Rev::ofPost($post), $full['rev']); } + public function testDiviHintNamesTheDiviToolsOnlyWhenDiviIsActive(): void + { + self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true)); + self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true)); + self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false)); + self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint()); + } + public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void { $this->stubLinks(); diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php index 89636e3..7bdafdc 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php @@ -66,7 +66,7 @@ final class RevisionsListAbilityTest extends TestCase self::assertTrue($result['items'][0]['divi']); self::assertFalse($result['items'][1]['divi']); - self::assertSame([PostPresenter::DIVI_HINT], $result['hints']); + self::assertSame([PostPresenter::diviHint()], $result['hints']); } public function testRevisionsOfATrashedPostCanStillBeListed(): void From 4137882008cfba4c5466d0ca712a98686c52fb2f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 13:10:00 +0200 Subject: [PATCH 100/185] fix: harden the Divi BlockTree against opaque/invalid writes Final fix wave for stage 2a, load-bearing for 2b's writers: - Block::setAttrs()/Container::insertChild()/removeChild() refuse on an opaque block (undecodable attrs or a global module) instead of silently corrupting it. - Container::append() is now @internal (Parser only) and refuses on a void block, so a child can no longer be silently dropped from the serialized output. - Block::create() validates the block name against namespace/block-name. - Json::decodeObject() returns null (-> opaque) for a top-level object whose keys are a plain list ("0","1",...) rather than returning [] and letting the Serializer re-encode it as a corrupting JSON array. - AttrMap::set() rejects empty path segments and keeps an existing stdClass value an object when rewrapping it (no silent object->list flip); AttrMap::get() is updated in step so it can still read back through such an object. - Block::copy() deep-clones stdClass attribute values instead of sharing them. - Json::encode()'s existing JsonException-on-invalid-UTF-8 behaviour is documented. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/BlockTree/AttrMap.php | 19 ++++- src/Integrations/Divi/BlockTree/Block.php | 37 ++++++++++ src/Integrations/Divi/BlockTree/Container.php | 25 ++++++- src/Integrations/Divi/BlockTree/Json.php | 19 ++++- .../Divi/BlockTree/Serializer.php | 3 +- .../Divi/BlockTree/AttrMapTest.php | 59 ++++++++++++++++ .../Integrations/Divi/BlockTree/BlockTest.php | 69 +++++++++++++++++++ .../Divi/BlockTree/ContainerTest.php | 54 +++++++++++++++ .../Integrations/Divi/BlockTree/JsonTest.php | 51 ++++++++++++++ .../Divi/BlockTree/ParserTest.php | 10 +++ 10 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Integrations/Divi/BlockTree/AttrMapTest.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/BlockTest.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/JsonTest.php diff --git a/src/Integrations/Divi/BlockTree/AttrMap.php b/src/Integrations/Divi/BlockTree/AttrMap.php index aa784f6..dd977ad 100644 --- a/src/Integrations/Divi/BlockTree/AttrMap.php +++ b/src/Integrations/Divi/BlockTree/AttrMap.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\BlockTree; +use InvalidArgumentException; use stdClass; /** @@ -52,6 +53,9 @@ final class AttrMap { $value = $attrs; foreach (explode('.', $path) as $segment) { + if ($value instanceof stdClass) { + $value = (array) $value; + } if (! is_array($value) || ! array_key_exists($segment, $value)) { return null; } @@ -76,10 +80,15 @@ final class AttrMap /** * @param array $attrs * @return array + * @throws InvalidArgumentException When $path has an empty segment ("a..b", ".a", "a."). */ public static function set(array $attrs, string $path, mixed $value): array { $segments = explode('.', $path); + if (in_array('', $segments, true)) { + throw new InvalidArgumentException(sprintf('"%s" has an empty path segment.', $path)); + } + $key = (string) array_shift($segments); if ($segments === []) { $attrs[$key] = $value; @@ -87,9 +96,13 @@ final class AttrMap return $attrs; } - $child = $attrs[$key] ?? []; - $child = $child instanceof stdClass ? (array) $child : (is_array($child) ? $child : []); - $attrs[$key] = self::set($child, implode('.', $segments), $value); + $existing = $attrs[$key] ?? null; + $wasObject = $existing instanceof stdClass; + $child = $wasObject ? (array) $existing : (is_array($existing) ? $existing : []); + $child = self::set($child, implode('.', $segments), $value); + // Keep an object an object (spec §5.2/Json.php): a numeric-looking key like "0" would + // otherwise silently turn $child into a PHP list and re-encode as a JSON array. + $attrs[$key] = $wasObject ? (object) $child : $child; return $attrs; } diff --git a/src/Integrations/Divi/BlockTree/Block.php b/src/Integrations/Divi/BlockTree/Block.php index 532f8ae..e7212e2 100644 --- a/src/Integrations/Divi/BlockTree/Block.php +++ b/src/Integrations/Divi/BlockTree/Block.php @@ -4,6 +4,10 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\BlockTree; +use InvalidArgumentException; +use LogicException; +use stdClass; + /** * One block of the tree. A parsed block keeps its original opener/closer bytes; they are * dropped only when the block itself changes (setAttrs(), a void block getting children), @@ -15,6 +19,9 @@ namespace LIT\MCP\Integrations\Divi\BlockTree; */ final class Block extends Container { + /** A valid block name: "namespace/block-name" (WordPress' own block name convention). */ + private const NAME_PATTERN = '/^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$/'; + /** * @param array $attrs */ @@ -42,6 +49,10 @@ final class Block extends Container */ public static function create(string $name, array $attrs = [], array $children = []): self { + if (preg_match(self::NAME_PATTERN, $name) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name)); + } + $block = new self($name, $attrs, null, null, false, false); foreach ($children as $child) { $block->parts[] = $child; @@ -77,6 +88,10 @@ final class Block extends Container /** @param array $attrs */ public function setAttrs(array $attrs): void { + if ($this->opaque) { + throw new LogicException('Cannot set attributes of an opaque block.'); + } + $this->attrs = $attrs; $this->rawOpen = null; } @@ -125,6 +140,28 @@ final class Block extends Container $this->parts[$position] = clone $part; } } + $this->attrs = self::deepCloneAttrs($this->attrs); + } + + /** + * PHP's own array-copy-on-write already deep-copies nested arrays, but stdClass values + * (Json.php keeps empty/list-like JSON objects as stdClass) are objects – clone() alone + * would leave the copy's attrs() sharing them with the original. + * + * @param array $attrs + * @return array + */ + private static function deepCloneAttrs(array $attrs): array + { + foreach ($attrs as $key => $value) { + if ($value instanceof stdClass) { + $attrs[$key] = (object) self::deepCloneAttrs((array) $value); + } elseif (is_array($value)) { + $attrs[$key] = self::deepCloneAttrs($value); + } + } + + return $attrs; } protected function touched(): void diff --git a/src/Integrations/Divi/BlockTree/Container.php b/src/Integrations/Divi/BlockTree/Container.php index 08cd5f1..610e58b 100644 --- a/src/Integrations/Divi/BlockTree/Container.php +++ b/src/Integrations/Divi/BlockTree/Container.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\BlockTree; use InvalidArgumentException; +use LogicException; /** * Ordered parts of a document or of a block's inner content: blocks and the raw text @@ -28,9 +29,21 @@ abstract class Container return array_values(array_filter($this->parts, static fn (string|Block $part): bool => $part instanceof Block)); } - /** Used by the Parser: adds a part without marking the container as changed. */ + /** + * Adds a part without marking the container as changed – the Parser's own way of + * building the tree (a block is appended to its container immediately, before parsing + * knows whether it stays void). + * + * @internal Parser only. Any other caller adding a child must use insertChild(), which + * also converts a void block so the new child is not silently dropped again + * (Serializer::block() never descends into a void block's parts()). + */ public function append(string|Block $part): void { + if ($this instanceof Block && $this->isVoid()) { + throw new LogicException('Cannot append to a void block; use insertChild() instead.'); + } + $this->parts[] = $part; } @@ -48,6 +61,7 @@ abstract class Container */ public function insertChild(int $index, Block $block): void { + $this->guardMutable(); $children = $this->children(); $index = max(0, min($index, count($children))); if ($index < count($children)) { @@ -65,6 +79,7 @@ abstract class Container /** Removes the child block; the text around it stays. Returns its former child index. */ public function removeChild(Block $block): int { + $this->guardMutable(); $index = $this->indexOf($block); if ($index === null) { throw new InvalidArgumentException('Block is not a child of this container.'); @@ -81,6 +96,14 @@ abstract class Container { } + /** insertChild()/removeChild() are the tools' structural edits – never on an opaque block. */ + private function guardMutable(): void + { + if ($this instanceof Block && $this->isOpaque()) { + throw new LogicException('Cannot modify the children of an opaque block.'); + } + } + private function partPosition(Block $block): int { $position = array_search($block, $this->parts, true); diff --git a/src/Integrations/Divi/BlockTree/Json.php b/src/Integrations/Divi/BlockTree/Json.php index f865d34..e479df9 100644 --- a/src/Integrations/Divi/BlockTree/Json.php +++ b/src/Integrations/Divi/BlockTree/Json.php @@ -15,7 +15,11 @@ use stdClass; */ final class Json { - /** @return array|null Null when $json is not a JSON object. */ + /** + * @return array|null Null when $json is not a JSON object, or its top level + * is one only nominally (keys "0", "1", … – see the class docblock): re-encoding those + * keys as a PHP array would silently turn the object back into a JSON list. + */ public static function decodeObject(string $json): ?array { try { @@ -28,6 +32,11 @@ final class Json return null; } + $keys = get_object_vars($value); + if ($keys !== [] && array_is_list($keys)) { + return null; + } + $converted = self::fromJson($value); return is_array($converted) ? $converted : []; @@ -52,7 +61,13 @@ final class Json return $value; } - /** @throws JsonException */ + /** + * @throws JsonException When $value cannot be represented as JSON – including a string + * with an invalid UTF-8 byte sequence (JSON_ERROR_UTF8). 2a only ever encodes + * attributes this plugin already decoded from valid block markup, so that never + * happens here; a caller that later encodes user-supplied strings (2b's writers) + * must catch this and map it to a validation error instead of a fatal. + */ public static function encode(mixed $value): string { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). diff --git a/src/Integrations/Divi/BlockTree/Serializer.php b/src/Integrations/Divi/BlockTree/Serializer.php index b298530..76d55ee 100644 --- a/src/Integrations/Divi/BlockTree/Serializer.php +++ b/src/Integrations/Divi/BlockTree/Serializer.php @@ -8,7 +8,8 @@ namespace LIT\MCP\Integrations\Divi\BlockTree; * Turns a Document back into block markup. Unchanged blocks reuse their original * delimiters; changed or new blocks get delimiters generated exactly like WordPress' * get_comment_delimited_block_content() / serialize_block_attributes() do, which also - * yields the escaping Divi expects inside $variable(…)$ payloads (" → "). + * yields the escaping Divi expects inside $variable(…)$ payloads: json_encode()'s own + * \" (an escaped quote inside a JSON string) is replaced by ", see ESCAPES below. */ final class Serializer { diff --git a/tests/Unit/Integrations/Divi/BlockTree/AttrMapTest.php b/tests/Unit/Integrations/Divi/BlockTree/AttrMapTest.php new file mode 100644 index 0000000..1ae665a --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/AttrMapTest.php @@ -0,0 +1,59 @@ + */ + public static function emptySegmentPaths(): array + { + return [ + 'double dot' => ['a..b'], + 'leading dot' => ['.a'], + 'trailing dot' => ['a.'], + 'empty' => [''], + ]; + } + + /** @dataProvider emptySegmentPaths */ + public function testSetRejectsEmptyPathSegments(string $path): void + { + $this->expectException(InvalidArgumentException::class); + + AttrMap::set([], $path, 'z'); + } + + public function testSetKeepsAnObjectAnObjectWhenRewrapping(): void + { + $attrs = ['a' => new stdClass()]; + + $changed = AttrMap::set($attrs, 'a.0', 'z'); + + self::assertInstanceOf(stdClass::class, $changed['a']); + self::assertSame('z', $changed['a']->{'0'}); + self::assertSame('{"a":{"0":"z"}}', json_encode($changed)); + } + + public function testSetStillBuildsPlainArraysWhenNoObjectExisted(): void + { + $changed = AttrMap::set([], 'a.b', 'z'); + + self::assertSame(['a' => ['b' => 'z']], $changed); + } + + /** get() must still read back through a value set() kept as an object (regression guard). */ + public function testGetReadsThroughAnObjectRewrappedBySet(): void + { + $attrs = ['a' => new stdClass()]; + $changed = AttrMap::set($attrs, 'a.b', 'z'); + + self::assertSame('z', AttrMap::get($changed, 'a.b')); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php new file mode 100644 index 0000000..ba1b31a --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php @@ -0,0 +1,69 @@ + */ + public static function invalidNames(): array + { + return [ + 'no namespace' => ['button'], + 'trailing markup injection' => ['divi/x -->')); + self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', '')); + self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px')); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + $writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$') + ); + self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x'])); + } + + /** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */ + public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void + { + $writer = ComposeFixture::attrWriter(); + + // Unknown kind, URL keys deep inside the value. + self::assertSame( + ['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]], + $writer->cleanValue('divi/text', 'module.decoration.background', [ + 'color' => '#fff', + 'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']], + ]) + ); + self::assertSame( + ['url' => '', 'target' => 'on'], + $writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => 'on']) + ); + self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [ + 'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()', + ])); + // Rich text keeps markup, text kind strips it, unknown innerContent is text too. + self::assertSame('

    Hallo Welt

    ', $writer->cleanValue('divi/text', 'content.innerContent', '

    Hallo Welt

    ')); + self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '

    Titel

    ')); + self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', 'Los', 'text')); + // Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url). + self::assertSame( + ['text' => 'Mehr', 'linkUrl' => ''], + $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [ + 'text' => 'Mehr', + 'linkUrl' => 'javascript:alert(1)', + ]) + ); + self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl')); + // An explicit sub-kind wins over the key name; the image module's alt is text, src a URL. + self::assertSame( + ['useIcon' => 'on', 'src' => ''], + $writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x']) + ); + self::assertSame( + ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'], + $writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team']) + ); + // Numbers and booleans are no strings: unchanged. + self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true])); + } + + /** + * Ruling S6: each malicious reference fails and nothing is stored. + * + * @return array, array}> + */ + public static function malicious(): array + { + $cases = []; + foreach (VariableRefTest::malicious() as $name => [$value]) { + $cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT]; + } + + $cases['embedded reference in rich text'] = [ + 'divi/text', + 'content.innerContent', + ['desktop' => '

    Hallo $variable({"type":"content","value":{"name":"post_title","settings":{}}})$

    '], + self::TEXT, + ]; + $cases['embedded reference deep in a value'] = [ + 'divi/text', + 'module.decoration.background', + ['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]], + self::TEXT, + ]; + $cases['javascript via a reference in a URL field'] = [ + 'divi/fullwidth-header', + 'buttonOne.innerContent', + ['desktop' => [ + 'text' => 'Los', + 'linkUrl' => '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$', + ]], + [], + ]; + $cases['javascript via a reference in a URL key'] = [ + 'divi/text', + 'module.advanced.link', + ['desktop' => ['url' => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":{"before":"javascript:","after":""}}})$']], + self::TEXT, + ]; + + return $cases; + } + + /** + * @dataProvider malicious + * @param array $map + * @param array $attrs + */ + public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void + { + $result = ComposeFixture::attrWriter(true)->apply($module, $attrs, [$path => $map], 'attrs'); + + self::assertArrayHasKey('attrs.' . $path, $result['errors']); + self::assertSame($attrs, $result['attrs']); + } + + public function testEmbeddedReferenceNamesTheRule(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Verweise nur allein im Wert'); + + ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b'); + } + + /** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */ + public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void + { + $changes = [ + 'module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'data-x', 'value' => '1']]]], + 'module.advanced.html' => ['desktop' => ['elementType' => 'section']], + ]; + + $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame(self::TEXT, $editor['attrs']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.attributes']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.advanced.html']); + + $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.advanced.html' => null], 'attrs'); + self::assertArrayHasKey('attrs.module.advanced.html', $removal['errors']); + + $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame([], $admin['errors']); + self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']); + self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('nur im Divi-Builder'); + ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]); + } + + /** Ruling S13: keys inside value maps are plain identifiers. */ + public function testRejectsOddKeysInsideValues(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ + 'module.decoration.spacing' => ['desktop' => ['' => '1px']], + ], 'attrs'); + + self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']); + self::assertSame(self::TEXT, $result['attrs']); + } + + public function testRejectsInvalidUtf8(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ + 'content.innerContent' => ['desktop' => "

    \xC3\x28

    "], + ], 'attrs'); + + self::assertStringContainsString('UTF-8', $result['errors']['attrs.content.innerContent']); + } + + public function testReferencesFromAnEarlierReadAreReserialized(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ + 'module.decoration.background' => ['desktop' => [ + 'color' => '$variable({"value":{"name":"gcid-a1b2","settings":[]},"type":"color"})$', + ]], + ], 'attrs'); + + self::assertSame([], $result['errors']); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + $result['attrs']['module']['decoration']['background']['desktop']['value']['color'] + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php new file mode 100644 index 0000000..4824b81 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -0,0 +1,78 @@ +]*>.*?#is', '', $value); + } + + public function code(string $value): string + { + return $value; + } + + public function url(string $value): string + { + return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value); + } + + public function allowsUnfilteredHtml(): bool + { + return $this->unfiltered; + } + }; + } + + public static function media(): MediaResolver + { + return new class implements MediaResolver { + public function image(int $id): ?array + { + return match ($id) { + 33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'], + 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''], + default => null, + }; + } + }; + } + + public static function placeholders(): Placeholders + { + return new Placeholders(DesignSystemTest::sample()); + } + + public static function attrWriter(bool $unfiltered = false): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered)); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php new file mode 100644 index 0000000..4095d71 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -0,0 +1,136 @@ + */ + public static function placeholders(): array + { + return [ + 'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], + 'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + 'color id' => ['{{color:gcid-a1b2}}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + 'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'], + 'variable id' => ['{{var:gvid-s1}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'], + 'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'], + 'post title' => [ + '{{post:title}}', + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + ], + 'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'], + 'custom field' => [ + '{{field:telefon_mobil}}', + '$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$', + ], + 'plain text' => ['Hallo Welt', 'Hallo Welt'], + ]; + } + + /** @dataProvider placeholders */ + public function testResolvesToDiviVariables(string $input, string $expected): void + { + $resolved = ComposeFixture::placeholders()->resolve($input); + + self::assertSame($expected, $resolved); + self::assertSame($expected !== $input, Placeholders::isVariable($resolved)); + } + + /** @return array */ + public static function invalid(): array + { + return [ + 'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'], + 'unknown color id' => ['{{color:gcid-nope}}', 'Unbekannte globale Farbe'], + 'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'], + 'unknown font' => ['{{font:mono}}', 'heading, body'], + 'unknown post value' => ['{{post:views}}', 'title, excerpt'], + 'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'], + 'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'], + 'embedded' => ['Seite: {{post:title}}', 'allein im Wert'], + ]; + } + + /** @dataProvider invalid */ + public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + ComposeFixture::placeholders()->resolve($input); + } + + /** @return array */ + public static function ambiguous(): array + { + return [ + 'color label' => ['{{color:blau}}', 'gcid-b1, gcid-b2'], + 'variable label' => ['{{var:Info}}', 'gvid-i1, gvid-i2'], + ]; + } + + /** + * Ruling S13a: a label two entries share never silently picks the first one; IDs work directly. + * + * @dataProvider ambiguous + */ + public function testAmbiguousLabelsFailWithTheIds(string $input, string $ids): void + { + $placeholders = new Placeholders(new DesignSystem( + [ + ['id' => 'gcid-b1', 'label' => 'Blau', 'value' => '#00f'], + ['id' => 'gcid-b2', 'label' => 'blau', 'value' => '#00a'], + ], + [ + ['id' => 'gvid-i1', 'type' => 'strings', 'label' => 'Info', 'value' => 'a'], + ['id' => 'gvid-i2', 'type' => 'strings', 'label' => 'Info', 'value' => 'b'], + ], + [], + ['heading' => '', 'body' => ''], + [] + )); + + try { + $placeholders->resolve($input); + self::fail('Ambiguous label accepted.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('mehrdeutig', $e->getMessage()); + self::assertStringContainsString($ids, $e->getMessage()); + } + + self::assertStringContainsString('"gcid-b2"', $placeholders->resolve('{{color:gcid-b2}}')); + self::assertStringContainsString('"gvid-i2"', $placeholders->resolve('{{var:gvid-i2}}')); + } + + public function testColorTypeVariablesBecomeColorReferences(): void + { + $placeholders = new Placeholders(new DesignSystem( + [], + [['id' => 'gvid-c1', 'type' => 'colors', 'label' => 'Akzent', 'value' => '#f00']], + [], + ['heading' => '', 'body' => ''], + [] + )); + + self::assertSame( + '$variable({"type":"color","value":{"name":"gvid-c1","settings":{}}})$', + $placeholders->resolve('{{var:Akzent}}') + ); + } + + public function testResolvesDeepValues(): void + { + self::assertSame( + ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'], + ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px']) + ); + self::assertFalse(Placeholders::isVariable('$variable(kaputt)$')); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php new file mode 100644 index 0000000..1385378 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -0,0 +1,146 @@ + */ + public static function valid(): array + { + return [ + 'reordered keys' => [ + '$variable({"value":{"settings":{},"name":"gcid-a1b2"},"type":"color"})$', + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + ], + 'empty list settings, no settings' => [ + '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":[]}})$', + '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', + ], + 'missing settings' => [ + '$variable({"type":"content","value":{"name":"gvid-r1"}})$', + '$variable({"type":"content","value":{"name":"gvid-r1","settings":{}}})$', + ], + 'color settings numeric' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$', + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$', + ], + 'post date with format and html before' => [ + '$variable({"type":"content","value":{"name":"post_date","settings":{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$', + '$variable({"type":"content","value":{"name":"post_date","settings":{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$', + ], + 'custom meta' => [ + '$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$', + '$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$', + ], + 'theme font' => [ + '$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$', + '$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$', + ], + ]; + } + + /** @dataProvider valid */ + public function testReserializesValidReferences(string $input, string $expected): void + { + self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer())); + } + + /** + * Hand-crafted attacks (preflight §4 "Malicious $variable tests"). + * + * @return array Value => part of the German error. + */ + public static function malicious(): array + { + $content = static fn (string $name, string $settings): string => + '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$'; + + return [ + 'script in before' => [$content('post_title', '{"before":"","after":""}'), 'before'], + 'script in after' => [$content('post_title', '{"before":"","after":""}'), 'after'], + 'escaped script in before' => [ + $content('post_title', '{"before":"","after":""}'), + 'before', + ], + 'tags in date format' => [$content('post_date', '{"date_format":"d"}'), 'date_format'], + 'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'], + 'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'], + 'unknown gvid' => [$content('gvid-nope', '{}'), 'gvid-nope'], + 'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'], + 'color gcid as content' => [$content('gcid-a1b2', '{}'), 'gcid-a1b2'], + 'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'], + 'name with style breakout' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2;}","settings":{}}})$', + 'Name', + ], + 'unknown settings key' => [$content('post_title', '{"before":"","foo":"1"}'), 'foo'], + 'enable_html' => [$content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], + 'color setting not numeric' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}"}}})$', + 'opacity', + ], + 'protected meta key' => [$content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], + 'unknown dynamic content' => [$content('post_meta_key', '{}'), 'post_meta_key'], + 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], + 'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'], + 'greedy smuggling' => ['$variable({"a":"})$"})$', 'allein im Wert'], + 'nested reference in before' => [ + $content('post_title', '{"before":"$variable(x)$","after":""}'), + 'allein im Wert', + ], + 'broken json' => ['$variable(kaputt)$', 'JSON'], + 'trailing text' => [$content('post_title', '{}') . ' ', 'allein im Wert'], + 'embedded in text' => ['

    Hi ' . $content('post_title', '{"before":"","after":""}') . '

    ', 'allein im Wert'], + ]; + } + + /** @dataProvider malicious */ + public function testRejectsMaliciousReferences(string $input, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + self::ref()->canonical($input, ComposeFixture::sanitizer()); + } + + public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void + { + $ref = self::ref(); + $link = '$variable({"type":"content","value":{"name":"post_link_url","settings":{}}})$'; + + self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true)); + + foreach ( + [ + '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$', + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + ] as $value + ) { + try { + $ref->canonical($value, ComposeFixture::sanitizer(), true); + self::fail('Accepted in a URL field: ' . $value); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('URL', $e->getMessage()); + } + } + } + + public function testDetectsReferences(): void + { + self::assertTrue(VariableRef::contains('a $variable( b')); + self::assertFalse(VariableRef::contains('Preis: 5 $')); + } +} From 718069061161f0367c0a43fae1d06f5ff68cddfd Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 13:37:56 +0200 Subject: [PATCH 107/185] fix: Task 1 review round 1 - escaped )$ in reference settings, allow map, routing rulings I-1: reject )$ in every decoded string setting and in the re-serialized JSON. S21: string settings cleaned and re-serialized. S22: injectable dynamic-content allow map (name => setting => field type), loop_* only via the map. S23: unknown content.innerContent is rich text. S24: keys below scalar kinds are unknown, ...Url path segments are URLs. S25: isVariable removed. M-1: real \u escapes in tests, malicious suite as editor, fixture html() raw for unfiltered_html. M-2: S7 gate at any depth plus css.advanced.customCss. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/AttrWriter.php | 44 +++- .../Divi/Compose/Placeholders.php | 16 +- src/Integrations/Divi/Compose/VariableRef.php | 245 ++++++++++++------ .../Divi/Compose/AttrWriterTest.php | 54 +++- .../Divi/Compose/ComposeFixture.php | 3 +- .../Divi/Compose/PlaceholdersTest.php | 9 +- .../Divi/Compose/VariableRefTest.php | 164 +++++++++--- 7 files changed, 393 insertions(+), 142 deletions(-) diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index c6d0dab..2c2e528 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -31,8 +31,14 @@ final class AttrWriter /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */ private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/'; - /** Paths Divi re-serializes whole pages for, and that carry raw HTML (ruling S7). */ - private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html)$/'; + /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ + private const URL_SEGMENT = '/url$/i'; + + /** + * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (ruling S7), at any + * depth: module.advanced.html, module.advanced.html.htmlBefore, css.advanced.customCss. + */ + private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss)(\.|$)/'; public function __construct( private ModuleCatalog $catalog, @@ -131,7 +137,7 @@ final class AttrWriter { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { throw new InvalidArgumentException(sprintf( - '"%s" (eigene HTML-Attribute und -Elemente) ist nur im Divi-Builder änderbar ' + '"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar ' . '(oder mit dem Recht unfiltered_html).', $path )); @@ -185,24 +191,46 @@ final class AttrWriter } /** - * Sanitizer for a string (ruling S3/S3a): the catalog kind of the path – or of the - * sub-field for a composite path – decides; an unresolved kind falls back to the key name - * (URL keys at any depth), else text – never laxer than text. + * Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the + * sub-field for a composite path – decides. A string reached through a key below a path + * with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys + * at any depth), then to the last path segment (…Url), then content.innerContent → rich + * text (S23: fields Divi defines only in JS), else text – never laxer than text. * * @return 'html'|'code'|'url'|'text' */ private function kind(string $module, string $path, ?string $subName, ?string $key): string { // "unknown" also for a composite path whose sub-kinds do not name $subName. - return match ($this->catalog->pathKind($module, $path, $subName)) { + $kind = $this->catalog->pathKind($module, $path, $subName); + if ($key !== null && is_string($this->catalog->kinds($module)[$path] ?? null)) { + $kind = 'unknown'; + } + + return match ($kind) { 'richtext' => 'html', 'code' => 'code', 'url', 'image' => 'url', - 'unknown' => $key !== null && preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text', + 'unknown' => $this->unknownKind($path, $key), default => 'text', }; } + /** @return 'html'|'url'|'text' */ + private function unknownKind(string $path, ?string $key): string + { + if ($key !== null) { + return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text'; + } + + $segments = explode('.', $path); + if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) { + return 'url'; + } + + return $path === 'content.innerContent' ? 'html' : 'text'; + } + /** * @param array> $normalized * @return array> diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php index 46232a4..5bbfa7d 100644 --- a/src/Integrations/Divi/Compose/Placeholders.php +++ b/src/Integrations/Divi/Compose/Placeholders.php @@ -38,21 +38,13 @@ final class Placeholders private VariableRef $references; - public function __construct(private DesignSystem $design) - { - $this->references = new VariableRef($design); - } - /** - * Whether $value has the shape of one Divi variable reference ($variable({…})$), e.g. from an - * earlier read. A shape check only – never a reason to store a value unchecked: that is - * VariableRef::canonical() (ruling S1). + * @param array>|null $dynamicContent Divi's dynamic content + * options for VariableRef (ruling S22); null = VariableRef::DEFAULT_DYNAMIC_CONTENT. */ - public static function isVariable(string $value): bool + public function __construct(private DesignSystem $design, ?array $dynamicContent = null) { - return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1 - && ! str_contains($match[1], ')$') - && is_array(json_decode($match[1], true)); + $this->references = new VariableRef($design, $dynamicContent); } /** The strict checker of $variable(…)$ values against this design system (ruling S1). */ diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index eafdbb7..65fc4f4 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -12,12 +12,29 @@ use stdClass; /** * Divi's $variable({…})$ references (global colors, global variables, theme fonts, dynamic - * content). Divi saves bypass kses (D7), and Divi resolves references anywhere in a string and - * escapes before/after only for authors without unfiltered_html – so no client-supplied - * reference is ever stored as sent (ruling S1). canonical() parses one, checks it strictly - * against the live design system and re-serializes it; everything else fails with a German - * message (the abilities report validation_failed). A reference must be the whole value (D9, - * ruling S2). Pure PHP. + * content). Divi saves bypass kses (D7), Divi resolves references anywhere in a string with + * the non-greedy /\$variable\((.+?)\)\$/ and escapes before/after only for authors without + * unfiltered_html – so no client-supplied reference is ever stored as sent (ruling S1). + * canonical() parses one, checks it strictly, cleans its string settings (S21) and + * re-serializes it; everything else fails with a German message (the abilities report + * validation_failed). A reference must be the whole value (D9, ruling S2). Pure PHP. + * + * Allowed names: + * - type "color": a global color ID of the design system or a global variable of type "colors"; + * settings hue/saturation/lightness/opacity, numbers only. + * - type "content": a non-color global variable or a theme font (settings DESIGN_SETTINGS); + * custom_meta_ with META_KEY (no leading "_", ruling S12; settings from the map entry of + * that exact name, else META_SETTINGS); or a key of the dynamic content map. + * + * Dynamic content map (ruling S22), constructor argument $dynamicContent: + * array> option name => [setting key => field type] + * e.g. ['post_title' => ['before' => 'text', 'after' => 'text'], 'post_date' => [… 'date_format' => 'select']] + * Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); anything else + * (number, select, …) → a scalar without markup ("<", ">"), strings cleaned with text(). + * Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry + * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => + * field "type"). Always, whatever the map says: "enable_html" is rejected, loop_* names only + * when the map names them, no string may contain a reference or ")$". */ final class VariableRef { @@ -26,20 +43,44 @@ final class VariableRef /** custom_meta_: no leading "_" – Divi renders protected meta publicly (ruling S12). */ public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/'; + /** @var array> Placeholders::POST values and their settings. */ + public const DEFAULT_DYNAMIC_CONTENT = [ + 'post_title' => self::BEFORE_AFTER, + 'post_excerpt' => self::BEFORE_AFTER, + 'post_date' => self::BEFORE_AFTER + self::DATE, + 'post_modified_date' => self::BEFORE_AFTER + self::DATE, + 'post_author' => self::BEFORE_AFTER, + 'post_link_url' => [], + 'post_featured_image' => [], + 'post_categories' => self::BEFORE_AFTER, + 'post_tags' => self::BEFORE_AFTER, + ]; + + /** Settings of custom_meta_ without a map entry of its own. */ + public const META_SETTINGS = self::BEFORE_AFTER; + + /** Settings of content references to global variables and theme fonts. */ + public const DESIGN_SETTINGS = self::BEFORE_AFTER; + + private const BEFORE_AFTER = ['before' => 'richtext', 'after' => 'richtext']; + + private const DATE = ['date_format' => 'select', 'custom_date_format' => 'text']; + + private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity']; + private const WHOLE = '/^\$variable\((.*)\)\$$/s'; private const META_PREFIX = 'custom_meta_'; - private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity']; - - private const HTML_SETTINGS = ['before', 'after']; - - private const TEXT_SETTINGS = ['date_format', 'custom_date_format']; - private const NUMBER = '/^-?\d+(\.\d+)?$/'; - public function __construct(private DesignSystem $design) + /** @var array> */ + private array $dynamicContent; + + /** @param array>|null $dynamicContent See the class docblock. */ + public function __construct(private DesignSystem $design, ?array $dynamicContent = null) { + $this->dynamicContent = $dynamicContent ?? self::DEFAULT_DYNAMIC_CONTENT; } /** Whether $value contains a reference anywhere (such a value may only be a whole reference). */ @@ -55,9 +96,13 @@ final class VariableRef $settings = new stdClass(); } - $payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]; + $json = Json::encode(['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]); + if (str_contains($json, ')$')) { + // Divi would end the reference there (non-greedy match) – never write that. + throw self::notAlone(); + } - return '$variable(' . Json::encode($payload) . ')$'; + return '$variable(' . $json . ')$'; } /** @@ -96,9 +141,11 @@ final class VariableRef throw new InvalidArgumentException('In URL-Feldern sind nur Inhaltsverweise (type "content") erlaubt.'); } - $this->checkName($type, $name); + $clean = $type === 'color' + ? $this->colorSettings($name, $settings) + : $this->contentSettings($name, $settings, $sanitizer, $url); - return self::serialize($type, $name, self::settings($type, $settings, $sanitizer, $url)); + return self::serialize($type, $name, $clean); } public static function notAlone(): InvalidArgumentException @@ -142,23 +189,11 @@ final class VariableRef } } - private function checkName(string $type, string $name): void - { - $allowed = $type === 'color' ? $this->colorNames() : $this->contentNames(); - $meta = $type === 'content' && str_starts_with($name, self::META_PREFIX) - && preg_match(self::META_KEY, substr($name, strlen(self::META_PREFIX))) === 1; - if (! $meta && ! in_array($name, $allowed, true)) { - throw new InvalidArgumentException(sprintf( - 'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, ' - . 'Theme-Schriften, Beitragswerte und custom_meta_ ohne führendes "_").', - $name, - $type - )); - } - } - - /** @return list Global color IDs plus color-type global variables. */ - private function colorNames(): array + /** + * @param array $settings + * @return array + */ + private function colorSettings(string $name, array $settings): array { $names = array_column($this->design->colors(), 'id'); foreach ($this->design->variables() as $variable) { @@ -166,82 +201,122 @@ final class VariableRef $names[] = $variable['id']; } } - - return $names; - } - - /** @return list */ - private function contentNames(): array - { - $names = array_merge(array_values(DesignSystem::FONT_VARIABLES), array_values(Placeholders::POST)); - foreach ($this->design->variables() as $variable) { - if ($variable['type'] !== 'colors') { - $names[] = $variable['id']; - } + if (! in_array($name, $names, true)) { + throw self::unknownName($name, 'color'); } - return $names; + $clean = []; + foreach ($settings as $key => $value) { + $key = (string) $key; + if (! in_array($key, self::COLOR_SETTINGS, true)) { + throw self::unknownSetting($key, self::COLOR_SETTINGS); + } + $numeric = is_string($value) ? preg_match(self::NUMBER, $value) === 1 : is_int($value) || is_float($value); + if (! $numeric) { + throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Zahlen.', $key)); + } + $clean[$key] = $value; + } + + return $clean; } /** * @param array $settings * @return array */ - private static function settings(string $type, array $settings, ValueSanitizer $sanitizer, bool $url): array + private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array { + $fields = $this->contentFields($name); $clean = []; foreach ($settings as $key => $value) { $key = (string) $key; - $clean[$key] = $type === 'color' - ? self::colorSetting($key, $value) - : self::contentSetting($key, $value, $sanitizer, $url); + if ($key === 'enable_html' || ! array_key_exists($key, $fields)) { + throw self::unknownSetting($key, array_keys($fields)); + } + if ($url && ($key === 'before' || $key === 'after') && $value !== '') { + throw new InvalidArgumentException(sprintf('"%s" im Divi-Verweis: in URL-Feldern verboten.', $key)); + } + $clean[$key] = self::setting($key, $value, $fields[$key], $sanitizer); } return $clean; } - private static function colorSetting(string $key, mixed $value): int|float|string + /** + * @return array Setting key => field type allowed for the content name. + * @throws InvalidArgumentException When the name is not allowed. + */ + private function contentFields(string $name): array { - if (! in_array($key, self::COLOR_SETTINGS, true)) { - throw self::unknownSetting($key, self::COLOR_SETTINGS); + if (isset($this->dynamicContent[$name]) && is_array($this->dynamicContent[$name])) { + return $this->dynamicContent[$name]; } - if (is_int($value) || is_float($value) || (is_string($value) && preg_match(self::NUMBER, $value) === 1)) { - return $value; + if (str_starts_with($name, self::META_PREFIX)) { + if (preg_match(self::META_KEY, substr($name, strlen(self::META_PREFIX))) !== 1) { + throw self::unknownName($name, 'content'); + } + + return self::META_SETTINGS; } - throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis muss eine Zahl sein.', $key)); - } - - private static function contentSetting(string $key, mixed $value, ValueSanitizer $sanitizer, bool $url): string - { - $isHtml = in_array($key, self::HTML_SETTINGS, true); - if (! $isHtml && ! in_array($key, self::TEXT_SETTINGS, true)) { - throw self::unknownSetting($key, array_merge(self::HTML_SETTINGS, self::TEXT_SETTINGS)); - } - - if (! is_string($value)) { - throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis muss Text sein.', $key)); - } - - if (self::contains($value)) { - throw self::notAlone(); - } - - if ($isHtml && $url && $value !== '') { - throw new InvalidArgumentException(sprintf('In URL-Feldern ist "%s" im Divi-Verweis nicht erlaubt.', $key)); - } - - // Rejected rather than silently cleaned: the client learns what was not accepted. - if (($isHtml ? $sanitizer->html($value) : $sanitizer->text($value)) !== $value) { + if (str_starts_with($name, 'loop_')) { throw new InvalidArgumentException(sprintf( - 'Einstellung "%s" im Divi-Verweis enthält unzulässiges %s.', - $key, - $isHtml ? 'HTML' : 'Markup' + 'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).', + $name )); } - return $value; + $design = array_values(DesignSystem::FONT_VARIABLES); + foreach ($this->design->variables() as $variable) { + if ($variable['type'] !== 'colors') { + $design[] = $variable['id']; + } + } + if (in_array($name, $design, true)) { + return self::DESIGN_SETTINGS; + } + + throw self::unknownName($name, 'content'); + } + + /** Cleans one content setting by its field type (ruling S21/S22). */ + private static function setting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed + { + if (is_string($value) && (self::contains($value) || str_contains($value, ')$'))) { + throw self::notAlone(); + } + + switch ($fieldType) { + case 'text': + case 'richtext': + case 'html': + if (! is_string($value)) { + throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key)); + } + + return $fieldType === 'text' ? $sanitizer->text($value) : $sanitizer->html($value); + default: + if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) { + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis muss ein einfacher Wert ohne Markup sein.', + $key + )); + } + + return is_string($value) ? $sanitizer->text($value) : $value; + } + } + + private static function unknownName(string $name, string $type): InvalidArgumentException + { + return new InvalidArgumentException(sprintf( + 'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, ' + . 'Theme-Schriften, Divi-Inhaltsquellen und custom_meta_ ohne führendes "_").', + $name, + $type + )); } /** @param list $allowed */ @@ -253,7 +328,7 @@ final class VariableRef 'Unzulässige Einstellung "%s" im Divi-Verweis%s; erlaubt: %s.', $key, $hint, - implode(', ', $allowed) + $allowed === [] ? '(keine)' : implode(', ', $allowed) )); } } diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 33c5821..f2082af 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; use PHPUnit\Framework\TestCase; final class AttrWriterTest extends TestCase @@ -147,7 +148,8 @@ final class AttrWriterTest extends TestCase } /** - * Ruling S6: each malicious reference fails and nothing is stored. + * Ruling S6: each malicious reference fails and nothing is stored (as an editor without + * unfiltered_html). * * @return array, array}> */ @@ -196,12 +198,25 @@ final class AttrWriterTest extends TestCase */ public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void { - $result = ComposeFixture::attrWriter(true)->apply($module, $attrs, [$path => $map], 'attrs'); + $result = ComposeFixture::attrWriter()->apply($module, $attrs, [$path => $map], 'attrs'); self::assertArrayHasKey('attrs.' . $path, $result['errors']); self::assertSame($attrs, $result['attrs']); } + /** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */ + public function testReferenceSettingsAreCleanedForEditors(): void + { + $sent = '$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"Titel: "}}})$'; + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['content.innerContent' => ['desktop' => $sent]], 'attrs'); + + self::assertSame([], $result['errors']); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"Titel: "}}})$', + $result['attrs']['content']['innerContent']['desktop']['value'] + ); + } + public function testEmbeddedReferenceNamesTheRule(): void { $this->expectException(InvalidArgumentException::class); @@ -231,11 +246,46 @@ final class AttrWriterTest extends TestCase self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']); self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']); + // Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2). + foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) { + try { + ComposeFixture::attrWriter()->cleanValue('divi/canvas-portal', $path, 'x'); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + } + self::assertSame('x', ComposeFixture::attrWriter(true)->cleanValue('divi/canvas-portal', 'css.advanced.customCss', 'x')); + self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.htmlish', 'x')); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('nur im Divi-Builder'); ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]); } + /** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */ + public function testUnknownContentInnerContentIsRichText(): void + { + self::assertSame('unknown', ModuleCatalogTest::sampleCatalog()->pathKind('divi/blurb', 'content.innerContent')); + self::assertSame('

    Hallo

    ', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '

    Hallo

    ')); + // Other unknown innerContent stays text. + self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', 'Los')); + } + + /** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */ + public function testKeysBelowScalarKindsAndUrlPathSegments(): void + { + $writer = ComposeFixture::attrWriter(); + + self::assertSame( + ['url' => '', 'text' => 'a'], + $writer->cleanValue('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => 'a']) + ); + self::assertSame('', $writer->cleanValue('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)')); + self::assertSame('', $writer->cleanValue('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)')); + self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', 'Titel')); + } + /** Ruling S13: keys inside value maps are plain identifiers. */ public function testRejectsOddKeysInsideValues(): void { diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 4824b81..25ca5d4 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -30,9 +30,10 @@ final class ComposeFixture return trim(strip_tags($value)); } + /** Like WpValueSanitizer (ruling S4): raw for users with unfiltered_html. */ public function html(string $value): string { - return (string) preg_replace('#]*>.*?#is', '', $value); + return $this->unfiltered ? $value : (string) preg_replace('#]*>.*?#is', '', $value); } public function code(string $value): string diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php index 4095d71..1cfbc4a 100644 --- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -37,10 +37,14 @@ final class PlaceholdersTest extends TestCase /** @dataProvider placeholders */ public function testResolvesToDiviVariables(string $input, string $expected): void { - $resolved = ComposeFixture::placeholders()->resolve($input); + $placeholders = ComposeFixture::placeholders(); + $resolved = $placeholders->resolve($input); self::assertSame($expected, $resolved); - self::assertSame($expected !== $input, Placeholders::isVariable($resolved)); + if ($expected !== $input) { + // What placeholders produce passes the strict checker unchanged (ruling S1/S25). + self::assertSame($resolved, $placeholders->references()->canonical($resolved, ComposeFixture::sanitizer())); + } } /** @return array */ @@ -131,6 +135,5 @@ final class PlaceholdersTest extends TestCase ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'], ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px']) ); - self::assertFalse(Placeholders::isVariable('$variable(kaputt)$')); } } diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 1385378..548412c 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -9,12 +9,24 @@ use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; use PHPUnit\Framework\TestCase; -/** Ruling S1/S2/S12: every $variable(…)$ value is parsed, strictly checked and re-serialized. */ +/** Ruling S1/S2/S12/S21/S22: every $variable(…)$ value is parsed, strictly checked, cleaned and re-serialized. */ final class VariableRefTest extends TestCase { - private static function ref(): VariableRef + /** @param array>|null $dynamicContent */ + private static function ref(?array $dynamicContent = null): VariableRef { - return new VariableRef(DesignSystemTest::sample()); + return new VariableRef(DesignSystemTest::sample(), $dynamicContent); + } + + private static function content(string $name, string $settings): string + { + return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$'; + } + + /** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */ + private static function escape(string $hex): string + { + return chr(92) . 'u' . $hex; } /** @return array */ @@ -38,16 +50,16 @@ final class VariableRefTest extends TestCase '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$', ], 'post date with format and html before' => [ - '$variable({"type":"content","value":{"name":"post_date","settings":{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$', - '$variable({"type":"content","value":{"name":"post_date","settings":{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$', + self::content('post_date', '{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'), + self::content('post_date', '{"before":"Am ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'), ], 'custom meta' => [ - '$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$', - '$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$', + self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'), + self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'), ], 'theme font' => [ - '$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$', - '$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$', + self::content('--et_global_body_font', '{}'), + self::content('--et_global_body_font', '{}'), ], ]; } @@ -58,6 +70,45 @@ final class VariableRefTest extends TestCase self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer())); } + /** + * Ruling S21: string settings are cleaned and the cleaned value is re-serialized. + * + * @return array + */ + public static function cleaned(): array + { + $script = self::escape('003c') . 'script' . self::escape('003e') . 'alert(1)' . self::escape('003c') . '/script' + . self::escape('003e'); + + return [ + 'script in before' => [ + self::content('post_title', '{"before":"Hi","after":""}'), + self::content('post_title', '{"before":"Hi","after":""}'), + ], + 'script in after' => [ + self::content('custom_meta_telefon', '{"before":"","after":"!"}'), + self::content('custom_meta_telefon', '{"before":"","after":"!"}'), + ], + 'escaped script in before' => [ + self::content('post_title', '{"before":"' . $script . 'Hallo","after":""}'), + self::content('post_title', '{"before":"Hallo","after":""}'), + ], + 'tags in date format' => [ + self::content('post_date', '{"custom_date_format":"d.m"}'), + self::content('post_date', '{"custom_date_format":"d.m"}'), + ], + ]; + } + + /** @dataProvider cleaned */ + public function testCleansStringSettings(string $input, string $expected): void + { + $canonical = self::ref()->canonical($input, ComposeFixture::sanitizer()); + + self::assertSame($expected, $canonical); + self::assertStringNotContainsString(' - '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$'; + $end = self::escape('0029') . '$'; return [ - 'script in before' => [$content('post_title', '{"before":"","after":""}'), 'before'], - 'script in after' => [$content('post_title', '{"before":"","after":""}'), 'after'], - 'escaped script in before' => [ - $content('post_title', '{"before":"","after":""}'), - 'before', - ], - 'tags in date format' => [$content('post_date', '{"date_format":"d"}'), 'date_format'], + 'escaped end marker in before' => [self::content('post_title', '{"before":"x' . $end . ' y","after":""}'), 'allein im Wert'], + 'escaped end marker in after' => [self::content('post_title', '{"before":"","after":"' . $end . '"}'), 'allein im Wert'], + 'escaped end marker in date format' => [self::content('post_date', '{"custom_date_format":"d' . $end . '"}'), 'allein im Wert'], + 'escaped end marker in name' => [self::content('post_title' . $end, '{}'), 'Unbekannter Name'], + 'select with markup' => [self::content('post_date', '{"date_format":"d"}'), 'date_format'], 'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'], 'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'], - 'unknown gvid' => [$content('gvid-nope', '{}'), 'gvid-nope'], + 'unknown gvid' => [self::content('gvid-nope', '{}'), 'gvid-nope'], 'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'], - 'color gcid as content' => [$content('gcid-a1b2', '{}'), 'gcid-a1b2'], + 'color gcid as content' => [self::content('gcid-a1b2', '{}'), 'gcid-a1b2'], 'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'], 'name with style breakout' => [ '$variable({"type":"color","value":{"name":"gcid-a1b2;}","settings":{}}})$', 'Name', ], - 'unknown settings key' => [$content('post_title', '{"before":"","foo":"1"}'), 'foo'], - 'enable_html' => [$content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], + 'unknown settings key' => [self::content('post_title', '{"before":"","foo":"1"}'), 'foo'], + 'setting the option lacks' => [self::content('post_featured_image', '{"before":"x"}'), 'before'], + 'enable_html' => [self::content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], 'color setting not numeric' => [ '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}"}}})$', 'opacity', ], - 'protected meta key' => [$content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], - 'unknown dynamic content' => [$content('post_meta_key', '{}'), 'post_meta_key'], + 'protected meta key' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], + 'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'], + 'unknown dynamic content' => [self::content('post_meta_key', '{}'), 'post_meta_key'], 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], 'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'], 'greedy smuggling' => ['$variable({"a":"})$"})$', 'allein im Wert'], - 'nested reference in before' => [ - $content('post_title', '{"before":"$variable(x)$","after":""}'), - 'allein im Wert', - ], + 'nested reference in before' => [self::content('post_title', '{"before":"$variable(x)$","after":""}'), 'allein im Wert'], + 'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'], 'broken json' => ['$variable(kaputt)$', 'JSON'], - 'trailing text' => [$content('post_title', '{}') . ' ', 'allein im Wert'], - 'embedded in text' => ['

    Hi ' . $content('post_title', '{"before":"","after":""}') . '

    ', 'allein im Wert'], + 'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'], + 'embedded in text' => ['

    Hi ' . self::content('post_title', '{"before":"","after":""}') . '

    ', 'allein im Wert'], ]; } @@ -116,6 +164,19 @@ final class VariableRefTest extends TestCase self::ref()->canonical($input, ComposeFixture::sanitizer()); } + /** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */ + public function testEscapedEndMarkerIsBytesNotTheCharacter(): void + { + $input = self::content('post_title', '{"before":"x' . self::escape('0029') . '$","after":""}'); + + self::assertStringNotContainsString(')$ ', $input); + self::assertSame(1, substr_count($input, ')$')); + self::assertStringContainsString("\\u0029", $input); + + $this->expectException(InvalidArgumentException::class); + self::ref()->canonical($input, ComposeFixture::sanitizer()); + } + public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void { $ref = self::ref(); @@ -125,7 +186,7 @@ final class VariableRefTest extends TestCase foreach ( [ - '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$', + self::content('custom_meta_ziel', '{"before":"javascript:alert(1)//","after":""}'), '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', ] as $value ) { @@ -138,6 +199,47 @@ final class VariableRefTest extends TestCase } } + /** Ruling S22: an injected allow map (Divi's live registry in Task 5) decides names and setting types. */ + public function testInjectedDynamicContentMap(): void + { + $ref = self::ref([ + 'site_title' => ['before' => 'text', 'after' => 'richtext'], + 'post_date' => ['date_format' => 'select', 'offset' => 'number'], + 'loop_post_title' => [], + 'custom_meta_preis' => ['before' => 'text', 'enable_html' => 'yes_no_button'], + ]); + $sanitizer = ComposeFixture::sanitizer(); + + self::assertSame( + self::content('site_title', '{"before":"Site","after":"!"}'), + $ref->canonical(self::content('site_title', '{"before":"Site","after":"!"}'), $sanitizer) + ); + self::assertSame( + self::content('post_date', '{"date_format":"d.m.Y","offset":3}'), + $ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer) + ); + self::assertSame(self::content('loop_post_title', '{}'), $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer)); + // Names outside the map: custom_meta_ keeps its rule, the rest is unknown. + self::assertSame( + self::content('custom_meta_telefon', '{"before":"T"}'), + $ref->canonical(self::content('custom_meta_telefon', '{"before":"T"}'), $sanitizer) + ); + + $rejected = [ + 'post_title' => self::content('post_title', '{}'), + 'enable_html' => self::content('custom_meta_preis', '{"enable_html":"on"}'), + 'offset' => self::content('post_date', '{"offset":[1]}'), + ]; + foreach ($rejected as $fragment => $value) { + try { + $ref->canonical($value, $sanitizer); + self::fail('Accepted: ' . $value); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } + } + public function testDetectsReferences(): void { self::assertTrue(VariableRef::contains('a $variable( b')); From eb99b360b47c2b66b47a26717770e8d1616197ba Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 13:39:36 +0200 Subject: [PATCH 108/185] fix: Task 1 review round 2 - custom CSS builder-only, no settings on gvid/font references S26: every css.* path is gated like S7 for users without unfiltered_html. S27: Divi ignores settings on global variable and theme font references, so any settings there are rejected. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/AttrWriter.php | 7 ++++--- src/Integrations/Divi/Compose/VariableRef.php | 11 +++++++--- .../Divi/Compose/AttrWriterTest.php | 20 +++++++++++++++++++ .../Divi/Compose/VariableRefTest.php | 2 ++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 2c2e528..3a71f70 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -35,10 +35,11 @@ final class AttrWriter private const URL_SEGMENT = '/url$/i'; /** - * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (ruling S7), at any - * depth: module.advanced.html, module.advanced.html.htmlBefore, css.advanced.customCss. + * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by + * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, + * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). */ - private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss)(\.|$)/'; + private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; public function __construct( private ModuleCatalog $catalog, diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 65fc4f4..eb7a945 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -22,7 +22,7 @@ use stdClass; * Allowed names: * - type "color": a global color ID of the design system or a global variable of type "colors"; * settings hue/saturation/lightness/opacity, numbers only. - * - type "content": a non-color global variable or a theme font (settings DESIGN_SETTINGS); + * - type "content": a non-color global variable or a theme font (no settings, DESIGN_SETTINGS, S27); * custom_meta_ with META_KEY (no leading "_", ruling S12; settings from the map entry of * that exact name, else META_SETTINGS); or a key of the dynamic content map. * @@ -59,8 +59,13 @@ final class VariableRef /** Settings of custom_meta_ without a map entry of its own. */ public const META_SETTINGS = self::BEFORE_AFTER; - /** Settings of content references to global variables and theme fonts. */ - public const DESIGN_SETTINGS = self::BEFORE_AFTER; + /** + * Settings of content references to global variables and theme fonts: none (ruling S27). + * Divi ignores them: theme fonts resolve to var() (DynamicData.php:300), and the gvid + * option "doesn't have any settings" and renders without them + * (DynamicContentGlobalVariableOptions.php:66-69, 155-161). + */ + public const DESIGN_SETTINGS = []; private const BEFORE_AFTER = ['before' => 'richtext', 'after' => 'richtext']; diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index f2082af..1291fdd 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -263,6 +263,26 @@ final class AttrWriterTest extends TestCase ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]); } + /** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */ + public function testCustomCssPathsNeedUnfilteredHtml(): void + { + $css = ['mainElement' => 'color:red;} body{display:none']; + foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) { + try { + ComposeFixture::attrWriter()->cleanValue('divi/text', $path, $css); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + + self::assertSame($css, ComposeFixture::attrWriter(true)->cleanValue('divi/text', $path, $css)); + } + + // Only a whole "css" segment counts. + self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.cssClass', 'x')); + self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.cssish', 'x')); + } + /** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */ public function testUnknownContentInnerContentIsRichText(): void { diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 548412c..c351c94 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -134,6 +134,8 @@ final class VariableRefTest extends TestCase '$variable({"type":"color","value":{"name":"gcid-a1b2;}","settings":{}}})$', 'Name', ], + 'settings on a global variable' => [self::content('gvid-s1', '{"before":"x"}'), 'before'], + 'settings on a theme font' => [self::content('--et_global_heading_font', '{"after":""}'), 'after'], 'unknown settings key' => [self::content('post_title', '{"before":"","foo":"1"}'), 'foo'], 'setting the option lacks' => [self::content('post_featured_image', '{"before":"x"}'), 'before'], 'enable_html' => [self::content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], From 665e603abef3e11417c685062aa60f796def6e1a Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 13:46:52 +0200 Subject: [PATCH 109/185] fix: Task 1 review round 3 - re-check $variable marker after sanitizing, meta-key rule Critical-1/S28: sanitizers that strip , NUL or %XX could rebuild the $variable( marker after the raw check; every cleaned string (AttrWriter at every depth, VariableRef settings) is checked again. S29: custom_meta_ map names and *meta_key setting values obey META_KEY. Minor-2: malformed dynamic content maps fail with InvalidArgumentException. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/AttrWriter.php | 11 ++- src/Integrations/Divi/Compose/VariableRef.php | 83 ++++++++++++++++--- .../Divi/Compose/AttrWriterTest.php | 35 ++++++++ .../Divi/Compose/ComposeFixture.php | 22 ++++- .../Divi/Compose/VariableRefTest.php | 78 +++++++++++++++++ 5 files changed, 215 insertions(+), 14 deletions(-) diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 3a71f70..832b7ae 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -180,15 +180,24 @@ final class AttrWriter $kind = $this->kind($module, $path, $subName, $key); if (VariableRef::contains($value)) { + // The only way a reference is stored: whole, straight from canonical(). return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url'); } - return match ($kind) { + $clean = match ($kind) { 'html' => $this->sanitizer->html($value), 'code' => $this->sanitizer->code($value), 'url' => $this->sanitizer->url($value), default => $this->sanitizer->text($value), }; + + // Ruling S28: sanitizers strip characters ("", NUL, "%41") and can thereby build a + // marker the raw check above never saw – so the final string is checked again. + if (VariableRef::contains($clean)) { + throw VariableRef::notAlone(); + } + + return $clean; } /** diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index eb7a945..abcbbe4 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -34,7 +34,10 @@ use stdClass; * Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => * field "type"). Always, whatever the map says: "enable_html" is rejected, loop_* names only - * when the map names them, no string may contain a reference or ")$". + * when the map names them, custom_meta_ names and the values of settings whose key ends in + * "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY (ruling S29), and no + * string – before or after cleaning (ruling S28) – may contain a reference or ")$". A map + * of another shape fails in the constructor with InvalidArgumentException. */ final class VariableRef { @@ -83,9 +86,15 @@ final class VariableRef private array $dynamicContent; /** @param array>|null $dynamicContent See the class docblock. */ + /** + * @param array|null $dynamicContent See the class docblock. + * @throws InvalidArgumentException When the map does not have that shape. + */ public function __construct(private DesignSystem $design, ?array $dynamicContent = null) { - $this->dynamicContent = $dynamicContent ?? self::DEFAULT_DYNAMIC_CONTENT; + $this->dynamicContent = $dynamicContent === null + ? self::DEFAULT_DYNAMIC_CONTENT + : self::checkMap($dynamicContent); } /** Whether $value contains a reference anywhere (such a value may only be a whole reference). */ @@ -254,16 +263,17 @@ final class VariableRef */ private function contentFields(string $name): array { - if (isset($this->dynamicContent[$name]) && is_array($this->dynamicContent[$name])) { - return $this->dynamicContent[$name]; - } - + // Ruling S29: the meta key rule holds for map entries too. if (str_starts_with($name, self::META_PREFIX)) { - if (preg_match(self::META_KEY, substr($name, strlen(self::META_PREFIX))) !== 1) { + if (! self::isMetaKey($name)) { throw self::unknownName($name, 'content'); } - return self::META_SETTINGS; + return $this->dynamicContent[$name] ?? self::META_SETTINGS; + } + + if (isset($this->dynamicContent[$name])) { + return $this->dynamicContent[$name]; } if (str_starts_with($name, 'loop_')) { @@ -301,7 +311,8 @@ final class VariableRef throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key)); } - return $fieldType === 'text' ? $sanitizer->text($value) : $sanitizer->html($value); + $clean = $fieldType === 'text' ? $sanitizer->text($value) : $sanitizer->html($value); + break; default: if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) { throw new InvalidArgumentException(sprintf( @@ -310,8 +321,60 @@ final class VariableRef )); } - return is_string($value) ? $sanitizer->text($value) : $value; + $clean = is_string($value) ? $sanitizer->text($value) : $value; } + + // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). + if (is_string($clean) && (self::contains($clean) || str_contains($clean, ')$'))) { + throw self::notAlone(); + } + + // Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …) + // name a meta key – never a protected one. + if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) { + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", ' + . 'nicht mit "_" am Anfang).', + $key + )); + } + + return $clean; + } + + /** "telefon", "custom_meta_telefon" yes; "_edit_lock", "custom_meta__edit_lock", "a b" no. */ + private static function isMetaKey(string $key): bool + { + if (str_starts_with($key, self::META_PREFIX)) { + $key = substr($key, strlen(self::META_PREFIX)); + } + + return preg_match(self::META_KEY, $key) === 1; + } + + /** + * @param array $map + * @return array> + * @throws InvalidArgumentException + */ + private static function checkMap(array $map): array + { + foreach ($map as $name => $fields) { + if (! is_string($name) || ! is_array($fields)) { + throw new InvalidArgumentException('Dynamic content map: erwartet Name => [Einstellung => Feldtyp].'); + } + foreach ($fields as $key => $type) { + if (! is_string($key) || ! is_string($type)) { + throw new InvalidArgumentException(sprintf( + 'Dynamic content map "%s": erwartet Einstellung => Feldtyp (Text).', + $name + )); + } + } + } + + /** @var array> $map */ + return $map; } private static function unknownName(string $name, string $type): InvalidArgumentException diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 1291fdd..ca17697 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -172,6 +172,26 @@ final class AttrWriterTest extends TestCase ['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]], self::TEXT, ]; + // S28: markers a real sanitizer rebuilds after the raw check (verified in DDEV). + $reference = '({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; + $cases['tag-split marker in rich text'] = [ + 'divi/text', 'content.innerContent', ['desktop' => '$variable' . $reference], self::TEXT, + ]; + $cases['NUL-split marker in rich text'] = [ + 'divi/text', 'content.innerContent', ['desktop' => '$variable' . chr(0) . $reference], self::TEXT, + ]; + $cases['octet-split marker in a text title'] = [ + 'divi/heading', 'title.innerContent', ['desktop' => '$variable%41' . $reference], self::HEADING, + ]; + $cases['tag-split marker deep in a value'] = [ + 'divi/text', 'module.decoration.background', ['desktop' => ['color' => '$variable' . $reference]], self::TEXT, + ]; + $cases['sanitizer-built valid reference'] = [ + 'divi/text', + 'content.innerContent', + ['desktop' => '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + self::TEXT, + ]; $cases['javascript via a reference in a URL field'] = [ 'divi/fullwidth-header', 'buttonOne.innerContent', @@ -204,6 +224,21 @@ final class AttrWriterTest extends TestCase self::assertSame($attrs, $result['attrs']); } + /** S28: the split-marker rows carry no marker before sanitizing, but the sanitizer rebuilds one. */ + public function testSplitMarkersAreRebuiltBySanitizing(): void + { + $sanitizer = ComposeFixture::sanitizer(); + $rows = ['tag-split marker in rich text', 'NUL-split marker in rich text', 'octet-split marker in a text title', + 'tag-split marker deep in a value']; + foreach (array_intersect_key(self::malicious(), array_flip($rows)) as $name => [, , $map]) { + $raw = (string) json_encode($map, JSON_UNESCAPED_SLASHES); + $value = is_array($map['desktop']) ? $map['desktop']['color'] : $map['desktop']; + self::assertStringNotContainsString('$variable(', $value, $name); + self::assertStringContainsString('$variable(', $sanitizer->text($value) . $sanitizer->html($value), $name . ' ' . $raw); + } + self::assertSame('$variable' . chr(0) . '(', substr(self::malicious()['NUL-split marker in rich text'][2]['desktop'], 0, 11)); + } + /** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */ public function testReferenceSettingsAreCleanedForEditors(): void { diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 25ca5d4..739b164 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -25,15 +25,31 @@ final class ComposeFixture { } + /** Like sanitize_text_field: NUL bytes, tags and %XX octets go, then trim. */ public function text(string $value): string { - return trim(strip_tags($value)); + $value = strip_tags(str_replace(chr(0), '', $value)); + + return trim((string) preg_replace('/%[a-f0-9]{2}/i', '', $value)); } - /** Like WpValueSanitizer (ruling S4): raw for users with unfiltered_html. */ + /** + * Like WpValueSanitizer (ruling S4): raw for users with unfiltered_html; otherwise like + * wp_kses_post: NUL bytes, #is', '', $value); + if ($this->unfiltered) { + return $value; + } + + $value = (string) preg_replace('#]*>.*?#is', '', str_replace(chr(0), '', $value)); + + return (string) preg_replace( + '#]*>#i', + '', + $value + ); } public function code(string $value): string diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index c351c94..3b0177e 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -150,6 +150,13 @@ final class VariableRefTest extends TestCase 'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'], 'greedy smuggling' => ['$variable({"a":"})$"})$', 'allein im Wert'], 'nested reference in before' => [self::content('post_title', '{"before":"$variable(x)$","after":""}'), 'allein im Wert'], + // S28: a marker the sanitizer rebuilds inside a cleaned setting (html(), text()). + 'tag-split marker in before' => [self::content('post_title', '{"before":"$variable(y","after":""}'), 'allein im Wert'], + 'NUL-split marker in after' => [ + self::content('post_title', '{"before":"","after":"$variable' . self::escape('0000') . '(y"}'), + 'allein im Wert', + ], + 'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), 'allein im Wert'], 'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'], 'broken json' => ['$variable(kaputt)$', 'JSON'], 'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'], @@ -242,6 +249,77 @@ final class VariableRefTest extends TestCase } } + /** S28: the split-marker rows really carry no marker before sanitizing. */ + public function testSplitMarkerRowsHaveNoRawMarker(): void + { + $rows = self::malicious(); + foreach (['tag-split marker in before', 'NUL-split marker in after', 'octet-split marker in date format'] as $row) { + $payload = substr($rows[$row][0], strlen('$variable(')); + self::assertStringNotContainsString('$variable(', $payload, $row); + } + self::assertStringContainsString(chr(92) . 'u0000(y', $rows['NUL-split marker in after'][0]); + } + + /** Ruling S29: custom_meta_ names and meta-key settings obey META_KEY, map or not. */ + public function testMetaKeysObeyTheRuleEvenFromTheMap(): void + { + $ref = self::ref([ + 'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'], + 'custom_meta__edit_lock' => ['before' => 'text'], + 'loop_post_meta_key' => ['loop_meta_key' => 'text'], + ]); + $sanitizer = ComposeFixture::sanitizer(); + + self::assertSame( + self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'), + $ref->canonical(self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'), $sanitizer) + ); + self::assertSame( + self::content('post_meta_key', '{"select_meta_key":""}'), + $ref->canonical(self::content('post_meta_key', '{"select_meta_key":""}'), $sanitizer) + ); + + $rejected = [ + self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key', + self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key', + self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key', + self::content('loop_post_meta_key', '{"loop_meta_key":"_secret"}') => 'loop_meta_key', + self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock', + ]; + foreach ($rejected as $value => $fragment) { + try { + $ref->canonical($value, $sanitizer); + self::fail('Accepted: ' . $value); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } + } + + /** @return array}> */ + public static function malformedMaps(): array + { + return [ + 'setting type is an array' => [['post_title' => ['before' => ['type' => 'text']]]], + 'settings not an array' => [['post_title' => 'text']], + 'numeric setting key' => [['post_title' => ['text']]], + 'numeric name' => [[['before' => 'text']]], + ]; + } + + /** + * Minor-2: a malformed map fails early and clearly. + * + * @dataProvider malformedMaps + * @param array $map + */ + public function testRejectsMalformedMaps(array $map): void + { + $this->expectException(InvalidArgumentException::class); + + self::ref($map); // @phpstan-ignore-line + } + public function testDetectsReferences(): void { self::assertTrue(VariableRef::contains('a $variable( b')); From 4832ec1e10b770744e2a2ccb3b349e681c36e2af Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 13:51:56 +0200 Subject: [PATCH 110/185] fix: Task 1 review round 4 - meta keys checked after Divi's prefix derivation (S30) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/VariableRef.php | 41 ++++++++-- .../Divi/Compose/VariableRefTest.php | 75 +++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index abcbbe4..4de4702 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -35,9 +35,10 @@ use stdClass; * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => * field "type"). Always, whatever the map says: "enable_html" is rejected, loop_* names only * when the map names them, custom_meta_ names and the values of settings whose key ends in - * "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY (ruling S29), and no - * string – before or after cleaning (ruling S28) – may contain a reference or ")$". A map - * of another shape fails in the constructor with InvalidArgumentException. + * "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's own prefix + * stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28) – may + * contain a reference or ")$". A map of another shape fails in the constructor with + * InvalidArgumentException. */ final class VariableRef { @@ -80,12 +81,23 @@ final class VariableRef private const META_PREFIX = 'custom_meta_'; + /** + * Prefixes Divi strips from a meta key option with str_replace – every occurrence, not just + * the leading one (DynamicContentOptionPostMetaKey.php:232,249,275,286; + * DynamicContentOptionLoopPostMetaKey.php:366-373). Ruling S30. + */ + private const META_KEY_PREFIXES = [ + self::META_PREFIX, + 'loop_post_meta_key_', + 'loop_user_meta_key_', + 'loop_term_meta_key_', + ]; + private const NUMBER = '/^-?\d+(\.\d+)?$/'; /** @var array> */ private array $dynamicContent; - /** @param array>|null $dynamicContent See the class docblock. */ /** * @param array|null $dynamicContent See the class docblock. * @throws InvalidArgumentException When the map does not have that shape. @@ -342,11 +354,26 @@ final class VariableRef return $clean; } - /** "telefon", "custom_meta_telefon" yes; "_edit_lock", "custom_meta__edit_lock", "a b" no. */ + /** + * Checks the meta key Divi derives from $key (ruling S30): a leading known prefix is removed the + * way Divi does it – str_replace of every occurrence – and a prefix still present afterwards is + * rejected (defensive). "telefon", "custom_meta_telefon", "loop_post_meta_key_telefon" yes; + * "_edit_lock", "custom_meta__edit_lock", "custom_meta_custom_meta__edit_lock", + * "loop_post_meta_key__edit_lock", "a b" no. + */ private static function isMetaKey(string $key): bool { - if (str_starts_with($key, self::META_PREFIX)) { - $key = substr($key, strlen(self::META_PREFIX)); + foreach (self::META_KEY_PREFIXES as $prefix) { + if (str_starts_with($key, $prefix)) { + $key = str_replace($prefix, '', $key); + break; + } + } + + foreach (self::META_KEY_PREFIXES as $prefix) { + if (str_contains($key, $prefix)) { + return false; + } } return preg_match(self::META_KEY, $key) === 1; diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 3b0177e..bfedaf0 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -144,6 +144,10 @@ final class VariableRefTest extends TestCase 'opacity', ], 'protected meta key' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], + 'doubled meta prefix' => [ + self::content('custom_meta_custom_meta__edit_lock', '{}'), + 'custom_meta_custom_meta__edit_lock', + ], 'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'], 'unknown dynamic content' => [self::content('post_meta_key', '{}'), 'post_meta_key'], 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], @@ -296,6 +300,77 @@ final class VariableRefTest extends TestCase } } + /** @return array */ + public static function derivedMetaKeyBypasses(): array + { + return [ + 'doubled prefix in the name' => [self::content('custom_meta_custom_meta__edit_lock', '{}')], + 'tripled prefix in the name' => [self::content('custom_meta_custom_meta_custom_meta__edit_lock', '{}')], + 'prefix rebuilt by the strip' => [self::content('custom_meta_custom_mcustom_meta_eta_telefon', '{}')], + 'loop prefix in the name' => [self::content('custom_meta_loop_post_meta_key__edit_lock', '{}')], + 'doubled prefix in select_meta_key' => [ + self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'), + ], + 'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')], + 'loop post prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')], + 'loop user prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')], + 'loop term prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key__secret"}')], + 'doubled loop prefix' => [ + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'), + ], + 'mixed loop and meta prefix' => [ + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'), + ], + 'mixed meta and loop prefix' => [ + self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'), + ], + 'two loop prefixes' => [ + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'), + ], + ]; + } + + /** + * Ruling S30: Divi derives the meta key with str_replace of every prefix occurrence + * (DynamicContentOptionPostMetaKey.php:232,275; DynamicContentOptionLoopPostMetaKey.php:366-373). + * + * @dataProvider derivedMetaKeyBypasses + */ + public function testMetaKeysAreCheckedAfterDivisDerivation(string $value): void + { + $ref = self::ref([ + 'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'], + 'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'], + ]); + + $this->expectException(InvalidArgumentException::class); + + $ref->canonical($value, ComposeFixture::sanitizer()); + } + + public function testDerivedMetaKeysThatAreFineStayAccepted(): void + { + $ref = self::ref([ + 'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'], + 'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'], + ]); + $sanitizer = ComposeFixture::sanitizer(); + + foreach ([ + self::content('custom_meta_telefon', '{}'), + self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'), + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_telefon"}'), + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_first_name"}'), + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key_farbe"}'), + self::content( + 'loop_post_meta_key', + '{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}' + ), + ] as $value) { + self::assertSame($value, $ref->canonical($value, $sanitizer)); + } + } + /** @return array}> */ public static function malformedMaps(): array { From f86cd5b34a4a06a944d3b56f11d1a0a9d09f57f9 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 13:59:24 +0200 Subject: [PATCH 111/185] feat: Divi short-form writer ShortformWriter routes every short-form string through AttrWriter::cleanValue() with the curated field kind (new optional kind override), exposes the color check for background_color and guards builder-only paths on removal too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/AttrWriter.php | 42 ++- .../Divi/Compose/ShortformWriter.php | 265 ++++++++++++++++ .../Divi/Compose/ComposeFixture.php | 23 +- .../Divi/Compose/ShortformWriterTest.php | 289 ++++++++++++++++++ 4 files changed, 607 insertions(+), 12 deletions(-) create mode 100644 src/Integrations/Divi/Compose/ShortformWriter.php create mode 100644 tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 832b7ae..31e4af4 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -91,15 +91,23 @@ final class AttrWriter /** * Cleans one value written to $path (used by the short form too). $subKey names the * sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its - * string alone. + * string alone. $kind ('html'|'code'|'url'|'text') replaces the catalog routing for every + * string of $value: the short form passes its curated field kind (ShortformMap), so a + * short-form url field is always a URL and icon-list-item text always plain text. * + * @param 'html'|'code'|'url'|'text'|null $kind * @throws InvalidArgumentException */ - public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed - { + public function cleanValue( + string $module, + string $path, + mixed $value, + ?string $subKey = null, + ?string $kind = null + ): mixed { $this->guardPath($path); - return $this->cleanNode($module, $path, $value, $subKey, $subKey); + return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind); } /** @@ -133,8 +141,13 @@ final class AttrWriter return $normalized; } - /** @throws InvalidArgumentException For Divi's HTML/attribute fields without unfiltered_html (S7). */ - private function guardPath(string $path): void + /** + * Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also + * for removals, which the short form does without cleanValue(). + * + * @throws InvalidArgumentException + */ + public function guardPath(string $path): void { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { throw new InvalidArgumentException(sprintf( @@ -148,10 +161,17 @@ final class AttrWriter /** * @param string|null $subName First key below the state value: selects the sub-kind of a composite path. * @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind. + * @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form). * @throws InvalidArgumentException */ - private function cleanNode(string $module, string $path, mixed $value, ?string $subName, ?string $key): mixed - { + private function cleanNode( + string $module, + string $path, + mixed $value, + ?string $subName, + ?string $key, + ?string $kind = null + ): mixed { if (is_array($value)) { $clean = []; foreach ($value as $childKey => $item) { @@ -163,8 +183,8 @@ final class AttrWriter } $clean[$childKey] = is_string($childKey) - ? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey) - : $this->cleanNode($module, $path, $item, $subName, $key); + ? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey, $kind) + : $this->cleanNode($module, $path, $item, $subName, $key, $kind); } return $clean; @@ -178,7 +198,7 @@ final class AttrWriter throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.'); } - $kind = $this->kind($module, $path, $subName, $key); + $kind ??= $this->kind($module, $path, $subName, $key); if (VariableRef::contains($value)) { // The only way a reference is stored: whole, straight from canonical(). return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url'); diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php new file mode 100644 index 0000000..3c7b5b3 --- /dev/null +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -0,0 +1,265 @@ +.desktop.value[.]"; + * null removes the field. Pure PHP. + * + * Every string goes through AttrWriter::cleanValue() – the single choke point under the kses + * bypass (D7) – with the curated kind of the field: $variable(…)$ values only whole and + * re-serialized by VariableRef (S1/S2/S25), the final string checked again (S28), URL fields + * through the URL sanitizer (S3), builder-only paths guarded (S7/S26). Nothing is passed through. + */ +final class ShortformWriter +{ + /** A plain CSS color; no names, no functions but rgb/hsl (ruling S13). */ + private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/D'; + + private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; + + /** Prefix of every color reference VariableRef::serialize() writes (fixed key order). */ + private const COLOR_REFERENCE = '$variable({"type":"color",'; + + /** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */ + private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text']; + + /** + * @param array>>|null $modules Short form per + * type (tests only); null = ShortformMap. + */ + public function __construct( + private Placeholders $placeholders, + private MediaResolver $media, + private AttrWriter $attrs, + private ?array $modules = null + ) { + } + + /** + * @param string $type Short module name ("button"). + * @param array $attrs Current attributes. + * @param array $values Field => value. + * @return array{attrs: array, errors: array} + */ + public function apply(string $type, array $attrs, array $values, string $field): array + { + $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); + $errors = []; + foreach ($values as $name => $value) { + $name = (string) $name; + $spec = $fields[$name] ?? null; + if ($spec === null) { + $errors[$field . '.' . $name] = $fields === [] + ? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen' + : 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields)); + continue; + } + + try { + $attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values); + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . $name] = $e->getMessage(); + } + } + + return ['attrs' => $attrs, 'errors' => $errors]; + } + + /** + * The short-form color check (ruling S13; Task 3 reuses it for background_color): a + * placeholder or reference that is a color reference, or a plain CSS color – checked + * before and after cleaning (S28). Returns the value to store. + * + * @throws InvalidArgumentException + */ + public function color(string $module, string $path, mixed $value, ?string $subKey = null): string + { + if (! is_string($value)) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + $value = trim($this->placeholders->resolve($value)); + if (VariableRef::contains($value)) { + $reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); + if (! is_string($reference) || ! str_starts_with($reference, self::COLOR_REFERENCE)) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + return $reference; + } + + if (preg_match(self::COLOR, $value) !== 1) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + $clean = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); + if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) { + throw new InvalidArgumentException(self::COLOR_ERROR); + } + + return $clean; + } + + /** + * @param array $attrs + * @param array $spec + * @param array $all All values of this call (media must not overwrite an explicit alt). + * @return array + * @throws InvalidArgumentException + */ + private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array + { + $path = (string) $spec['path']; + $sub = $spec['sub'] === null ? null : (string) $spec['sub']; + $base = $path . '.desktop.value'; + $this->attrs->guardPath($path); + if ($value === null) { + return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub); + } + + if ($spec['kind'] === 'media') { + $attrs = $this->media($module, $attrs, $path, $spec['keys'], $value, ! array_key_exists('alt', $all)); + } else { + $clean = $this->value($module, $path, $sub, $spec, $value); + $attrs = AttrMap::set($attrs, $sub === null ? $base : $base . '.' . $sub, $clean); + } + + foreach ($spec['with'] ?? [] as $withKey => $fixed) { + $attrs = AttrMap::set($attrs, $base . '.' . $withKey, $fixed); + } + + return $attrs; + } + + /** + * @param array $attrs + * @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key. + * @return array + * @throws InvalidArgumentException + */ + private function media(string $module, array $attrs, string $path, array $keys, mixed $value, bool $withAlt): array + { + $id = self::id($value); + $image = $id > 0 ? $this->media->image($id) : null; + $notFound = 'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen'; + if ($image === null) { + throw new InvalidArgumentException($notFound); + } + + [$urlKey, $idKey, $altKey] = $keys; + $base = $path . '.desktop.value.'; + $url = $this->attrs->cleanValue($module, $path, $image['url'], $urlKey, 'url'); + if ($url === '') { + throw new InvalidArgumentException($notFound); + } + + $attrs = AttrMap::set($attrs, $base . $urlKey, $url); + if ($idKey !== null) { + $attrs = AttrMap::set($attrs, $base . $idKey, (string) $id); + } + + if ($altKey !== null && $withAlt && $image['alt'] !== '') { + $alt = $this->attrs->cleanValue($module, $path, $image['alt'], $altKey, 'text'); + $attrs = AttrMap::set($attrs, $base . $altKey, $alt); + } + + return $attrs; + } + + /** + * @param array $spec + * @throws InvalidArgumentException + */ + private function value(string $module, string $path, ?string $sub, array $spec, mixed $value): mixed + { + switch ($spec['kind']) { + case 'onoff': + if (is_bool($value)) { + return $value ? 'on' : 'off'; + } + if ($value === 'on' || $value === 'off') { + return $value; + } + throw new InvalidArgumentException('true oder false erwartet'); + case 'level': + if (is_string($value) && preg_match('/^h[1-6]$/D', $value) === 1) { + return $value; + } + throw new InvalidArgumentException('h1 bis h6 erwartet'); + case 'number': + $number = is_int($value) || is_float($value) || (is_string($value) && is_numeric($value)) + ? $value + 0 + : null; + if ($number !== null && is_finite((float) $number)) { + return $number; + } + throw new InvalidArgumentException('Zahl erwartet'); + case 'enum': + if (is_string($value) && in_array($value, $spec['values'], true)) { + return $value; + } + throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values'])); + case 'ids': + return $this->ids($value); + case 'color': + return $this->color($module, $path, $value, $sub); + } + + $kind = self::CLEAN_KIND[$spec['kind']] ?? 'text'; + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { + throw new InvalidArgumentException('Text erwartet'); + } + + $value = (string) $value; + if ($kind !== 'code') { + $value = $this->placeholders->resolve($value); + } + + $clean = $this->attrs->cleanValue($module, $path, $value, $sub, $kind); + if ($kind === 'url' && $clean === '' && trim($value) !== '') { + throw new InvalidArgumentException('ungültige URL'); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private function ids(mixed $value): string + { + if (! is_array($value) || $value === [] || ! array_is_list($value)) { + throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]'); + } + + $ids = []; + foreach ($value as $item) { + $id = self::id($item); + if ($id <= 0 || $this->media->image($id) === null) { + throw new InvalidArgumentException(sprintf( + '%s ist kein Bild aus der Mediathek', + is_scalar($item) ? (string) $item : gettype($item) + )); + } + $ids[] = $id; + } + + return implode(',', $ids); + } + + /** Attachment ID from an int or a digit string; 0 otherwise. */ + private static function id(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : 0; + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 739b164..9ab2c23 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; @@ -57,9 +58,20 @@ final class ComposeFixture return $value; } + /** + * Like esc_url_raw: spaces become %20, characters outside its allow list (tags, quotes, + * braces, NUL) go, %0d/%0a are removed repeatedly, javascript: gives ''. + */ public function url(string $value): string { - return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value); + $value = str_replace(' ', '%20', trim($value)); + $value = (string) preg_replace('|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i', '', $value); + do { + $before = $value; + $value = str_replace(['%0d', '%0a', '%0D', '%0A'], '', $value); + } while ($value !== $before); + + return str_starts_with(strtolower($value), 'javascript:') ? '' : $value; } public function allowsUnfilteredHtml(): bool @@ -92,4 +104,13 @@ final class ComposeFixture { return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered)); } + + /** + * @param array>>|null $modules Short form per + * type instead of ShortformMap (to reach paths the curated map never names). + */ + public static function shortform(bool $unfiltered = false, ?array $modules = null): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php new file mode 100644 index 0000000..633ff2a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -0,0 +1,289 @@ + ['innerContent' => ['desktop' => ['value' => 'Alt']]], + 'builderVersion' => '5.2.1', + ]; + + /** Short-form field per value kind the malicious suite attacks: [type, field]. */ + private const TARGETS = [ + 'text' => ['heading', 'text'], + 'url' => ['button', 'url'], + 'color' => ['divider', 'color'], + ]; + + public function testWritesFieldsAtTheirPaths(): void + { + $result = ComposeFixture::shortform()->apply('button', [], [ + 'text' => 'Termin buchen', + 'url' => '/kontakt', + 'new_window' => true, + ], 'nodes[0]'); + + self::assertSame([], $result['errors']); + self::assertSame( + ['button' => ['innerContent' => ['desktop' => ['value' => [ + 'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on', + ]]]]], + $result['attrs'] + ); + } + + public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void + { + $writer = ComposeFixture::shortform(); + + $image = $writer->apply('image', [], ['image_id' => 33], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'], + $image['attrs']['image']['innerContent']['desktop']['value'] + ); + + $own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n'); + self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']); + + $blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'], + $blurb['attrs']['imageIcon']['innerContent']['desktop']['value'] + ); + + $gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n'); + self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']); + } + + public function testValidatesKindsAndNames(): void + { + $result = ComposeFixture::shortform()->apply('heading', [], [ + 'level' => 'h7', + 'text' => '{{color:Lila}}', + 'size' => 12, + ], 'nodes[1]'); + + self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']); + self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']); + self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']); + + $more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f'); + self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']); + self::assertSame('true oder false erwartet', $more['errors']['f.required']); + + $media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm'); + self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']); + self::assertSame('ungültige URL', $media['errors']['m.url']); + + $none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a'); + self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']); + + $numbers = ComposeFixture::shortform()->apply('map', [], ['zoom' => '12abc', 'lng' => '1e999', 'lat' => '50.1'], 'm'); + self::assertSame('Zahl erwartet', $numbers['errors']['m.zoom']); + self::assertSame('Zahl erwartet', $numbers['errors']['m.lng']); + self::assertSame(50.1, $numbers['attrs']['map']['innerContent']['desktop']['value']['lat']); + self::assertArrayNotHasKey('m.lat', $numbers['errors']); + + $ids = ComposeFixture::shortform()->apply('gallery', [], ['image_ids' => [12, 999]], 'g'); + self::assertStringContainsString('999 ist kein Bild', $ids['errors']['g.image_ids']); + self::assertSame([], $ids['attrs']); + } + + public function testPlaceholdersAndNullRemoval(): void + { + $writer = ComposeFixture::shortform(); + $attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs']; + + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + $attrs['title']['innerContent']['desktop']['value'] + ); + + $removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs']; + self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned'); + + $divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs']; + self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']); + self::assertSame( + 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a', + $writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color'] + ); + } + + /** The curated kind wins over catalog routing: icon-list-item text is plain text, not rich text (S23). */ + public function testCuratedKindDecidesTheSanitizer(): void + { + $writer = ComposeFixture::shortform(); + + $item = $writer->apply('icon-list-item', [], ['text' => 'Schnell geliefert'], 'n')['attrs']; + self::assertSame('Schnell geliefert', $item['content']['innerContent']['desktop']['value']); + + $text = $writer->apply('text', [], ['content' => '

    Hallo Welt

    '], 'n')['attrs']; + self::assertSame('

    Hallo Welt

    ', $text['content']['innerContent']['desktop']['value']); + + $link = $writer->apply('image', [], ['src' => '{{post:featured_image}}'], 'n'); + self::assertSame([], $link['errors']); + self::assertStringContainsString('post_featured_image', $link['attrs']['image']['innerContent']['desktop']['value']['src']); + } + + /** + * Ruling S6 (Task 2 part): attacks on short-form text, url and color. Every Task-1 attack on a + * $variable(…)$ value runs against each of the three kinds, plus kind-specific rows. + * + * The Task-1 rows run for text as an editor only (as in AttrWriterTest): for a user with + * unfiltered_html, html() keeps before/after raw, so a split marker there stays split and + * harmless (no marker in the final string). Every other row fails for both roles. + * + * @return array Kind, value, also as admin (default true). + */ + public static function malicious(): array + { + $rows = []; + foreach (VariableRefTest::malicious() as $name => [$value]) { + foreach (array_keys(self::TARGETS) as $kind) { + $rows[$kind . ': ' . $name] = [$kind, $value, $kind !== 'text']; + } + } + + $postTitle = static fn (string $settings): string + => '$variable({"type":"content","value":{"name":"post_title","settings":' . $settings . '}})$'; + $meta = static fn (string $settings): string + => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":' . $settings . '}})$'; + $color = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'; + + return $rows + [ + // Script in before/after through a placeholder. + 'text: script after a placeholder' => ['text', '{{post:title}}'], + 'text: script in a placeholder name' => ['text', '{{post:}}'], + 'url: script after a placeholder' => ['url', '{{post:url}}'], + 'color: script after a placeholder' => ['color', '{{color:primary}};}'], + 'url: script in before of a reference' => ['url', $postTitle('{"before":"","after":""}')], + 'color: script in before of a reference' => ['color', $postTitle('{"before":"","after":""}')], + // javascript: links. + 'url: javascript' => ['url', 'javascript:alert(1)'], + 'url: javascript mixed case' => ['url', 'JavaScript:alert(document.cookie)'], + 'url: javascript after spaces' => ['url', ' javascript:alert(1)'], + 'url: javascript in before of a reference' => ['url', $meta('{"before":"javascript:","after":""}')], + // CSS breakout in colors. + 'color: style breakout' => ['color', '#fff;}'], + 'color: name breakout' => ['color', 'red;}'], + 'color: rgba breakout' => ['color', 'rgba(0,0,0,.5);}body{display:none}'], + 'color: expression' => ['color', 'expression(alert(1))'], + 'color: url()' => ['color', 'url(javascript:alert(1))'], + 'color: content reference' => ['color', '{{post:title}}'], + 'color: not a string' => ['color', ['#fff']], + // Embedded $variable(. + 'text: embedded reference' => ['text', 'Hallo ' . $postTitle('{}')], + 'url: embedded reference' => ['url', 'https://kunde.test/?a=' . $postTitle('{}')], + 'color: embedded reference' => ['color', '#fff ' . $color], + 'color: two references' => ['color', $color . $color], + // Markers the sanitizer would rebuild (S28). + 'text: tag-split marker' => ['text', '$variable(' . substr($color, 10)], + 'text: NUL-split marker' => ['text', '$variable' . chr(0) . '(' . substr($color, 10)], + 'text: octet-split marker' => ['text', '$variable%41(' . substr($color, 10)], + 'url: stripped-characters marker' => ['url', '$variable<"{}>(y)$'], + 'url: NUL-split marker' => ['url', '$variable' . chr(0) . '(y)$'], + 'url: line-break octet marker' => ['url', '$variable%0a(y)$'], + 'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'], + 'color: tag-split marker' => ['color', '$variable(' . substr($color, 10)], + 'color: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . substr($color, 10)], + 'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)], + ]; + } + + /** @dataProvider malicious */ + public function testMaliciousValuesAreRejectedAndNothingIsStored(string $kind, mixed $value, bool $admin = true): void + { + [$type, $field] = self::TARGETS[$kind]; + foreach ($admin ? [false, true] : [false] as $unfiltered) { + $result = ComposeFixture::shortform($unfiltered)->apply($type, self::BASE, [$field => $value], 'nodes[0]'); + + self::assertArrayHasKey('nodes[0].' . $field, $result['errors']); + self::assertSame(self::BASE, $result['attrs']); + } + } + + /** The split-marker rows really carry no marker, and the fake sanitizers really rebuild one. */ + public function testSplitMarkerRowsNeedTheFinalStringCheck(): void + { + $rows = self::malicious(); + $sanitizer = ComposeFixture::sanitizer(); + foreach (['text: tag-split marker', 'text: NUL-split marker', 'text: octet-split marker'] as $row) { + self::assertStringNotContainsString('$variable(', $rows[$row][1]); + self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1])); + } + + foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) { + self::assertStringNotContainsString('$variable(', $rows[$row][1]); + self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1])); + } + + self::assertStringContainsString(chr(0), $rows['text: NUL-split marker'][1]); + } + + /** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */ + public function testReferenceSettingsInTextAreCleaned(): void + { + $value = '$variable({"type":"content","value":{"name":"post_title","settings":' + . '{"before":"Hi","after":""}}})$'; + + $result = ComposeFixture::shortform()->apply('heading', [], ['text' => $value], 'n'); + + self::assertSame([], $result['errors']); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"Hi","after":""}}})$', + $result['attrs']['title']['innerContent']['desktop']['value'] + ); + } + + /** Ruling S13: the color check Task 3 reuses for background_color. */ + public function testColorCheckIsReusable(): void + { + $writer = ComposeFixture::shortform(); + + self::assertSame('#1A1a1a', $writer->color('divi/section', 'module.decoration.background', ' #1A1a1a ', 'color')); + self::assertSame('rgba(0, 0, 0, 0.5)', $writer->color('divi/section', 'module.decoration.background', 'rgba(0, 0, 0, 0.5)', 'color')); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', + $writer->color('divi/section', 'module.decoration.background', '{{color:primary}}', 'color') + ); + + foreach (['{{post:title}}', 'blau', '#fff;}', '', "#fff\n}"] as $bad) { + try { + $writer->color('divi/section', 'module.decoration.background', $bad, 'color'); + self::fail('accepted ' . $bad); + } catch (InvalidArgumentException $e) { + self::assertNotSame('', $e->getMessage()); + } + } + } + + /** Rulings S7/S26: the short form never reaches builder-only paths without unfiltered_html. */ + public function testBuilderOnlyPathsNeedUnfilteredHtml(): void + { + $modules = ['raw' => [ + 'before' => ['path' => 'module.advanced.html', 'sub' => 'htmlBefore', 'kind' => 'text', 'description' => 'x'], + 'css' => ['path' => 'css.advanced.customCss', 'sub' => null, 'kind' => 'text', 'description' => 'x'], + ]]; + + $editor = ComposeFixture::shortform(false, $modules); + foreach (['before', 'css'] as $field) { + foreach (['Hallo', null] as $value) { + $result = $editor->apply('raw', self::BASE, [$field => $value], 'n'); + self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.' . $field]); + self::assertSame(self::BASE, $result['attrs']); + } + } + + $admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['before' => 'Hallo'], 'n'); + self::assertSame([], $admin['errors']); + self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']); + } +} From 4b72c2bb42b96731416f7490deaf1f63333c5243 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 14:15:33 +0200 Subject: [PATCH 112/185] =?UTF-8?q?feat:=20Divi=20composer=20=E2=80=93=20s?= =?UTF-8?q?hort/full=20form=20to=20blocks=20with=20structure=20rules,=20pr?= =?UTF-8?q?esets=20and=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Labels, background_color and preset ids go through the choke point (S13); ambiguous preset names fail with their ids (S13a); fullwidth + rows is an error. Carry-ins: control characters stripped before the marker check (S32), kind override may only tighten, reference alt texts dropped, color check via VariableRef::typeOf (S33). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/AttrWriter.php | 41 +- src/Integrations/Divi/Compose/Composer.php | 548 ++++++++++++++++++ .../Divi/Compose/ShortformWriter.php | 30 +- .../Divi/Compose/StructureRules.php | 89 +++ src/Integrations/Divi/Compose/VariableRef.php | 36 +- .../Divi/Compose/AttrWriterTest.php | 59 ++ .../Divi/Compose/ComposeFixture.php | 55 +- .../Divi/Compose/ComposerTest.php | 366 ++++++++++++ .../Divi/Compose/ShortformWriterTest.php | 19 +- .../Divi/Compose/StructureRulesTest.php | 66 +++ .../Divi/Compose/VariableRefTest.php | 22 + 11 files changed, 1314 insertions(+), 17 deletions(-) create mode 100644 src/Integrations/Divi/Compose/Composer.php create mode 100644 src/Integrations/Divi/Compose/StructureRules.php create mode 100644 tests/Unit/Integrations/Divi/Compose/ComposerTest.php create mode 100644 tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 31e4af4..615ef83 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -39,6 +39,9 @@ final class AttrWriter * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). */ + /** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */ + private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2]; + private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; public function __construct( @@ -92,8 +95,9 @@ final class AttrWriter * Cleans one value written to $path (used by the short form too). $subKey names the * sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its * string alone. $kind ('html'|'code'|'url'|'text') replaces the catalog routing for every - * string of $value: the short form passes its curated field kind (ShortformMap), so a - * short-form url field is always a URL and icon-list-item text always plain text. + * string of $value: the short form passes its curated field kind (ShortformMap), so + * icon-list-item text is always plain text. It may only tighten (ruling S33a): code < html + * < text; url only where the routing says url – a weaker or other-family kind is refused. * * @param 'html'|'code'|'url'|'text'|null $kind * @throws InvalidArgumentException @@ -198,7 +202,17 @@ final class AttrWriter throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.'); } - $kind ??= $this->kind($module, $path, $subName, $key); + $routed = $this->kind($module, $path, $subName, $key); + if ($kind !== null && ! self::tightens($kind, $routed)) { + throw new InvalidArgumentException(sprintf( + 'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").', + $kind, + $path, + $routed + )); + } + + $kind ??= $routed; if (VariableRef::contains($value)) { // The only way a reference is stored: whole, straight from canonical(). return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url'); @@ -211,8 +225,10 @@ final class AttrWriter default => $this->sanitizer->text($value), }; - // Ruling S28: sanitizers strip characters ("", NUL, "%41") and can thereby build a - // marker the raw check above never saw – so the final string is checked again. + // Ruling S32: control characters (the WordPress sanitizers keep NUL & co.) go before the + // check. Ruling S28: sanitizers strip characters ("", NUL, "%41") and can thereby build + // a marker the raw check above never saw – so the final string is checked again. + $clean = VariableRef::withoutControls($clean); if (VariableRef::contains($clean)) { throw VariableRef::notAlone(); } @@ -220,6 +236,21 @@ final class AttrWriter return $clean; } + /** + * Whether the kind override $kind is at least as strict as the routed kind (ruling S33a). + * Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text + * (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is + * tightened by the others, so only url → url passes. + */ + private static function tightens(string $kind, string $routed): bool + { + if ($kind === 'url' || $routed === 'url') { + return $kind === $routed; + } + + return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed]; + } + /** * Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the * sub-field for a composite path – decides. A string reached through a key below a path diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php new file mode 100644 index 0000000..6c62b9f --- /dev/null +++ b/src/Integrations/Divi/Compose/Composer.php @@ -0,0 +1,548 @@ + – " (spec §5.5). + * Every error is collected with its input path; blocks are only usable when there are none. + * Pure PHP. + * + * Divi saves bypass kses (D7): every client string reaches the attributes only through + * ShortformWriter / AttrWriter::cleanValue() (the single choke point) – short-form fields, + * full-form attrs, admin labels, background_color (ShortformWriter::color(), ruling S13) and + * preset ids. A value that fails there is an error, never stored raw (S13). What Composer + * writes itself is not client text: the builder version (checked in the constructor), block + * names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built + * from digits. Design-system names that match several entries are errors listing the ids + * (ruling S13a: colors/variables in Placeholders, presets here). + */ +final class Composer +{ + private const COMMON = ['type', 'label', 'preset', 'attrs']; + private const SECTION = ['section_type', 'background_color', 'rows', 'modules']; + private const ROW = ['columns', 'modules']; + + /** Keys of one lit-divi-update change ("id" names the node, read by the ability). */ + private const UPDATE = ['id', 'set', 'attrs', 'preset', 'label']; + + /** Column fractions Divi's flex layout knows besides multiples of 1/24. */ + private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '4_5', '1_7', '1_9', '1_10', '1_11']; + + /** ET_BUILDER_PRODUCT_VERSION, e.g. "5.2.1" – written into every new block. */ + private const VERSION = '/^[0-9][0-9A-Za-z.+-]*$/D'; + + private const LABEL_PATH = 'module.meta.adminLabel'; + + /** @var array */ + private array $errors = []; + + /** @var list */ + private array $invalidPaths = []; + + /** @throws InvalidArgumentException When $builderVersion is no version number. */ + public function __construct( + private ModuleCatalog $catalog, + private StructureRules $rules, + private ShortformWriter $shortform, + private AttrWriter $attrWriter, + private DesignSystem $design, + private string $builderVersion + ) { + if (preg_match(self::VERSION, $builderVersion) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a Divi builder version.', $builderVersion)); + } + } + + /** + * @param array $specs Node descriptions. + * @param Block|null $parent Block the nodes go into (null = top level of the page). + * @param string $field Input path of $specs for error keys, e.g. "nodes". + * @param string $context Admin label of the surrounding section ("" if none). + * @return array{blocks: list, errors: array, invalid_paths: list} + */ + public function compose(array $specs, ?Block $parent, string $field, string $context = ''): array + { + $this->errors = []; + $this->invalidPaths = []; + $blocks = []; + foreach (array_values($specs) as $position => $spec) { + $block = $this->node($spec, $parent, sprintf('%s[%d]', $field, $position), $context); + if ($block !== null) { + $blocks[] = $block; + } + } + + return ['blocks' => $blocks, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** + * Applies short-form fields ("set"), full-form attrs, preset and label to an existing block + * (lit-divi-update); errors like compose(). The block itself is not changed – the caller + * sets the returned attrs. "preset": null and "label": null remove them. + * + * @param array $change id, set, attrs, preset, label + * @return array{attrs: array, errors: array, invalid_paths: list} + */ + public function update(Block $block, array $change, string $field): array + { + $this->errors = []; + $this->invalidPaths = []; + $attrs = $block->attrs(); + if (! $block->isDivi() || $block->isOpaque()) { + $this->errors[$field . '.id'] = 'nur lesbar (globales Modul oder fremder Block)'; + + return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => []]; + } + + $this->unknownKeys($change, self::UPDATE, $field); + if (array_key_exists('set', $change)) { + if (! is_array($change['set']) || $change['set'] === [] || array_is_list($change['set'])) { + $this->errors[$field . '.set'] = 'Objekt mit Kurzform-Feldern erwartet'; + } else { + $result = $this->shortform->apply($block->shortName(), $attrs, $change['set'], $field . '.set'); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + } + + $attrs = $this->presetAndAttrs($block->name(), $attrs, $change, $field); + if (array_key_exists('label', $change)) { + $attrs = $change['label'] === null || $change['label'] === '' + ? AttrMap::remove($attrs, self::LABEL_PATH) + : $this->label($block->name(), $attrs, $change['label'], $field . '.label'); + } + + return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** Admin label for a new module: " – " or the title alone. */ + public function autoLabel(string $name, string $context): string + { + $title = $this->catalog->title($name); + + return $context === '' ? $title : $context . ' – ' . $title; + } + + private function node(mixed $spec, ?Block $parent, string $path, string $context): ?Block + { + if (! is_array($spec) || array_is_list($spec)) { + $this->errors[$path] = 'Objekt mit "type" erwartet'; + + return null; + } + + $type = $this->typeOf($spec, $parent); + if ($type === null) { + $this->errors[$path . '.type'] = 'type fehlt'; + + return null; + } + + $violation = $this->rules->violation($parent, $type); + if ($violation !== null) { + $this->errors[$path . '.type'] = $violation; + + return null; + } + + return match ($type) { + 'divi/section' => $this->section($spec, $path), + 'divi/row' => $this->row($spec, $path, $context), + default => $this->module($type, $spec, $path, $context), + }; + } + + /** @param array $spec */ + private function typeOf(array $spec, ?Block $parent): ?string + { + if (is_string($spec['type'] ?? null) && trim($spec['type']) !== '') { + return ModuleCatalog::normalize($spec['type']); + } + + if (array_key_exists('type', $spec)) { + return null; + } + + $children = $parent === null ? [] : $this->catalog->childrenOf($parent->name()); + + return count($children) === 1 ? $children[0] : null; + } + + /** @param array $spec */ + private function section(array $spec, string $path): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::SECTION), $path); + $type = $spec['section_type'] ?? 'regular'; + $attrs = ['builderVersion' => $this->builderVersion]; + if ($type === 'fullwidth') { + $attrs = AttrMap::set($attrs, 'module.advanced.type', ['desktop' => ['value' => 'fullwidth']]); + } elseif ($type !== 'regular') { + $this->errors[$path . '.section_type'] = 'regular oder fullwidth erwartet (specialty nur im Divi-Builder)'; + } + + if (array_key_exists('background_color', $spec)) { + $attrs = $this->backgroundColor($attrs, $spec['background_color'], $path . '.background_color'); + } + + $attrs = $this->common('divi/section', $attrs, $spec, $path, null); + $label = AttrMap::desktop($attrs, self::LABEL_PATH); + $context = is_string($label) ? $label : ''; + $section = Block::create('divi/section', $attrs); + + $hasRows = array_key_exists('rows', $spec); + $hasModules = array_key_exists('modules', $spec); + if ($type === 'fullwidth') { + if ($hasRows) { + $this->errors[$path . '.rows'] = 'ein Vollbreite-Abschnitt (section_type fullwidth) hat keine Zeilen; ' + . 'fullwidth-Module unter "modules" angeben'; + } + $this->children($section, $spec['modules'] ?? [], $path . '.modules', $context); + } elseif ($hasRows && $hasModules) { + $this->errors[$path] = 'entweder rows oder modules angeben'; + } elseif ($hasModules) { + $this->children($section, [['type' => 'row', 'modules' => $spec['modules']]], $path . '.modules', $context); + } else { + $this->children($section, $spec['rows'] ?? [], $path . '.rows', $context); + } + + return $section; + } + + /** @param array $spec */ + private function row(array $spec, string $path, string $context): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path); + $widths = $this->widths($spec['columns'] ?? '4_4', $path . '.columns'); + $label = $context === '' ? null : $context . ' – Zeile'; + $attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label); + $row = Block::create('divi/row', $attrs); + + $modules = $spec['modules'] ?? []; + if (! is_array($modules) || ! array_is_list($modules)) { + $this->errors[$path . '.modules'] = 'Liste erwartet (je Spalte eine Liste von Modulen)'; + $modules = []; + } + + // One column: a flat list of modules is accepted. + $first = $modules[0] ?? null; + if (count($widths) === 1 && is_array($first) && ! array_is_list($first)) { + $modules = [$modules]; + } + + if (count($modules) > count($widths)) { + $this->errors[$path . '.modules'] = sprintf( + '%d Spalten, aber %d Modul-Listen', + count($widths), + count($modules) + ); + } + + foreach ($widths as $position => $width) { + $column = Block::create('divi/column', AttrMap::set( + ['builderVersion' => $this->builderVersion], + 'module.decoration.sizing', + ['desktop' => ['value' => ['flexType' => $width]]] + )); + $row->insertChild($position, $column); + $this->children($column, $modules[$position] ?? [], sprintf('%s.modules[%d]', $path, $position), $context); + } + + return $row; + } + + /** @param array $spec */ + private function module(string $name, array $spec, string $path, string $context): Block + { + $short = ModuleCatalog::shortName($name); + $fields = array_keys(ShortformMap::fields($short)); + $hasItems = $this->catalog->childrenOf($name) !== []; + $allowed = array_merge( + self::COMMON, + $fields, + $hasItems ? ['items'] : [], + $name === 'divi/group' ? ['modules'] : [] + ); + $this->unknownKeys($spec, $allowed, $path); + + // "type" and "label" of a node are its module type and admin label; the short-form fields + // of the same name (contact-field type, social network label) go through lit-divi-update "set". + $values = array_intersect_key($spec, array_flip(array_diff($fields, self::COMMON))); + if ($name === 'divi/heading' && ! array_key_exists('level', $values)) { + $values['level'] = 'h2'; + } + + $attrs = ['builderVersion' => $this->builderVersion]; + if ($values !== []) { + $result = $this->shortform->apply($short, $attrs, $values, $path); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + + $block = Block::create($name, $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context))); + if ($hasItems) { + $this->children($block, $spec['items'] ?? [], $path . '.items', $context); + } elseif ($name === 'divi/group') { + $this->children($block, $spec['modules'] ?? [], $path . '.modules', $context); + } + + return $block; + } + + /** + * Preset, full-form attrs and admin label of a new block. The label is $spec's "label" + * when given ("" = none), else $autoLabel. + * + * @param array $attrs + * @param array $spec + * @return array + */ + private function common(string $name, array $attrs, array $spec, string $path, ?string $autoLabel): array + { + $attrs = $this->presetAndAttrs($name, $attrs, $spec, $path); + $label = array_key_exists('label', $spec) && $spec['label'] !== null ? $spec['label'] : $autoLabel; + if ($label === null || $label === '') { + return $attrs; + } + + return $this->label($name, $attrs, $label, $path . '.label'); + } + + /** + * @param array $attrs + * @param array $spec + * @return array + */ + private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array + { + if (array_key_exists('preset', $spec)) { + $attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset'); + } + + if (! array_key_exists('attrs', $spec)) { + return $attrs; + } + + if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) { + $this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; + + return $attrs; + } + + $result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs'); + $this->errors += $result['errors']; + array_push($this->invalidPaths, ...$result['invalid_paths']); + + return $result['attrs']; + } + + /** + * The admin label, cleaned as text at the choke point (ruling S13): a label that fails there, + * or that still carries a reference, is an error and nothing is written. + * + * @param array $attrs + * @return array + */ + private function label(string $name, array $attrs, mixed $label, string $path): array + { + if (! is_string($label)) { + $this->errors[$path] = 'Text erwartet'; + + return $attrs; + } + + try { + $clean = $this->attrWriter->cleanValue($name, self::LABEL_PATH, $label, null, 'text'); + } catch (InvalidArgumentException $e) { + $this->errors[$path] = $e->getMessage(); + + return $attrs; + } + + if (! is_string($clean) || VariableRef::contains($clean)) { + $this->errors[$path] = 'Beschriftung: nur Text, keine Divi-Verweise oder Platzhalter'; + + return $attrs; + } + + return $clean === '' ? $attrs : AttrMap::set($attrs, self::LABEL_PATH, ['desktop' => ['value' => $clean]]); + } + + /** + * @param array $attrs + * @return array + */ + private function preset(string $name, array $attrs, mixed $preset, string $path): array + { + if ($preset === null || $preset === '') { + unset($attrs['modulePreset']); + + return $attrs; + } + + $id = is_string($preset) ? $this->presetId($name, $preset, $path) : null; + if ($id === null) { + if (! isset($this->errors[$path])) { + $names = $this->design->modulePresetNames($name); + $this->errors[$path] = sprintf( + 'unbekanntes Preset; vorhanden für %s: %s', + $name, + $names === [] ? '(keine)' : implode(', ', $names) + ); + } + + return $attrs; + } + + // Design-system data, but still through the choke point: an id text() would change is refused. + try { + $clean = $this->attrWriter->cleanValue($name, 'modulePreset', $id, null, 'text'); + } catch (InvalidArgumentException $e) { + $clean = null; + } + + if ($clean !== $id) { + $this->errors[$path] = sprintf('Preset-ID "%s" ist ungültig', $id); + + return $attrs; + } + + $attrs['modulePreset'] = [$id]; + + return $attrs; + } + + /** + * Module preset id by id, else by name (case-insensitive); a name several presets of the + * module share is an error listing their ids (ruling S13a). Null when unknown or ambiguous. + */ + private function presetId(string $name, string $preset, string $path): ?string + { + $wanted = trim($preset); + $key = mb_strtolower($wanted); + $ids = []; + foreach ($this->design->presets() as $entry) { + if ($entry['kind'] !== 'module' || $entry['module'] !== $name) { + continue; + } + + if ($entry['id'] === $wanted) { + return $entry['id']; + } + + if (mb_strtolower($entry['name']) === $key) { + $ids[] = $entry['id']; + } + } + + if (count($ids) > 1) { + $this->errors[$path] = sprintf( + 'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.', + $wanted, + implode(', ', $ids) + ); + + return null; + } + + return $ids[0] ?? null; + } + + /** + * Ruling S13: the short-form color check (ShortformWriter::color()), never text(). + * + * @param array $attrs + * @return array + */ + private function backgroundColor(array $attrs, mixed $color, string $path): array + { + try { + $clean = $this->shortform->color('divi/section', 'module.decoration.background', $color, 'color'); + } catch (InvalidArgumentException $e) { + $this->errors[$path] = $e->getMessage(); + + return $attrs; + } + + return AttrMap::set($attrs, 'module.decoration.background', ['desktop' => ['value' => ['color' => $clean]]]); + } + + private function children(Block $parent, mixed $specs, string $path, string $context): void + { + if (! is_array($specs) || ($specs !== [] && ! array_is_list($specs))) { + $this->errors[$path] = 'Liste erwartet'; + + return; + } + + foreach ($specs as $position => $spec) { + $child = $this->node($spec, $parent, sprintf('%s[%d]', $path, $position), $context); + if ($child !== null) { + $parent->insertChild(PHP_INT_MAX, $child); + } + } + } + + /** + * "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row. + * + * @return list + */ + private function widths(mixed $columns, string $path): array + { + $parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : []; + $widths = []; + $sum = 0.0; + foreach ($parts as $part) { + $valid = preg_match('/^(\d{1,2})_(\d{1,2})$/D', $part, $match) === 1 + && (int) $match[1] >= 1 + && (int) $match[1] <= (int) $match[2]; + if (! $valid) { + $this->errors[$path] = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet'; + + return ['24_24']; + } + + [$numerator, $denominator] = [(int) $match[1], (int) $match[2]]; + if (24 % $denominator === 0) { + $widths[] = ($numerator * 24 / $denominator) . '_24'; + } elseif (in_array($part, self::SPECIAL_WIDTHS, true)) { + $widths[] = $part; + } else { + $this->errors[$path] = sprintf('Spaltenbreite %s gibt es in Divi nicht', $part); + + return ['24_24']; + } + $sum += $numerator / $denominator; + } + + if ($widths === [] || abs($sum - 1.0) > 0.01) { + $this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")'; + + return ['24_24']; + } + + return $widths; + } + + /** + * @param array $spec + * @param list $allowed + */ + private function unknownKeys(array $spec, array $allowed, string $path): void + { + foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) { + $this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed); + } + } +} diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index 3c7b5b3..8442906 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -25,9 +25,6 @@ final class ShortformWriter private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; - /** Prefix of every color reference VariableRef::serialize() writes (fixed key order). */ - private const COLOR_REFERENCE = '$variable({"type":"color",'; - /** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */ private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text']; @@ -89,7 +86,7 @@ final class ShortformWriter $value = trim($this->placeholders->resolve($value)); if (VariableRef::contains($value)) { $reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); - if (! is_string($reference) || ! str_starts_with($reference, self::COLOR_REFERENCE)) { + if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -166,14 +163,33 @@ final class ShortformWriter $attrs = AttrMap::set($attrs, $base . $idKey, (string) $id); } - if ($altKey !== null && $withAlt && $image['alt'] !== '') { - $alt = $this->attrs->cleanValue($module, $path, $image['alt'], $altKey, 'text'); - $attrs = AttrMap::set($attrs, $base . $altKey, $alt); + $alt = $altKey !== null && $withAlt ? $this->alt($module, $path, $image['alt'], $altKey) : ''; + if ($alt !== '') { + $attrs = AttrMap::set($attrs, $base . (string) $altKey, $alt); } return $attrs; } + /** + * The attachment's alt text, cleaned as text – or '' (no alt) when it carries a reference, + * whole or rebuilt by cleaning (ruling S33b): library data never becomes dynamic content. + */ + private function alt(string $module, string $path, string $alt, string $altKey): string + { + if ($alt === '' || VariableRef::contains($alt)) { + return ''; + } + + try { + $clean = $this->attrs->cleanValue($module, $path, $alt, $altKey, 'text'); + } catch (InvalidArgumentException) { + return ''; + } + + return is_string($clean) ? $clean : ''; + } + /** * @param array $spec * @throws InvalidArgumentException diff --git a/src/Integrations/Divi/Compose/StructureRules.php b/src/Integrations/Divi/Compose/StructureRules.php new file mode 100644 index 0000000..7de7bc4 --- /dev/null +++ b/src/Integrations/Divi/Compose/StructureRules.php @@ -0,0 +1,89 @@ +catalog->has($child)) { + return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child); + } + + if ($parent === null) { + return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt'; + } + + if (! $parent->isDivi() || $parent->isOpaque()) { + return 'der Zielknoten ist nur lesbar (globales Modul oder fremder Block)'; + } + + $parentName = $parent->name(); + if ($parentName === 'divi/section') { + return $this->inSection($parent, $child); + } + + if (in_array($parentName, self::ROWS, true)) { + return 'Spalten entstehen mit der Zeile ("columns"); eine neue Zeile mit der gewünschten Aufteilung ' + . 'einfügen'; + } + + if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') { + return $this->isPlainModule($child) || $child === 'divi/group' + ? null + : sprintf('%s passt nicht in eine Spalte', $child); + } + + $children = $this->catalog->childrenOf($parentName); + if ($children === []) { + return sprintf('%s kann keine Kind-Module enthalten', $parentName); + } + + return in_array($child, $children, true) + ? null + : sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children)); + } + + /** A module that goes into a column: category "module", not a fullwidth module. */ + public function isPlainModule(string $name): bool + { + return $this->catalog->category($name) === 'module' && ! str_starts_with($name, self::FULLWIDTH); + } + + private function inSection(Block $section, string $child): ?string + { + $type = AttrMap::desktop($section->attrs(), 'module.advanced.type'); + if ($type === 'specialty') { + return 'Spezialabschnitte (specialty) nur im Divi-Builder umbauen; Module in ihre Spalten einfügen'; + } + + if ($type === 'fullwidth') { + return str_starts_with($child, self::FULLWIDTH) + ? null + : 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module'; + } + + return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)'; + } +} diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 4de4702..c054883 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -115,6 +115,36 @@ final class VariableRef return str_contains($value, self::MARKER); } + /** + * Type ("color" or "content") of $value when it is exactly one reference whose payload + * names that type; null otherwise. Reads the payload, never a string prefix (ruling S33c); + * it does not validate the reference – that is canonical()'s job. + */ + public static function typeOf(string $value): ?string + { + if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) { + return null; + } + + try { + $type = self::decode($match[1])['type'] ?? null; + } catch (InvalidArgumentException) { + return null; + } + + return $type === 'color' || $type === 'content' ? $type : null; + } + + /** + * Removes C0 control characters except tab, LF and CR, and DEL (ruling S32). The WordPress + * sanitizers keep them (sanitize_text_field keeps NUL), and "$variable\0(" would otherwise + * hide a marker from the final-string check that a later strip anywhere could rebuild. + */ + public static function withoutControls(string $value): string + { + return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); + } + /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { @@ -336,7 +366,11 @@ final class VariableRef $clean = is_string($value) ? $sanitizer->text($value) : $value; } - // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). + // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("); control + // characters go first so a NUL-split marker is rebuilt – and caught – here (S32). + if (is_string($clean)) { + $clean = self::withoutControls($clean); + } if (is_string($clean) && (self::contains($clean) || str_contains($clean, ')$'))) { throw self::notAlone(); } diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index ca17697..a2a939c 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -375,4 +375,63 @@ final class AttrWriterTest extends TestCase $result['attrs']['module']['decoration']['background']['desktop']['value']['color'] ); } + + /** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */ + public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void + { + $writer = ComposeFixture::attrWriter(); + + self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f")); + self::assertSame("

    a\nb\r

    ", $writer->cleanValue('divi/text', 'content.innerContent', "

    a\x0b\nb\r\x08

    ")); + self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f")); + + $tail = '({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'; + $targets = [ + 'text' => ['divi/heading', 'title.innerContent'], + 'html' => ['divi/text', 'content.innerContent'], + 'code' => ['divi/code', 'content.innerContent'], + ]; + foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) { + $value = '$variable' . $control . $tail; + // The fake keeps them like sanitize_text_field does: the production code must catch it. + self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text($value)); + foreach ($targets as $kind => [$module, $path]) { + try { + $writer->cleanValue($module, $path, $value); + self::fail(sprintf('%s: control %d not caught', $kind, ord($control))); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('allein im Wert', $e->getMessage()); + } + } + } + } + + /** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */ + public function testKindOverrideMayOnlyTighten(): void + { + $writer = ComposeFixture::attrWriter(); + + self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '

    x

    ', null, 'text')); + self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', 'x', null, 'text')); + self::assertSame('

    x

    ', $writer->cleanValue('divi/code', 'content.innerContent', '

    x

    ', null, 'html')); + self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', 'Los', null, 'text')); + self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url')); + + $weaker = [ + 'html over text' => ['divi/heading', 'title.innerContent', null, 'html'], + 'code over text' => ['divi/heading', 'title.innerContent', null, 'code'], + 'code over html' => ['divi/text', 'content.innerContent', null, 'code'], + 'url over text' => ['divi/heading', 'title.innerContent', null, 'url'], + 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'], + 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'], + ]; + foreach ($weaker as $name => [$module, $path, $sub, $kind]) { + try { + $writer->cleanValue($module, $path, 'x', $sub, $kind); + self::fail($name . ' was accepted'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('Feldart', $e->getMessage(), $name); + } + } + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 9ab2c23..54f5afb 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +use LIT\MCP\Integrations\Divi\Compose\Composer; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +use LIT\MCP\Integrations\Divi\Compose\StructureRules; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; +use LIT\MCP\Integrations\Divi\Design\DesignSystem; use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; @@ -19,6 +22,9 @@ use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; */ final class ComposeFixture { + /** A valid content reference as an attachment's alt text (media ids 44 and 45). */ + public const REFERENCE_ALT = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$'; + public static function sanitizer(bool $unfiltered = false): ValueSanitizer { return new class ($unfiltered) implements ValueSanitizer { @@ -26,12 +32,26 @@ final class ComposeFixture { } - /** Like sanitize_text_field: NUL bytes, tags and %XX octets go, then trim. */ + /** + * Like sanitize_text_field (_sanitize_text_fields): tags only go when there is a "<" + * (wp_strip_all_tags; PHP's strip_tags also drops NUL then), whitespace runs become one + * space, trim, %XX octets go repeatedly. Without a "<", NUL and other control + * characters stay – as in WordPress (ruling S32: AttrWriter strips them). + */ public function text(string $value): string { - $value = strip_tags(str_replace(chr(0), '', $value)); + if (str_contains($value, '<')) { + $value = strip_tags((string) preg_replace('@<(script|style)[^>]*?>.*?@si', '', $value)); + } - return trim((string) preg_replace('/%[a-f0-9]{2}/i', '', $value)); + $value = trim((string) preg_replace('/[\r\n\t ]+/', ' ', $value)); + $found = false; + while (preg_match('/%[a-f0-9]{2}/i', $value, $match) === 1) { + $value = str_replace($match[0], '', $value); + $found = true; + } + + return $found ? trim((string) preg_replace('/ +/', ' ', $value)) : $value; } /** @@ -89,6 +109,10 @@ final class ComposeFixture return match ($id) { 33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'], 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''], + // Alt texts carrying a reference, whole or NUL-split (ruling S33b: dropped). + 44 => ['url' => 'https://kunde.test/wp-content/uploads/a.jpg', 'alt' => ComposeFixture::REFERENCE_ALT], + 45 => ['url' => 'https://kunde.test/wp-content/uploads/b.jpg', 'alt' => '$variable' . chr(0) + . substr(ComposeFixture::REFERENCE_ALT, strlen('$variable'))], default => null, }; } @@ -113,4 +137,29 @@ final class ComposeFixture { return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules); } + + public static function rules(): StructureRules + { + return new StructureRules(ModuleCatalogTest::sampleCatalog()); + } + + /** + * Builder version 5.2.1. $design replaces the sample design system (one Placeholders instance + * shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31). + */ + public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer + { + $design ??= DesignSystemTest::sample(); + $placeholders = new Placeholders($design); + $attrWriter = new AttrWriter(ModuleCatalogTest::sampleCatalog(), $placeholders, self::sanitizer($unfiltered)); + + return new Composer( + ModuleCatalogTest::sampleCatalog(), + self::rules(), + new ShortformWriter($placeholders, self::media(), $attrWriter), + $attrWriter, + $design, + '5.2.1' + ); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php new file mode 100644 index 0000000..eff8f84 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -0,0 +1,366 @@ +compose([[ + 'type' => 'section', + 'label' => 'Hero', + 'background_color' => '{{color:Nachtblau}}', + 'rows' => [[ + 'columns' => '1_3,2_3', + 'modules' => [ + [['type' => 'image', 'image_id' => 33]], + [ + ['type' => 'heading', 'text' => 'Willkommen'], + ['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'], + ], + ], + ]], + ]], null, 'nodes'); + + self::assertSame([], $result['errors']); + self::assertStringStartsWith( + '', + Serializer::block($result['blocks'][0]) + ); + + $section = $result['blocks'][0]; + [$heading, $button] = $section->children()[0]->children()[1]->children(); + self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1])); + self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']); + self::assertSame('Hero – Heading', NodeSummary::label($heading)); + self::assertSame(['p-2'], $button->attrs()['modulePreset']); + + // What the composer writes, the parser reads back unchanged. + $markup = Serializer::block($section); + self::assertSame($markup, Serializer::document((new Parser())->parse($markup))); + self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque()); + } + + public function testShortcutsItemsFullwidthAndFullForm(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'section', 'modules' => [ + ['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => '

    Antwort

    ', 'open' => true]]], + ['type' => 'text', 'content' => '

    x

    ', 'label' => 'Einleitung', + 'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]], + ]], + ['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [ + ['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]], + ]], + ], null, 'nodes'); + + self::assertSame([], $result['errors']); + [$regular, $fullwidth] = $result['blocks']; + $column = $regular->children()[0]->children()[0]; + self::assertSame('24_24', NodeSummary::columnWidth($column)); + [$accordion, $text] = $column->children(); + self::assertSame('divi/accordion-item', $accordion->children()[0]->name()); + self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']); + self::assertSame('Einleitung', NodeSummary::label($text)); + self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']); + self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']); + self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name()); + } + + public function testCollectsEveryErrorWithItsInputPath(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'button', 'text' => 'x'], + ['type' => 'section', 'rows' => [ + ['columns' => '1_2,1_3', 'modules' => []], + ['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]], + ]], + ['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1], + ['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]], + ], null, 'nodes'); + + self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']); + self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']); + self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']); + self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']); + self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']); + self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']); + self::assertSame(['title.fnt'], $result['invalid_paths']); + self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']); + } + + public function testColumnWidths(): void + { + $composer = ComposeFixture::composer(); + $widths = static function (string $columns) use ($composer): array { + $result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n'); + + return [ + array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()), + $result['errors'], + ]; + }; + + self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2')); + self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4')); + self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5')); + self::assertSame(['24_24'], $widths('1_7,5_7')[0]); + self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]); + self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]); + } + + public function testUpdateChangesShortFormPresetAndLabel(): void + { + $block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); + + $result = ComposeFixture::composer()->update($block, [ + 'set' => ['text' => 'Neu', 'url' => '/neu'], + 'preset' => 'Standard', + 'label' => 'Hero – CTA', + ], 'changes[0]'); + + self::assertSame([], $result['errors']); + self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']); + self::assertSame(['p-1'], $result['attrs']['modulePreset']); + self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']); + // update() only computes attributes; the block itself stays as it was. + self::assertArrayNotHasKey('modulePreset', $block->attrs()); + + $cleared = ComposeFixture::composer()->update( + Block::create('divi/button', $result['attrs']), + ['preset' => null, 'label' => null], + 'changes[0]' + ); + self::assertSame([], $cleared['errors']); + self::assertArrayNotHasKey('modulePreset', $cleared['attrs']); + self::assertArrayNotHasKey('module', $cleared['attrs']); + } + + public function testUpdateReportsBadInput(): void + { + $block = Block::create('divi/button', ['builderVersion' => '5.2.1']); + + $result = ComposeFixture::composer()->update($block, [ + 'id' => 's0.r0.c0.m0', + 'set' => ['nope'], + 'label' => 42, + 'colour' => 'x', + ], 'changes[1]'); + + self::assertArrayHasKey('changes[1].set', $result['errors']); + self::assertArrayHasKey('changes[1].label', $result['errors']); + self::assertStringContainsString('unbekanntes Feld', $result['errors']['changes[1].colour']); + self::assertArrayNotHasKey('changes[1].id', $result['errors']); + self::assertSame(['builderVersion' => '5.2.1'], $result['attrs']); + + $opaque = Block::parsed('divi/button', [], '', false, true); + $readOnly = ComposeFixture::composer()->update($opaque, ['label' => 'x'], 'changes[2]'); + self::assertStringContainsString('nur lesbar', $readOnly['errors']['changes[2].id']); + } + + /** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */ + public function testLabelsAreCleanedOrReported(): void + { + $reference = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$'; + $result = ComposeFixture::composer()->compose([ + ['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'x']]], + ['type' => 'section', 'label' => '$variable(' . substr($reference, strlen('$variable(')), + 'modules' => [['type' => 'heading', 'text' => 'x']]], + ['type' => 'section', 'label' => '$variable' . chr(0) . substr($reference, strlen('$variable'))], + ['type' => 'section', 'label' => $reference], + ['type' => 'section', 'label' => ['Hero']], + ['type' => 'section', 'modules' => [['type' => 'heading', 'label' => "Titel\x01\x7f"]]], + ], null, 'nodes'); + + [$clean, $split, $nul, $whole, $list, $control] = $result['blocks']; + self::assertSame('Hero', NodeSummary::label($clean)); + self::assertSame('Hero – Heading', NodeSummary::label($clean->children()[0]->children()[0]->children()[0])); + + foreach ([1, 2, 3, 4] as $position) { + self::assertArrayHasKey(sprintf('nodes[%d].label', $position), $result['errors']); + } + foreach ([$split, $nul, $whole, $list] as $section) { + self::assertSame('', NodeSummary::label($section)); + } + // No raw label reaches the children's auto labels either. + self::assertSame('Heading', NodeSummary::label($split->children()[0]->children()[0]->children()[0])); + self::assertStringNotContainsString('$variable', Serializer::block($split)); + + // Ruling S32: control characters are stripped from every final string. + self::assertSame('Titel', NodeSummary::label($control->children()[0]->children()[0]->children()[0])); + } + + /** Ruling S13: background_color takes the short-form color check, not text(). */ + public function testBackgroundColorIsAColor(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'section', 'background_color' => '#1a1a1a'], + ['type' => 'section', 'background_color' => 'red;}'], + ['type' => 'section', 'background_color' => 'red;}'], + ['type' => 'section', 'background_color' => '{{post:title}}'], + ['type' => 'section', 'background_color' => '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + ['type' => 'section', 'background_color' => ['#fff']], + ], null, 'nodes'); + + self::assertSame('#1a1a1a', $result['blocks'][0]->attrs()['module']['decoration']['background']['desktop']['value']['color']); + foreach ([1, 2, 3, 4, 5] as $position) { + self::assertStringContainsString('Farbe erwartet', $result['errors'][sprintf('nodes[%d].background_color', $position)]); + self::assertArrayNotHasKey('module', $result['blocks'][$position]->attrs()); + } + } + + /** Ruling S13: a fullwidth section holds fullwidth modules; rows there are an error, not ignored. */ + public function testFullwidthSectionWithRowsIsAnError(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'section', 'section_type' => 'fullwidth', 'rows' => [['columns' => '1_2,1_2']]], + ['type' => 'section', 'rows' => [], 'modules' => []], + ['type' => 'section', 'section_type' => 'specialty'], + ['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [['type' => 'heading', 'text' => 'x']]], + ], null, 'nodes'); + + self::assertStringContainsString('Vollbreite', $result['errors']['nodes[0].rows']); + self::assertSame([], $result['blocks'][0]->children()); + self::assertStringContainsString('entweder rows oder modules', $result['errors']['nodes[1]']); + self::assertStringContainsString('specialty', $result['errors']['nodes[2].section_type']); + self::assertStringContainsString('fullwidth-Module', $result['errors']['nodes[3].modules[0].type']); + } + + /** Ruling S13a: an ambiguous design-system name is an error listing the ids; ids work directly. */ + public function testAmbiguousDesignNamesFailWithTheIds(): void + { + $design = DesignSystem::fromDivi( + [ + 'gcid-b1' => ['color' => '#00f', 'label' => 'Blau', 'status' => 'active'], + 'gcid-b2' => ['color' => '#00a', 'label' => 'blau', 'status' => 'active'], + ], + [], + ['module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-3' => ['id' => 'p-3', 'name' => 'standard', 'moduleName' => 'divi/button'], + ]]]], + [] + ); + $composer = ComposeFixture::composer(false, $design); + + $result = $composer->compose([ + ['type' => 'section', 'background_color' => '{{color:Blau}}', 'modules' => [ + ['type' => 'button', 'preset' => 'Standard'], + ['type' => 'button', 'preset' => 'p-3'], + ]], + ], null, 'nodes'); + + self::assertStringContainsString('gcid-b1, gcid-b2', $result['errors']['nodes[0].background_color']); + self::assertStringContainsString('mehrdeutig', $result['errors']['nodes[0].modules[0].modules[0][0].preset']); + self::assertStringContainsString('p-1, p-3', $result['errors']['nodes[0].modules[0].modules[0][0].preset']); + self::assertArrayNotHasKey('nodes[0].modules[0].modules[0][1].preset', $result['errors']); + [$ambiguous, $byId] = $result['blocks'][0]->children()[0]->children()[0]->children(); + self::assertArrayNotHasKey('modulePreset', $ambiguous->attrs()); + self::assertSame(['p-3'], $byId->attrs()['modulePreset']); + + $update = $composer->update(Block::create('divi/button'), ['preset' => 'STANDARD'], 'changes[0]'); + self::assertStringContainsString('p-1, p-3', $update['errors']['changes[0].preset']); + } + + /** Preset ids come from the design system but still pass the choke point unchanged. */ + public function testPresetIdsPassTheChokePoint(): void + { + $design = DesignSystem::fromDivi([], [], ['module' => ['divi/button' => ['default' => '', 'items' => [ + 'p' => ['id' => 'p', 'name' => 'Seltsam', 'moduleName' => 'divi/button'], + ]]]], []); + + $result = ComposeFixture::composer(false, $design)->update( + Block::create('divi/button'), + ['preset' => 'Seltsam'], + 'changes[0]' + ); + + self::assertArrayHasKey('changes[0].preset', $result['errors']); + self::assertArrayNotHasKey('modulePreset', $result['attrs']); + } + + public function testBuilderVersionMustBeAVersion(): void + { + $this->expectException(InvalidArgumentException::class); + + new Composer( + ModuleCatalogTest::sampleCatalog(), + ComposeFixture::rules(), + ComposeFixture::shortform(), + ComposeFixture::attrWriter(), + DesignSystemTest::sample(), + '5.2.1"},"x":{"' + ); + } + + public function testAutoLabel(): void + { + $composer = ComposeFixture::composer(); + + self::assertSame('Hero – Button', $composer->autoLabel('divi/button', 'Hero')); + self::assertSame('Button', $composer->autoLabel('divi/button', '')); + } + + public function testComposesIntoAnExistingParent(): void + { + $composer = ComposeFixture::composer(); + $column = Block::create('divi/column'); + $accordion = Block::create('divi/accordion'); + + $modules = $composer->compose([['type' => 'button', 'text' => 'Los']], $column, 'nodes', 'Hero'); + $items = $composer->compose([['title' => 'Frage']], $accordion, 'nodes'); + + self::assertSame([], $modules['errors']); + self::assertSame('Hero – Button', NodeSummary::label($modules['blocks'][0])); + self::assertSame([], $items['errors']); + self::assertSame('divi/accordion-item', $items['blocks'][0]->name()); + // compose() builds blocks; inserting them is the caller's job. + self::assertSame([], $column->children()); + + $opaque = Block::parsed('divi/column', [], '', false, true); + $readOnly = $composer->compose([['type' => 'button']], $opaque, 'nodes'); + self::assertStringContainsString('nur lesbar', $readOnly['errors']['nodes[0].type']); + self::assertSame([], $readOnly['blocks']); + } + + public function testRejectsNonObjectsAndMissingTypes(): void + { + $result = ComposeFixture::composer()->compose(['section', [], ['label' => 'x']], null, 'nodes'); + + self::assertArrayHasKey('nodes[0]', $result['errors']); + self::assertArrayHasKey('nodes[1]', $result['errors']); + self::assertSame('type fehlt', $result['errors']['nodes[2].type']); + self::assertSame([], $result['blocks']); + } + + public function testTooManyModuleListsForTheColumns(): void + { + $result = ComposeFixture::composer()->compose([['type' => 'section', 'rows' => [[ + 'columns' => '1_2,1_2', + 'modules' => [[], [], [['type' => 'text']]], + ]]]], null, 'nodes'); + + self::assertStringContainsString('2 Spalten, aber 3 Modul-Listen', $result['errors']['nodes[0].rows[0].modules']); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 633ff2a..70ca3bf 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -215,11 +215,16 @@ final class ShortformWriterTest extends TestCase { $rows = self::malicious(); $sanitizer = ComposeFixture::sanitizer(); - foreach (['text: tag-split marker', 'text: NUL-split marker', 'text: octet-split marker'] as $row) { + foreach (['text: tag-split marker', 'text: octet-split marker'] as $row) { self::assertStringNotContainsString('$variable(', $rows[$row][1]); self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1])); } + // S32: like sanitize_text_field, the fake text() keeps NUL – so the marker the NUL row + // hides is rebuilt only by AttrWriter's control-character stripping (production code). + self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker'][1])); + self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker'][1])); + foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) { self::assertStringNotContainsString('$variable(', $rows[$row][1]); self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1])); @@ -228,6 +233,18 @@ final class ShortformWriterTest extends TestCase self::assertStringContainsString(chr(0), $rows['text: NUL-split marker'][1]); } + /** Ruling S33b: an attachment alt carrying a reference (whole or split) is dropped, never canonicalized. */ + public function testMediaAltWithAReferenceIsDropped(): void + { + foreach ([44, 45] as $id) { + $result = ComposeFixture::shortform()->apply('image', [], ['image_id' => $id], 'n'); + + self::assertSame([], $result['errors'], (string) $id); + self::assertSame(['src', 'id'], array_keys($result['attrs']['image']['innerContent']['desktop']['value'])); + self::assertStringNotContainsString('variable', (string) json_encode($result['attrs'])); + } + } + /** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */ public function testReferenceSettingsInTextAreCleaned(): void { diff --git a/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php new file mode 100644 index 0000000..38f00cb --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php @@ -0,0 +1,66 @@ + Parent, child, part of the reason (null = allowed). */ + public static function placements(): array + { + $section = static fn (string $type): Block => Block::create( + 'divi/section', + ['module' => ['advanced' => ['type' => ['desktop' => ['value' => $type]]]]] + ); + + return [ + 'section on top' => [null, 'divi/section', null], + 'module on top' => [null, 'divi/text', 'oberster Ebene'], + 'unknown module' => [Block::create('divi/column'), 'divi/slider-pro', 'unbekanntes Modul'], + 'row in a section' => [Block::create('divi/section'), 'divi/row', null], + 'module in a section' => [Block::create('divi/section'), 'divi/text', 'Zeilen'], + 'fullwidth module in a fullwidth section' => [$section('fullwidth'), 'divi/fullwidth-header', null], + 'row in a fullwidth section' => [$section('fullwidth'), 'divi/row', 'fullwidth-Module'], + 'anything in a specialty section' => [$section('specialty'), 'divi/row', 'Spezialabschnitte'], + 'column in a row' => [Block::create('divi/row'), 'divi/column', 'Spalten entstehen mit der Zeile'], + 'module in a column' => [Block::create('divi/column'), 'divi/button', null], + 'group in a column' => [Block::create('divi/column'), 'divi/group', null], + 'module in a group' => [Block::create('divi/group'), 'divi/text', null], + 'fullwidth module in a column' => [Block::create('divi/column'), 'divi/fullwidth-header', 'passt nicht'], + 'child module in a column' => [Block::create('divi/column'), 'divi/accordion-item', 'passt nicht'], + 'section in a column' => [Block::create('divi/column'), 'divi/section', 'passt nicht'], + 'item in its parent' => [Block::create('divi/accordion'), 'divi/accordion-item', null], + 'other module in a parent' => [Block::create('divi/accordion'), 'divi/text', 'erlaubt nur divi/accordion-item'], + 'child in a plain module' => [Block::create('divi/text'), 'divi/button', 'keine Kind-Module'], + 'opaque parent' => [Block::parsed('divi/column', [], '', false, true), 'divi/text', 'nur lesbar'], + 'foreign parent' => [Block::create('core/group'), 'divi/text', 'nur lesbar'], + ]; + } + + /** @dataProvider placements */ + public function testWhatGoesWhere(?Block $parent, string $child, ?string $reason): void + { + $violation = ComposeFixture::rules()->violation($parent, $child); + + if ($reason === null) { + self::assertNull($violation); + } else { + self::assertIsString($violation); + self::assertStringContainsString($reason, $violation); + } + } + + public function testPlainModules(): void + { + $rules = ComposeFixture::rules(); + + self::assertTrue($rules->isPlainModule('divi/button')); + self::assertFalse($rules->isPlainModule('divi/fullwidth-header')); + self::assertFalse($rules->isPlainModule('divi/accordion-item')); + self::assertFalse($rules->isPlainModule('divi/row')); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index bfedaf0..32efd41 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -161,6 +161,15 @@ final class VariableRefTest extends TestCase 'allein im Wert', ], 'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), 'allein im Wert'], + // S32: text() keeps control characters (like sanitize_text_field); VariableRef strips them. + 'NUL-split marker in date format' => [ + self::content('post_date', '{"custom_date_format":"$variable' . self::escape('0000') . '(y"}'), + 'allein im Wert', + ], + 'DEL-split marker in date format' => [ + self::content('post_date', '{"custom_date_format":"$variable' . self::escape('007f') . '(y"}'), + 'allein im Wert', + ], 'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'], 'broken json' => ['$variable(kaputt)$', 'JSON'], 'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'], @@ -395,6 +404,19 @@ final class VariableRefTest extends TestCase self::ref($map); // @phpstan-ignore-line } + /** Ruling S33c: the type of a whole reference, read from its payload (not from a string prefix). */ + public function testTypeOfAReference(): void + { + self::assertSame('color', VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')); + self::assertSame('content', VariableRef::typeOf(self::content('post_title', '{}'))); + self::assertSame('color', VariableRef::typeOf('$variable({"value":{"name":"gcid-a1b2"},"type":"color"})$')); + self::assertNull(VariableRef::typeOf('#1a1a1a')); + self::assertNull(VariableRef::typeOf('$variable(kaputt)$')); + self::assertNull(VariableRef::typeOf('$variable({"type":"evil","value":{"name":"x"}})$')); + self::assertNull(VariableRef::typeOf('#fff $variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')); + self::assertNull(VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$ ')); + } + public function testDetectsReferences(): void { self::assertTrue(VariableRef::contains('a $variable( b')); From 8688a024022072c27e76210b65209962960904ae Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 14:26:18 +0200 Subject: [PATCH 113/185] fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors Control characters now go on the raw value in AttrWriter::cleanNode and VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF no longer turns into a tag). VALUE_KEY anchored with D, exact column-width fractions, nested rows in columns, specialty columns refused, fullwidth by catalog category, preset ambiguity in DesignSystem::modulePresetId(). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/AttrWriter.php | 16 +-- src/Integrations/Divi/Compose/Composer.php | 97 +++++++++---------- .../Divi/Compose/ShortformWriter.php | 6 +- .../Divi/Compose/StructureRules.php | 44 +++++++-- src/Integrations/Divi/Compose/VariableRef.php | 25 +++-- src/Integrations/Divi/Design/DesignSystem.php | 37 +++++-- .../Divi/Compose/AttrWriterTest.php | 63 +++++++++--- .../Divi/Compose/ComposeFixture.php | 3 + .../Divi/Compose/ComposerTest.php | 29 +++++- .../Divi/Compose/ShortformWriterTest.php | 27 ++++-- .../Divi/Compose/StructureRulesTest.php | 12 +++ .../Divi/Compose/VariableRefTest.php | 9 ++ .../Divi/Design/DesignSystemTest.php | 14 +++ 13 files changed, 278 insertions(+), 104 deletions(-) diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 615ef83..bdd59c5 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -29,7 +29,7 @@ final class AttrWriter private const URL_KEY = '/(url|src|link|href|image)$/i'; /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */ - private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/'; + private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D'; /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ private const URL_SEGMENT = '/url$/i'; @@ -202,6 +202,11 @@ final class AttrWriter throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.'); } + // Ruling S32/S32a: control characters go on the RAW value, before the marker check and + // before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<" + // for strip_tags), so any later strip could build a tag or a marker nobody checked. + $value = VariableRef::withoutControls($value); + $routed = $this->kind($module, $path, $subName, $key); if ($kind !== null && ! self::tightens($kind, $routed)) { throw new InvalidArgumentException(sprintf( @@ -225,11 +230,10 @@ final class AttrWriter default => $this->sanitizer->text($value), }; - // Ruling S32: control characters (the WordPress sanitizers keep NUL & co.) go before the - // check. Ruling S28: sanitizers strip characters ("", NUL, "%41") and can thereby build - // a marker the raw check above never saw – so the final string is checked again. - $clean = VariableRef::withoutControls($clean); - if (VariableRef::contains($clean)) { + // Ruling S28: sanitizers strip characters ("", "%41") and can thereby build a marker the + // raw check above never saw – so the final string is checked again. After the sanitizer + // the only action is rejection (S32a): nothing transforms the cleaned string any more. + if (VariableRef::contains($clean) || VariableRef::withoutControls($clean) !== $clean) { throw VariableRef::notAlone(); } diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index 6c62b9f..81e9c93 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -25,7 +25,7 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap; * writes itself is not client text: the builder version (checked in the constructor), block * names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built * from digits. Design-system names that match several entries are errors listing the ids - * (ruling S13a: colors/variables in Placeholders, presets here). + * (ruling S13a: colors/variables in Placeholders, presets in DesignSystem::modulePresetId()). */ final class Composer { @@ -390,16 +390,22 @@ final class Composer return $attrs; } - $id = is_string($preset) ? $this->presetId($name, $preset, $path) : null; + try { + $id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null; + } catch (InvalidArgumentException $e) { + // Ruling S13a: a name several presets share (DesignSystem lists their ids). + $this->errors[$path] = $e->getMessage(); + + return $attrs; + } + if ($id === null) { - if (! isset($this->errors[$path])) { - $names = $this->design->modulePresetNames($name); - $this->errors[$path] = sprintf( - 'unbekanntes Preset; vorhanden für %s: %s', - $name, - $names === [] ? '(keine)' : implode(', ', $names) - ); - } + $names = $this->design->modulePresetNames($name); + $this->errors[$path] = sprintf( + 'unbekanntes Preset; vorhanden für %s: %s', + $name, + $names === [] ? '(keine)' : implode(', ', $names) + ); return $attrs; } @@ -422,42 +428,6 @@ final class Composer return $attrs; } - /** - * Module preset id by id, else by name (case-insensitive); a name several presets of the - * module share is an error listing their ids (ruling S13a). Null when unknown or ambiguous. - */ - private function presetId(string $name, string $preset, string $path): ?string - { - $wanted = trim($preset); - $key = mb_strtolower($wanted); - $ids = []; - foreach ($this->design->presets() as $entry) { - if ($entry['kind'] !== 'module' || $entry['module'] !== $name) { - continue; - } - - if ($entry['id'] === $wanted) { - return $entry['id']; - } - - if (mb_strtolower($entry['name']) === $key) { - $ids[] = $entry['id']; - } - } - - if (count($ids) > 1) { - $this->errors[$path] = sprintf( - 'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.', - $wanted, - implode(', ', $ids) - ); - - return null; - } - - return $ids[0] ?? null; - } - /** * Ruling S13: the short-form color check (ShortformWriter::color()), never text(). * @@ -494,7 +464,8 @@ final class Composer } /** - * "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row. + * "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to exactly one row + * (exact integer arithmetic over the common denominator, ruling S34 – no float tolerance). * * @return list */ @@ -502,7 +473,7 @@ final class Composer { $parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : []; $widths = []; - $sum = 0.0; + $fractions = []; foreach ($parts as $part) { $valid = preg_match('/^(\d{1,2})_(\d{1,2})$/D', $part, $match) === 1 && (int) $match[1] >= 1 @@ -523,10 +494,10 @@ final class Composer return ['24_24']; } - $sum += $numerator / $denominator; + $fractions[] = [$numerator, $denominator]; } - if ($widths === [] || abs($sum - 1.0) > 0.01) { + if ($widths === [] || ! self::sumsToOne($fractions)) { $this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")'; return ['24_24']; @@ -535,6 +506,32 @@ final class Composer return $widths; } + /** + * Whether the fractions add up to exactly 1: numerators scaled to the least common + * denominator (denominators are divisors of 24 or 5, 7, 9, 10, 11 – at most 27720). + * + * @param list $fractions [numerator, denominator] + */ + private static function sumsToOne(array $fractions): bool + { + $common = 1; + foreach ($fractions as [, $denominator]) { + $common = intdiv($common * $denominator, self::gcd($common, $denominator)); + } + + $sum = 0; + foreach ($fractions as [$numerator, $denominator]) { + $sum += $numerator * intdiv($common, $denominator); + } + + return $sum === $common; + } + + private static function gcd(int $a, int $b): int + { + return $b === 0 ? $a : self::gcd($b, $a % $b); + } + /** * @param array $spec * @param list $allowed diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index 8442906..a709625 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -177,7 +177,9 @@ final class ShortformWriter */ private function alt(string $module, string $path, string $alt, string $altKey): string { - if ($alt === '' || VariableRef::contains($alt)) { + // Controls are stripped at the choke point before its marker check (S32a) – so a + // NUL-split marker counts as a marker here too, and a canonical result is refused. + if ($alt === '' || VariableRef::contains(VariableRef::withoutControls($alt))) { return ''; } @@ -187,7 +189,7 @@ final class ShortformWriter return ''; } - return is_string($clean) ? $clean : ''; + return is_string($clean) && ! VariableRef::contains($clean) ? $clean : ''; } /** diff --git a/src/Integrations/Divi/Compose/StructureRules.php b/src/Integrations/Divi/Compose/StructureRules.php index 7de7bc4..c2645e8 100644 --- a/src/Integrations/Divi/Compose/StructureRules.php +++ b/src/Integrations/Divi/Compose/StructureRules.php @@ -10,15 +10,19 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; /** * Which node may sit where (spec §5.4 "Struktur"): page → sections; regular section → rows; - * fullwidth section → fullwidth modules; column/group → modules; parent module → its child - * modules (catalog). Columns only come with their row; specialty sections and read-only - * nodes (opaque, non-Divi) are not rebuilt. Pure PHP. + * fullwidth section → fullwidth modules (catalog category "fullwidth-module"); column/group → + * modules; column → a nested row too (Divi 5 row is "nestable": true; neither the server + * – BlockParserStore::is_nested_module() only adds et_pb_row_nested – nor the builder's drop + * rules limit the depth); parent module → its child modules (catalog). Columns only come with + * their row; specialty sections, specialty columns (module.advanced.specialtyColumns or + * row-inner children, ColumnModule.php:306-324) and read-only nodes (opaque, non-Divi) are not + * rebuilt. Pure PHP. */ final class StructureRules { private const COLUMNS = ['divi/column', 'divi/column-inner']; private const ROWS = ['divi/row', 'divi/row-inner']; - private const FULLWIDTH = 'divi/fullwidth-'; + private const FULLWIDTH = 'fullwidth-module'; public function __construct(private ModuleCatalog $catalog) { @@ -49,8 +53,15 @@ final class StructureRules . 'einfügen'; } + if (in_array($parentName, self::COLUMNS, true) && self::isSpecialtyColumn($parent)) { + return 'Spezialspalten (specialty) nur im Divi-Builder umbauen; Module in ihre inneren Spalten einfügen'; + } + if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') { - return $this->isPlainModule($child) || $child === 'divi/group' + // A nested row: only straight into a (Divi 5) column – like the builder's drop target. + $nestedRow = $child === 'divi/row' && $parentName === 'divi/column'; + + return $this->isPlainModule($child) || $child === 'divi/group' || $nestedRow ? null : sprintf('%s passt nicht in eine Spalte', $child); } @@ -65,10 +76,27 @@ final class StructureRules : sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children)); } - /** A module that goes into a column: category "module", not a fullwidth module. */ + /** A module that goes into a column: catalog category "module" (not "fullwidth-module"). */ public function isPlainModule(string $name): bool { - return $this->catalog->category($name) === 'module' && ! str_starts_with($name, self::FULLWIDTH); + return $this->catalog->category($name) === 'module'; + } + + /** A column of a specialty section: it holds inner rows, not modules. */ + private static function isSpecialtyColumn(Block $column): bool + { + $specialty = AttrMap::desktop($column->attrs(), 'module.advanced.specialtyColumns'); + if ($specialty !== null && $specialty !== '') { + return true; + } + + foreach ($column->children() as $child) { + if ($child->name() === 'divi/row-inner') { + return true; + } + } + + return false; } private function inSection(Block $section, string $child): ?string @@ -79,7 +107,7 @@ final class StructureRules } if ($type === 'fullwidth') { - return str_starts_with($child, self::FULLWIDTH) + return $this->catalog->category($child) === self::FULLWIDTH ? null : 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module'; } diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index c054883..7367d58 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -136,9 +136,10 @@ final class VariableRef } /** - * Removes C0 control characters except tab, LF and CR, and DEL (ruling S32). The WordPress - * sanitizers keep them (sanitize_text_field keeps NUL), and "$variable\0(" would otherwise - * hide a marker from the final-string check that a later strip anywhere could rebuild. + * Removes C0 control characters except tab, LF and CR, and DEL (rulings S32/S32a). Applied + * to RAW values only, before the marker check and the sanitizer: the WordPress sanitizers keep + * them (sanitize_text_field keeps NUL; strip_tags reads "<" + VT/FF as a literal "<"), so a + * strip after sanitizing could build a marker or a tag no check saw. */ public static function withoutControls(string $value): string { @@ -341,6 +342,12 @@ final class VariableRef /** Cleans one content setting by its field type (ruling S21/S22). */ private static function setting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed { + // Ruling S32/S32a: control characters go on the RAW value, before every check and the + // sanitizer ("<" + VT stays a literal "<" for strip_tags; a later strip would build a tag). + if (is_string($value)) { + $value = self::withoutControls($value); + } + if (is_string($value) && (self::contains($value) || str_contains($value, ')$'))) { throw self::notAlone(); } @@ -366,12 +373,12 @@ final class VariableRef $clean = is_string($value) ? $sanitizer->text($value) : $value; } - // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("); control - // characters go first so a NUL-split marker is rebuilt – and caught – here (S32). - if (is_string($clean)) { - $clean = self::withoutControls($clean); - } - if (is_string($clean) && (self::contains($clean) || str_contains($clean, ')$'))) { + // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). After the + // sanitizer the only action is rejection (S32a) – controls went on the raw value above. + if ( + is_string($clean) + && (self::contains($clean) || str_contains($clean, ')$') || self::withoutControls($clean) !== $clean) + ) { throw self::notAlone(); } diff --git a/src/Integrations/Divi/Design/DesignSystem.php b/src/Integrations/Divi/Design/DesignSystem.php index ceaa554..a030b59 100644 --- a/src/Integrations/Divi/Design/DesignSystem.php +++ b/src/Integrations/Divi/Design/DesignSystem.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; +use InvalidArgumentException; + /** * The site's Divi design system as read live from Divi (spec §2 principle 2, §5.7): global * colors, global variables, theme fonts and sizes, module and group presets. Pure PHP – @@ -162,21 +164,40 @@ final class DesignSystem return null; } - /** Module preset ID by name or ID for a full module name ("divi/button"); null when unknown. */ + /** + * Module preset ID for a full module name ("divi/button"): an exact ID first, else the one + * preset of that name (case-insensitive); null when unknown. + * + * @throws InvalidArgumentException When several presets of the module share the name (ruling S13a). + */ public function modulePresetId(string $module, string $name): ?string { - $key = mb_strtolower(trim($name)); + $wanted = trim($name); + $key = mb_strtolower($wanted); + $ids = []; foreach ($this->presets as $preset) { - if ( - $preset['kind'] === 'module' - && $preset['module'] === $module - && ($preset['id'] === trim($name) || mb_strtolower($preset['name']) === $key) - ) { + if ($preset['kind'] !== 'module' || $preset['module'] !== $module) { + continue; + } + + if ($preset['id'] === $wanted) { return $preset['id']; } + + if (mb_strtolower($preset['name']) === $key) { + $ids[] = $preset['id']; + } } - return null; + if (count($ids) > 1) { + throw new InvalidArgumentException(sprintf( + 'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.', + $wanted, + implode(', ', $ids) + )); + } + + return $ids[0] ?? null; } /** @return list Names of the module presets of $module. */ diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index a2a939c..78afb71 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -177,9 +177,6 @@ final class AttrWriterTest extends TestCase $cases['tag-split marker in rich text'] = [ 'divi/text', 'content.innerContent', ['desktop' => '$variable' . $reference], self::TEXT, ]; - $cases['NUL-split marker in rich text'] = [ - 'divi/text', 'content.innerContent', ['desktop' => '$variable' . chr(0) . $reference], self::TEXT, - ]; $cases['octet-split marker in a text title'] = [ 'divi/heading', 'title.innerContent', ['desktop' => '$variable%41' . $reference], self::HEADING, ]; @@ -228,7 +225,7 @@ final class AttrWriterTest extends TestCase public function testSplitMarkersAreRebuiltBySanitizing(): void { $sanitizer = ComposeFixture::sanitizer(); - $rows = ['tag-split marker in rich text', 'NUL-split marker in rich text', 'octet-split marker in a text title', + $rows = ['tag-split marker in rich text', 'octet-split marker in a text title', 'tag-split marker deep in a value']; foreach (array_intersect_key(self::malicious(), array_flip($rows)) as $name => [, , $map]) { $raw = (string) json_encode($map, JSON_UNESCAPED_SLASHES); @@ -236,7 +233,6 @@ final class AttrWriterTest extends TestCase self::assertStringNotContainsString('$variable(', $value, $name); self::assertStringContainsString('$variable(', $sanitizer->text($value) . $sanitizer->html($value), $name . ' ' . $raw); } - self::assertSame('$variable' . chr(0) . '(', substr(self::malicious()['NUL-split marker in rich text'][2]['desktop'], 0, 11)); } /** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */ @@ -385,7 +381,10 @@ final class AttrWriterTest extends TestCase self::assertSame("

    a\nb\r

    ", $writer->cleanValue('divi/text', 'content.innerContent', "

    a\x0b\nb\r\x08

    ")); self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f")); - $tail = '({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'; + // S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes + // through canonical() like any reference – validated, cleaned (S21) and re-serialized. + $tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; + $canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; $targets = [ 'text' => ['divi/heading', 'title.innerContent'], 'html' => ['divi/text', 'content.innerContent'], @@ -393,19 +392,55 @@ final class AttrWriterTest extends TestCase ]; foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) { $value = '$variable' . $control . $tail; - // The fake keeps them like sanitize_text_field does: the production code must catch it. - self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text($value)); + // The fake keeps them like sanitize_text_field does: the production code handles it. + self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x')); foreach ($targets as $kind => [$module, $path]) { - try { - $writer->cleanValue($module, $path, $value); - self::fail(sprintf('%s: control %d not caught', $kind, ord($control))); - } catch (InvalidArgumentException $e) { - self::assertStringContainsString('allein im Wert', $e->getMessage()); - } + self::assertSame($canonical, $writer->cleanValue($module, $path, $value), $kind . ' ' . ord($control)); + } + // Embedded in text it is an embedded reference: refused. + try { + $writer->cleanValue('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail); + self::fail('embedded control-split reference accepted'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('allein im Wert', $e->getMessage()); } } } + /** + * Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field; + * stripping the control character only after the sanitizer would build a real tag. Controls + * go on the RAW value, so the sanitizer sees – and removes – the tag. + */ + public function testControlCharactersGoBeforeTheSanitizer(): void + { + $writer = ComposeFixture::attrWriter(); + $img = '<' . chr(11) . 'img src=x onerror=alert(1)>'; + $script = '<' . chr(12) . 'script>alert(1)'; + // The fake reproduces the WordPress behaviour the attack relies on. + self::assertStringContainsString('<' . chr(11) . 'img', ComposeFixture::sanitizer()->text($img)); + + foreach ([$img . 'Titel', $script . 'Titel'] as $value) { + $result = $writer->apply('divi/heading', self::HEADING, ['title.innerContent' => ['desktop' => $value]], 'attrs'); + self::assertSame([], $result['errors']); + $stored = $result['attrs']['title']['innerContent']['desktop']['value']; + self::assertStringNotContainsString('<', $stored); + + $button = $writer->cleanValue('divi/button', 'button.innerContent', $value, 'text'); + self::assertStringNotContainsString('<', $button); + } + } + + /** Ruling S34: value keys are whole identifiers – "$" must not match before a trailing newline. */ + public function testValueKeysRejectATrailingNewline(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [ + 'module.decoration.spacing' => ['desktop' => ['padding' => ["top\n" => '1px']]], + ], 'attrs'); + + self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']); + } + /** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */ public function testKindOverrideMayOnlyTighten(): void { diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 54f5afb..83a8090 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -111,6 +111,9 @@ final class ComposeFixture 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''], // Alt texts carrying a reference, whole or NUL-split (ruling S33b: dropped). 44 => ['url' => 'https://kunde.test/wp-content/uploads/a.jpg', 'alt' => ComposeFixture::REFERENCE_ALT], + // "<" + VT/FF: strip_tags keeps it as text – a later strip would build the tag (S32a). + 46 => ['url' => 'https://kunde.test/wp-content/uploads/c.jpg', 'alt' => '<' . chr(11) + . 'img src=x onerror=alert(1)>Team'], 45 => ['url' => 'https://kunde.test/wp-content/uploads/b.jpg', 'alt' => '$variable' . chr(0) . substr(ComposeFixture::REFERENCE_ALT, strlen('$variable'))], default => null, diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php index eff8f84..f3f22bd 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -128,6 +128,10 @@ final class ComposerTest extends TestCase self::assertSame(['24_24'], $widths('1_7,5_7')[0]); self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]); self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]); + // Ruling S34: exact fractions – 1/11 + 11/12 is 133/132, not "about one". + self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_11,11_12')[1]); + self::assertSame([['1_5', '1_5', '1_5', '1_5', '1_5'], []], $widths('1_5,1_5,1_5,1_5,1_5')); + self::assertSame([['8_24', '8_24', '8_24'], []], $widths('1_3,1_3,1_3')); } public function testUpdateChangesShortFormPresetAndLabel(): void @@ -191,9 +195,14 @@ final class ComposerTest extends TestCase ['type' => 'section', 'label' => $reference], ['type' => 'section', 'label' => ['Hero']], ['type' => 'section', 'modules' => [['type' => 'heading', 'label' => "Titel\x01\x7f"]]], + ['type' => 'section', 'label' => '<' . chr(11) . 'img src=x onerror=alert(1)>Hero'], + ['type' => 'section', 'label' => '<' . chr(12) . 'script>alert(1)Hero'], ], null, 'nodes'); - [$clean, $split, $nul, $whole, $list, $control] = $result['blocks']; + [$clean, $split, $nul, $whole, $list, $control, $vt, $ff] = $result['blocks']; + // Ruling S32a: controls go before the sanitizer, so no tag is built after it. + self::assertSame('Hero', NodeSummary::label($vt)); + self::assertSame('Hero', NodeSummary::label($ff)); self::assertSame('Hero', NodeSummary::label($clean)); self::assertSame('Hero – Heading', NodeSummary::label($clean->children()[0]->children()[0]->children()[0])); @@ -344,6 +353,24 @@ final class ComposerTest extends TestCase self::assertSame([], $readOnly['blocks']); } + /** Ruling S34: Divi 5 rows are nestable – a row may go into a column. */ + public function testNestedRowInAColumn(): void + { + $result = ComposeFixture::composer()->compose([['type' => 'section', 'label' => 'Hero', 'rows' => [[ + 'columns' => '1_2,1_2', + 'modules' => [ + [['type' => 'row', 'columns' => '1_2,1_2', 'modules' => [[['type' => 'text', 'content' => '

    a

    ']], []]]], + [['type' => 'text', 'content' => '

    b

    ']], + ], + ]]]], null, 'nodes'); + + self::assertSame([], $result['errors']); + $nested = $result['blocks'][0]->children()[0]->children()[0]->children()[0]; + self::assertSame('divi/row', $nested->name()); + self::assertSame(['12_24', '12_24'], array_map([NodeSummary::class, 'columnWidth'], $nested->children())); + self::assertSame('divi/text', $nested->children()[0]->children()[0]->name()); + } + public function testRejectsNonObjectsAndMissingTypes(): void { $result = ComposeFixture::composer()->compose(['section', [], ['label' => 'x']], null, 'nodes'); diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 70ca3bf..f9c0e01 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -186,7 +186,7 @@ final class ShortformWriterTest extends TestCase 'color: two references' => ['color', $color . $color], // Markers the sanitizer would rebuild (S28). 'text: tag-split marker' => ['text', '$variable(' . substr($color, 10)], - 'text: NUL-split marker' => ['text', '$variable' . chr(0) . '(' . substr($color, 10)], + 'text: NUL-split marker embedded' => ['text', 'Hallo $variable' . chr(0) . '(' . substr($color, 10)], 'text: octet-split marker' => ['text', '$variable%41(' . substr($color, 10)], 'url: stripped-characters marker' => ['url', '$variable<"{}>(y)$'], 'url: NUL-split marker' => ['url', '$variable' . chr(0) . '(y)$'], @@ -220,17 +220,17 @@ final class ShortformWriterTest extends TestCase self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1])); } - // S32: like sanitize_text_field, the fake text() keeps NUL – so the marker the NUL row - // hides is rebuilt only by AttrWriter's control-character stripping (production code). - self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker'][1])); - self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker'][1])); + // S32/S32a: like sanitize_text_field, the fake text() keeps NUL – the marker the NUL row + // hides appears only through AttrWriter's control stripping on the raw value (production code). + self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker embedded'][1])); + self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker embedded'][1])); foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) { self::assertStringNotContainsString('$variable(', $rows[$row][1]); self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1])); } - self::assertStringContainsString(chr(0), $rows['text: NUL-split marker'][1]); + self::assertStringContainsString(chr(0), $rows['text: NUL-split marker embedded'][1]); } /** Ruling S33b: an attachment alt carrying a reference (whole or split) is dropped, never canonicalized. */ @@ -245,6 +245,21 @@ final class ShortformWriterTest extends TestCase } } + /** Ruling S32a: a control character after "<" never turns into a tag – in text and in media alt. */ + public function testControlSplitTagsAreRemoved(): void + { + $writer = ComposeFixture::shortform(); + foreach (['<' . chr(11) . 'img src=x onerror=alert(1)>Hallo', '<' . chr(12) . 'script>alert(1)Hallo'] as $value) { + $result = $writer->apply('heading', [], ['text' => $value], 'n'); + self::assertSame([], $result['errors']); + self::assertSame('Hallo', $result['attrs']['title']['innerContent']['desktop']['value']); + } + + $image = $writer->apply('image', [], ['image_id' => 46], 'n'); + self::assertSame([], $image['errors']); + self::assertSame('Team', $image['attrs']['image']['innerContent']['desktop']['value']['alt']); + } + /** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */ public function testReferenceSettingsInTextAreCleaned(): void { diff --git a/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php index 38f00cb..4513345 100644 --- a/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php @@ -28,6 +28,18 @@ final class StructureRulesTest extends TestCase 'anything in a specialty section' => [$section('specialty'), 'divi/row', 'Spezialabschnitte'], 'column in a row' => [Block::create('divi/row'), 'divi/column', 'Spalten entstehen mit der Zeile'], 'module in a column' => [Block::create('divi/column'), 'divi/button', null], + 'row in a column (nestable)' => [Block::create('divi/column'), 'divi/row', null], + 'row in a group' => [Block::create('divi/group'), 'divi/row', 'passt nicht'], + 'module in a specialty column' => [ + Block::create('divi/column', ['module' => ['advanced' => ['specialtyColumns' => ['desktop' => ['value' => '2']]]]]), + 'divi/text', + 'Spezial', + ], + 'module in a column holding inner rows' => [ + Block::create('divi/column', [], [Block::create('divi/row-inner')]), + 'divi/text', + 'Spezial', + ], 'group in a column' => [Block::create('divi/column'), 'divi/group', null], 'module in a group' => [Block::create('divi/group'), 'divi/text', null], 'fullwidth module in a column' => [Block::create('divi/column'), 'divi/fullwidth-header', 'passt nicht'], diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 32efd41..6659ea9 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -93,6 +93,15 @@ final class VariableRefTest extends TestCase self::content('post_title', '{"before":"' . $script . 'Hallo","after":""}'), self::content('post_title', '{"before":"Hallo","after":""}'), ], + // S32a: controls go before text(), so "<" + VT/FF is a tag text() removes. + 'VT-split tag in date format' => [ + self::content('post_date', '{"custom_date_format":"<' . self::escape('000b') . 'img src=x onerror=alert(1)>d.m"}'), + self::content('post_date', '{"custom_date_format":"d.m"}'), + ], + 'FF-split script in date format' => [ + self::content('post_date', '{"custom_date_format":"<' . self::escape('000c') . 'script>alert(1)d.m"}'), + self::content('post_date', '{"custom_date_format":"d.m"}'), + ], 'tags in date format' => [ self::content('post_date', '{"custom_date_format":"d.m"}'), self::content('post_date', '{"custom_date_format":"d.m"}'), diff --git a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php index 34f78e3..c1d9a9b 100644 --- a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php +++ b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php @@ -83,6 +83,20 @@ final class DesignSystemTest extends TestCase self::assertSame('{{color:Nachtblau}}', DesignSystem::colorPlaceholder('gcid-a1b2', 'Nachtblau')); } + /** Ruling S13a/S34: a preset name several presets of the module share fails with their ids. */ + public function testAmbiguousPresetNamesFail(): void + { + $design = DesignSystem::fromDivi([], [], ['module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-3' => ['id' => 'p-3', 'name' => 'standard', 'moduleName' => 'divi/button'], + ]]]], []); + + self::assertSame('p-3', $design->modulePresetId('divi/button', 'p-3')); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('p-1, p-3'); + $design->modulePresetId('divi/button', 'STANDARD'); + } + public function testToleratesEmptyOrOddInput(): void { $design = DesignSystem::fromDivi([], ['fonts' => 'x'], ['module' => 'y'], []); From ea1fd607b2f82ea1eab3fa1e0285f6fa7cef000e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 14:42:09 +0200 Subject: [PATCH 114/185] feat: Divi tree editor and quality warnings TreeEditor (position/insert/move/remove/duplicate on the BlockTree) and Warnings (post-write quality checks), plus S18's outline breadth limit on the 2a Outline/DiviOutlineAbility. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/BlockTree/Outline.php | 49 +++- src/Integrations/Divi/Compose/TreeEditor.php | 165 +++++++++++ src/Integrations/Divi/Compose/Warnings.php | 269 ++++++++++++++++++ .../Divi/Pages/DiviOutlineAbility.php | 30 +- .../Divi/BlockTree/OutlineTest.php | 34 ++- .../Divi/Compose/TreeEditorTest.php | 182 ++++++++++++ .../Divi/Compose/WarningsTest.php | 55 ++++ .../Divi/Pages/DiviReadAbilitiesTest.php | 14 + 8 files changed, 791 insertions(+), 7 deletions(-) create mode 100644 src/Integrations/Divi/Compose/TreeEditor.php create mode 100644 src/Integrations/Divi/Compose/Warnings.php create mode 100644 tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php create mode 100644 tests/Unit/Integrations/Divi/Compose/WarningsTest.php diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index 332b293..63ea24a 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -7,21 +7,44 @@ namespace LIT\MCP\Integrations\Divi\BlockTree; /** * The page tree as rows and as compact text (lit-divi-outline, spec §5.3/§5.7; also the * "outline" of write results). Preset IDs are shown with their names when known. + * + * Ruling S18: a page can have far more nodes at one level than are useful to show (e.g. a + * 200-item slider). rows() therefore caps the number of *direct* children shown per parent + * (breadth, not depth) at $limit; the parent whose children got cut is marked + * 'truncated' => true. wasTruncated() additionally reports whether ANY cap fired, including at + * the top level (the page root has no row of its own to mark). */ final class Outline { + /** Sane default breadth cap per parent when no explicit limit is given. */ + public const DEFAULT_LIMIT = 50; + + private bool $truncated = false; + /** @param array $presetNames Preset ID => name. */ public function __construct(private array $presetNames = []) { } + /** Whether the last rows() call cut off any children (at any level, including the top). */ + public function wasTruncated(): bool + { + return $this->truncated; + } + /** * @return list, children: int, editable: bool, depth: int}> + * presets: list, children: int, editable: bool, depth: int, truncated: bool}> */ - public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): array + public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array { + $limit = $limit ?? self::DEFAULT_LIMIT; + $this->truncated = false; + $rows = []; + $rowIndexById = []; + $counts = []; + $skipped = []; $baseDepth = null; foreach ($index->entries() as $entry) { $id = $entry['id']; @@ -29,12 +52,30 @@ final class Outline continue; } + $parentId = $index->parentId($entry['block']); + if ($parentId !== null && isset($skipped[$parentId])) { + $skipped[$id] = true; + continue; + } + $baseDepth ??= $entry['depth']; $depth = $entry['depth'] - $baseDepth; if ($maxDepth !== null && $depth > $maxDepth) { continue; } + $parentKey = $parentId ?? ''; + $counts[$parentKey] = ($counts[$parentKey] ?? 0) + 1; + if ($counts[$parentKey] > $limit) { + $this->truncated = true; + $skipped[$id] = true; + if ($parentId !== null && isset($rowIndexById[$parentId])) { + $rows[$rowIndexById[$parentId]]['truncated'] = true; + } + continue; + } + + $rowIndexById[$id] = count($rows); $rows[] = $this->row($entry['block'], $id, $depth); } @@ -52,6 +93,7 @@ final class Outline $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; $line .= $row['editable'] ? '' : ' (nur lesbar)'; + $line .= ($row['truncated'] ?? false) ? ' (weitere Kinder abgeschnitten)' : ''; $lines[] = $line; } @@ -60,7 +102,7 @@ final class Outline /** * @return array{id: string, type: string, label: string, excerpt: string, info: string, - * presets: list, children: int, editable: bool, depth: int} + * presets: list, children: int, editable: bool, depth: int, truncated: bool} */ private function row(Block $block, string $id, int $depth): array { @@ -79,6 +121,7 @@ final class Outline 'children' => $editable ? count($block->children()) : 0, 'editable' => $editable, 'depth' => $depth, + 'truncated' => false, ]; } } diff --git a/src/Integrations/Divi/Compose/TreeEditor.php b/src/Integrations/Divi/Compose/TreeEditor.php new file mode 100644 index 0000000..f859721 --- /dev/null +++ b/src/Integrations/Divi/Compose/TreeEditor.php @@ -0,0 +1,165 @@ + $input + * @return array{container: Container, parent: ?Block, index: int}|string Position or German error. + */ + public function position(NodeIndex $index, array $input): array|string + { + $given = array_filter( + [ + 'parent' => $input['parent'] ?? null, + 'before' => $input['before'] ?? null, + 'after' => $input['after'] ?? null, + ], + static fn (mixed $value): bool => is_string($value) && $value !== '' + ); + if (count($given) > 1) { + return 'nur eines von parent, before oder after angeben'; + } + + $at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX; + if ($given === []) { + return ['container' => $index->root(), 'parent' => null, 'index' => $at]; + } + + $key = (string) array_key_first($given); + $node = $index->find((string) $given[$key]); + if ($node === null) { + return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]); + } + + if ($key === 'parent') { + return ['container' => $node, 'parent' => $node, 'index' => $at]; + } + + $container = $index->parentOf($node); + if ($container === null) { + return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]); + } + + $sibling = (int) $container->indexOf($node); + + return [ + 'container' => $container, + 'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null, + 'index' => $key === 'before' ? $sibling : $sibling + 1, + ]; + } + + /** + * @param array{container: Container, parent: ?Block, index: int} $position + * @param list $blocks + */ + public function insert(array $position, array $blocks): ?string + { + foreach ($blocks as $block) { + $violation = $this->rules->violation($position['parent'], $block->name()); + if ($violation !== null) { + return $violation; + } + } + + $container = $this->targetContainer($position['container']); + $start = $position['index']; + foreach (array_values($blocks) as $offset => $block) { + $at = $start === PHP_INT_MAX ? PHP_INT_MAX : $start + $offset; + $container->insertChild($at, $block); + } + + return null; + } + + /** @param array{container: Container, parent: ?Block, index: int} $position */ + public function move(NodeIndex $index, Block $node, array $position): ?string + { + for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) { + if ($ancestor === $node) { + return 'ein Knoten kann nicht in sich selbst verschoben werden'; + } + } + + $violation = $this->rules->violation($position['parent'], $node->name()); + if ($violation !== null) { + return $violation; + } + + $from = $index->parentOf($node); + if ($from === null) { + return 'der Knoten hat keinen Elternknoten'; + } + + $old = $from->removeChild($node); + $target = $position['index']; + if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) { + --$target; + } + $position['container']->insertChild($target, $node); + + return null; + } + + public function remove(NodeIndex $index, Block $node): void + { + $index->parentOf($node)?->removeChild($node); + } + + /** Inserts a copy right after $node and returns it. Never touches $node or the copy afterwards. */ + public function duplicate(NodeIndex $index, Block $node): Block + { + $copy = $node->copy(); + $container = $index->parentOf($node); + if ($container !== null) { + $container->insertChild((int) $container->indexOf($node) + 1, $copy); + } + + return $copy; + } + + private function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } + + /** + * Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet) + * first wraps it in one, the same way DiviContentComposer (Task 9) writes new content – + * so a first-section page gets the structure the Visual Builder itself writes. + */ + private function targetContainer(Container $container): Container + { + if (! $container instanceof Document || $container->children() !== []) { + return $container; + } + + $placeholder = Block::create('divi/placeholder'); + $container->insertChild(0, $placeholder); + + return $placeholder; + } +} diff --git a/src/Integrations/Divi/Compose/Warnings.php b/src/Integrations/Divi/Compose/Warnings.php new file mode 100644 index 0000000..e182a96 --- /dev/null +++ b/src/Integrations/Divi/Compose/Warnings.php @@ -0,0 +1,269 @@ + 1, 'divi/blurb' => 4, 'divi/cta' => 2]; + + private const IMAGE_SOURCES = [ + 'divi/image' => ['image.innerContent', 'src', 'alt'], + 'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'], + 'divi/slide' => ['image.innerContent', 'src', 'alt'], + ]; + + public function __construct(private DesignSystem $design) + { + } + + /** + * @param list $touched Created or changed blocks (their descendants count too). + * @return list + */ + public function collect(NodeIndex $index, array $touched): array + { + $blocks = []; + foreach ($touched as $block) { + foreach ($this->withDescendants($block) as $node) { + $blocks[spl_object_id($node)] = $node; + } + } + + $warnings = []; + foreach ($blocks as $block) { + $id = $index->idOf($block) ?? '?'; + array_push( + $warnings, + ...$this->code($block, $id), + ...$this->image($block, $id), + ...$this->empty($block, $id), + ...$this->fixedValues($block, $id) + ); + } + + return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks)))); + } + + /** + * Opaque blocks (global modules) are never descended into – their content is edited only in + * the Divi Builder (Etappe 3), so no warning ever looks inside one. + * + * @return list + */ + private function withDescendants(Block $block): array + { + $all = [$block]; + if (! $block->isOpaque()) { + foreach ($block->children() as $child) { + array_push($all, ...$this->withDescendants($child)); + } + } + + return $all; + } + + /** @return list */ + private function code(Block $block, string $id): array + { + return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true) + ? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' + . 'natives Modul passt.', $id)] + : []; + } + + /** @return list */ + private function image(Block $block, string $id): array + { + $spec = self::IMAGE_SOURCES[$block->name()] ?? null; + if ($spec === null) { + return []; + } + + $value = AttrMap::desktop($block->attrs(), $spec[0]); + $src = is_array($value) ? ($value[$spec[1]] ?? '') : ''; + $alt = is_array($value) ? ($value[$spec[2]] ?? '') : ''; + + return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '') + ? [sprintf('%s: Bild ohne Alt-Text.', $id)] + : []; + } + + /** @return list */ + private function empty(Block $block, string $id): array + { + $name = $block->name(); + if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) { + return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : []; + } + + $type = $block->shortName(); + if (ShortformMap::childType($type) !== null && $block->children() === []) { + return [sprintf('%s: %s ohne Einträge.', $id, $type)]; + } + + $primary = ShortformMap::primary($type); + if ($primary === null || ShortformMap::childType($type) !== null) { + return []; + } + + $values = ShortformMap::values($type, $block->attrs()); + $value = $values[$primary] ?? ''; + + return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : []; + } + + /** @return list */ + private function fixedValues(Block $block, string $id): array + { + $warnings = []; + $fonts = array_filter($this->design->fonts()); + foreach ($this->leaves($block->attrs()) as [$key, $value]) { + if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) { + $match = $this->similarColor($value); + if ($match !== null) { + $warnings[] = sprintf( + '%s: feste Farbe %s – globale Farbe "%s" (%s) passt.', + $id, + $value, + $match['label'], + DesignSystem::colorPlaceholder($match['id'], $match['label']) + ); + } + } elseif ($key === 'family') { + $font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true); + if ($font !== false) { + $warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font); + } + } + } + + return $warnings; + } + + /** + * Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block. + * + * @param array $touched + * @return list + */ + private function headings(NodeIndex $index, array $touched): array + { + $warnings = []; + $previous = null; + foreach ($index->entries() as $entry) { + $block = $entry['block']; + foreach ($this->levels($block) as $level) { + if ($previous !== null && $level > $previous + 1 && isset($touched[spl_object_id($block)])) { + $warnings[] = sprintf( + '%s: Sprung in der Überschriftenhierarchie (h%d nach h%d).', + $entry['id'], + $level, + $previous + ); + } + $previous = $level; + } + } + + return $warnings; + } + + /** @return list Heading levels a block renders, in order. */ + private function levels(Block $block): array + { + $default = self::HEADING_DEFAULTS[$block->name()] ?? null; + if ($default !== null) { + $font = AttrMap::desktop($block->attrs(), 'title.decoration.font.font'); + $level = is_array($font) && is_string($font['headingLevel'] ?? null) + ? $font['headingLevel'] + : 'h' . $default; + + return [(int) substr($level, 1)]; + } + + if ($block->name() === 'divi/text') { + $html = AttrMap::desktop($block->attrs(), 'content.innerContent'); + preg_match_all('/design->colors() as $color) { + $other = self::rgb($color['value']); + if ($rgb !== null && $other !== null && self::distance($rgb, $other) <= self::SIMILAR) { + return $color; + } + } + + return null; + } + + /** + * @param array $values + * @return list [last key, value] of every string leaf (Divi + * references excluded – a fixed value that references a global one is not "fixed"). + */ + private function leaves(array $values): array + { + $leaves = []; + foreach ($values as $name => $value) { + if (is_array($value)) { + array_push($leaves, ...$this->leaves($value)); + } elseif (is_string($value) && ! VariableRef::contains($value)) { + $leaves[] = [(string) $name, $value]; + } + } + + return $leaves; + } + + /** @return array{int, int, int}|null */ + private static function rgb(string $hex): ?array + { + $hex = ltrim(trim($hex), '#'); + if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) { + return null; + } + + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))]; + } + + /** + * @param array{int, int, int} $a + * @param array{int, int, int} $b + */ + private static function distance(array $a, array $b): float + { + return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2); + } +} diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index 6407979..c1af55d 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -36,7 +36,8 @@ final class DiviOutlineAbility implements Ability { return 'Liest den Aufbau einer Divi-Seite: Knoten mit Pfad-ID (s0, s0.r1, s0.r1.c0.m2), Modultyp, ' . 'Admin-Label, Textauszug und Preset. Liefert tree_rev – Pflicht für jede Divi-Änderung. ' - . 'Optional nur ein Teilbaum (id) oder begrenzte Tiefe (depth).'; + . 'Optional nur ein Teilbaum (id), begrenzte Tiefe (depth) oder begrenzte Breite je Ebene (limit); ' + . 'abgeschnittene Kinder zeigt "truncated".'; } public function inputSchema(): array @@ -52,6 +53,13 @@ final class DiviOutlineAbility implements Ability 'maximum' => 10, 'description' => 'Maximale Tiefe unterhalb des Startknotens (0 = nur der Knoten).', ], + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 500, + 'description' => 'Höchstzahl Kind-Knoten je Elternknoten (Standard: ' + . Outline::DEFAULT_LIMIT . '). Überzählige Kinder werden abgeschnitten (truncated).', + ], ], 'required' => ['post_id'], 'additionalProperties' => false, @@ -83,16 +91,26 @@ final class DiviOutlineAbility implements Ability 'children' => ['type' => 'integer'], 'editable' => ['type' => 'boolean'], 'depth' => ['type' => 'integer'], + 'truncated' => [ + 'type' => 'boolean', + 'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.', + ], ], 'required' => [ 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', + 'truncated', ], ], ], + 'truncated' => [ + 'type' => 'boolean', + 'description' => 'Irgendwo im Baum wurden Kind-Knoten wegen "limit" abgeschnitten (auch auf ' + . 'oberster Ebene, die kein eigenes "truncated" an einem Knoten zeigen kann).', + ], 'edit_url' => ['type' => 'string'], 'hints' => ['type' => 'array', 'items' => ['type' => 'string']], ], - 'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'hints'], + 'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'truncated', 'hints'], ]; } @@ -129,7 +147,12 @@ final class DiviOutlineAbility implements Ability } $outline = new Outline($this->design->read()->presetNames()); - $rows = $outline->rows($page->index, $rootId, isset($input['depth']) ? (int) $input['depth'] : null); + $rows = $outline->rows( + $page->index, + $rootId, + isset($input['depth']) ? (int) $input['depth'] : null, + isset($input['limit']) ? (int) $input['limit'] : null + ); return [ 'post_id' => (int) $page->post->ID, @@ -139,6 +162,7 @@ final class DiviOutlineAbility implements Ability 'rev' => Rev::ofPost($page->post), 'outline' => $outline->text($rows), 'nodes' => $rows, + 'truncated' => $outline->wasTruncated(), 'edit_url' => $page->editUrl(), 'hints' => $this->hints($page, $rows), ]; diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index 15b5090..53be590 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -21,10 +21,12 @@ final class OutlineTest extends TestCase $outline = new Outline(['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' => 'Hero-Titel']); $rows = $outline->rows($index); + self::assertFalse($outline->wasTruncated()); self::assertSame( ['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne', - 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3], + 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3, + 'truncated' => false], $rows[5] ); self::assertSame('Spalten 8_24+16_24', $rows[1]['info']); @@ -50,6 +52,36 @@ final class OutlineTest extends TestCase self::assertSame(2, $rows[1]['children']); } + /** Ruling S18: outline breadth limit – caps children per parent, marks the parent truncated. */ + public function testBreadthLimitCapsChildrenPerParentAndMarksTruncation(): void + { + $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); + $outline = new Outline(); + + // s0.r0.c1 has three children (heading, text, button); cap them at two. + $rows = $outline->rows($index, 's0.r0.c1', null, 2); + + self::assertSame(['s0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1'], array_column($rows, 'id')); + self::assertTrue($rows[0]['truncated'], 'the parent whose children were cut is marked'); + self::assertFalse($rows[1]['truncated']); + self::assertTrue($outline->wasTruncated()); + + $full = (new Outline())->rows($index, 's0.r0.c1'); + self::assertSame(4, count($full), 'the default limit (50) does not cut three children'); + } + + /** The page root has no row of its own, so a top-level cap can only show up in wasTruncated(). */ + public function testBreadthLimitAtTheTopLevelStillReportsTruncation(): void + { + $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html'))); + $outline = new Outline(); + + $rows = $outline->rows($index, null, 0, 1); + + self::assertSame(['s0'], array_column($rows, 'id')); + self::assertTrue($outline->wasTruncated()); + } + public function testReadOnlyNodesAreMarked(): void { $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html'))); diff --git a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php new file mode 100644 index 0000000..d2e12d7 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php @@ -0,0 +1,182 @@ +parse((string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name)); + } + + public function testResolvesPositions(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + $end = $editor->position($index, []); + self::assertSame($index->root(), $end['container']); + self::assertNull($end['parent']); + + $inColumn = $editor->position($index, ['parent' => 's0.r0.c1', 'index' => 1]); + self::assertSame($index->find('s0.r0.c1'), $inColumn['parent']); + self::assertSame(1, $inColumn['index']); + + $after = $editor->position($index, ['after' => 's0.r0.c1.m0']); + self::assertSame($index->find('s0.r0.c1'), $after['container']); + self::assertSame(1, $after['index']); + + $beforeSection = $editor->position($index, ['before' => 's1']); + self::assertNull($beforeSection['parent']); + self::assertSame(1, $beforeSection['index']); + + self::assertSame('nur eines von parent, before oder after angeben', $editor->position($index, ['parent' => 's0', 'after' => 's1'])); + self::assertSame('Knoten "s9" gibt es nicht', $editor->position($index, ['before' => 's9'])); + } + + public function testInsertChecksTheStructureRules(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $button = Block::create('divi/button'); + + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [$button]) + ); + self::assertStringContainsString('Zeilen', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [$button])); + self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->insert($editor->position($index, ['parent' => 's0.r0']), [$button])); + self::assertStringContainsString('accordion-item', (string) $editor->insert($editor->position($index, ['parent' => 's1.r0.c1.m0']), [$button])); + + self::assertNull($editor->insert($editor->position($index, ['after' => 's0.r0.c1.m0']), [$button])); + self::assertSame('divi/button', NodeIndex::build($document)->find('s0.r0.c1.m1')?->name()); + } + + /** + * Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet) + * wraps it in one first, the way DiviContentComposer (Task 9) writes new content. + */ + public function testInsertIntoAnEmptyPageCreatesAPlaceholder(): void + { + $document = new Document(); + $index = NodeIndex::build($document); + self::assertSame($document, $index->root()); + $editor = new TreeEditor(ComposeFixture::rules()); + $section = Block::create('divi/section'); + + self::assertNull($editor->insert($editor->position($index, []), [$section])); + + self::assertSame( + '', + Serializer::document($document) + ); + $rebuilt = NodeIndex::build($document); + self::assertSame($section, $rebuilt->find('s0')); + self::assertNull($rebuilt->parentId($section), 'the placeholder is transparent, like every other top-level node'); + + // A second insert on the now non-empty page must not wrap it again. + $second = Block::create('divi/section'); + self::assertNull($editor->insert($editor->position($rebuilt, []), [$second])); + self::assertSame(1, substr_count(Serializer::document($document), '')); + } + + public function testMoveWithinAndAcrossParents(): void + { + $document = $this->document(); + $original = Serializer::document($document); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $button = $index->find('s0.r0.c1.m2'); + + self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0']))); + $index = NodeIndex::build($document); + self::assertSame($button, $index->find('s0.r0.c1.m0')); + + self::assertNull($editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c0']))); + $index = NodeIndex::build($document); + self::assertSame($button, $index->find('s1.r0.c0.m1')); + self::assertStringContainsString('Termin buchen', Serializer::document($document)); + + // Ruling S14: move it back (exact inverse of the two moves above) and assert the + // serialized page is byte-identical to the original – not merely the same length. + self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0']))); + $index = NodeIndex::build($document); + self::assertNull($editor->move($index, $button, $editor->position($index, ['after' => 's0.r0.c1.m2']))); + self::assertSame($original, Serializer::document($document), 'moving a node back restores every byte'); + + $section = $index->find('s0'); + self::assertSame( + 'ein Knoten kann nicht in sich selbst verschoben werden', + $editor->move($index, $section, $editor->position($index, ['parent' => 's0.r0.c0'])) + ); + self::assertNull($editor->move($index, $section, $editor->position($index, []))); + self::assertSame($section, NodeIndex::build($document)->find('s1')); + } + + public function testRemoveAndDuplicate(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + $copy = $editor->duplicate($index, $index->find('s1.r0.c1.m0')); + $index = NodeIndex::build($document); + self::assertSame($copy, $index->find('s1.r0.c1.m1')); + self::assertSame(Serializer::block($index->find('s1.r0.c1.m0')), Serializer::block($copy)); + + $editor->remove($index, $index->find('s0')); + self::assertSame('divi/section', NodeIndex::build($document)->find('s0')?->name()); + self::assertNull(NodeIndex::build($document)->find('s1')); + } + + /** + * Duplicate never mutates an opaque block's internals; a copy of a global module stays a + * byte-identical reference, and the original is untouched (spec §5: byte-identical roundtrip). + */ + public function testDuplicateNeverMutatesAnOpaqueGlobalModule(): void + { + $document = $this->document('synthetic-mixed.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + $globalRow = $index->find('s1.g1'); + self::assertNotNull($globalRow); + self::assertTrue($globalRow->isOpaque()); + $originalBytes = Serializer::block($globalRow); + + $copy = $editor->duplicate($index, $globalRow); + + self::assertNotSame($globalRow, $copy); + self::assertTrue($copy->isOpaque()); + self::assertSame($originalBytes, Serializer::block($copy), 'the copy is byte-identical to the original'); + self::assertSame($originalBytes, Serializer::block($globalRow), 'the original is left untouched'); + + $index = NodeIndex::build($document); + self::assertSame($globalRow, $index->find('s1.g1')); + self::assertSame($copy, $index->find('s1.g2')); + } + + public function testReadOnlyTargetsAndSpecialtySections(): void + { + $document = $this->document('synthetic-mixed.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $text = Block::create('divi/text'); + + self::assertStringContainsString('nur lesbar', (string) $editor->insert($editor->position($index, ['parent' => 's1.g1']), [$text])); + self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')])); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text])); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php new file mode 100644 index 0000000..898c05e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php @@ -0,0 +1,55 @@ +compose([ + ['type' => 'section', 'label' => 'Test', 'modules' => [ + ['type' => 'heading', 'text' => 'Titel', 'level' => 'h1'], + ['type' => 'heading', 'text' => 'Zu tief', 'level' => 'h4'], + ['type' => 'image', 'src' => 'https://kunde.test/a.jpg'], + ['type' => 'text', 'content' => ''], + ['type' => 'code', 'content' => '
    x
    '], + ['type' => 'accordion'], + ['type' => 'button', 'text' => 'Los', 'attrs' => [ + 'button.decoration.background' => ['desktop' => ['color' => '#2ea3f3']], + 'button.decoration.font.font' => ['desktop' => ['family' => 'Open Sans', 'color' => '#abcdef']], + ]], + ]], + ], null, 'nodes'); + self::assertSame([], $composed['errors']); + $document = new Document(); + foreach ($composed['blocks'] as $block) { + $document->append($block); + } + + $warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']); + + self::assertSame( + [ + 's0.r0.c0.m2: Bild ohne Alt-Text.', + 's0.r0.c0.m3: text ist leer (content fehlt).', + 's0.r0.c0.m4: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' + . 'natives Modul passt.', + 's0.r0.c0.m5: accordion ohne Einträge.', + 's0.r0.c0.m6: feste Farbe #2ea3f3 – globale Farbe "Primärfarbe" ({{color:primary}}) passt.', + 's0.r0.c0.m6: feste Schrift Open Sans – {{font:heading}} verwenden.', + 's0.r0.c0.m1: Sprung in der Überschriftenhierarchie (h4 nach h1).', + ], + $warnings + ); + + self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), [])); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 6592f16..066025a 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -37,11 +37,25 @@ final class DiviReadAbilitiesTest extends TestCase self::assertNotSame($result['tree_rev'], $result['rev']); self::assertCount(16, $result['nodes']); self::assertStringStartsWith('s0 section "Hero"', $result['outline']); + self::assertFalse($result['truncated']); self::assertSame([], $result['hints']); self::assertTrue($ability->isDirect()); self::assertTrue($ability->annotations()['readonly']); } + /** Ruling S18: the "limit" input caps children per parent; "truncated" reports the cut. */ + public function testOutlineBreadthLimitTruncatesAndReportsIt(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + $result = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1', 'limit' => 2]); + + self::assertSame(['s0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1'], array_column($result['nodes'], 'id')); + self::assertTrue($result['nodes'][0]['truncated']); + self::assertTrue($result['truncated']); + } + public function testOutlineOfASubtreeAndUnknownNode(): void { DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); From 7762b7630567b1d51d58f43a592efed671debe93 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 14:56:47 +0200 Subject: [PATCH 115/185] =?UTF-8?q?feat:=20Divi=20save=20pipeline=20?= =?UTF-8?q?=E2=80=93=20tree=5Frev=20lock,=20kses-safe=20save,=20revision,?= =?UTF-8?q?=20flags,=20cache,=20ToolResult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4), WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live registry (S22), S31 wiring, S35 control strip at apply() entry. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Compose/AttrWriter.php | 2 + .../Divi/Compose/ShortformWriter.php | 2 + src/Integrations/Divi/Compose/VariableRef.php | 18 +- src/Integrations/Divi/Pages/DiviChange.php | 88 +++++++ .../Divi/Pages/DiviComposition.php | 97 ++++++++ src/Integrations/Divi/Pages/DiviFlags.php | 33 +++ src/Integrations/Divi/Pages/DiviSaver.php | 157 ++++++++++++ src/Integrations/Divi/Pages/DiviWriter.php | 224 ++++++++++++++++++ .../Divi/Pages/DynamicContentMap.php | 105 ++++++++ .../Divi/Pages/WpMediaResolver.php | 28 +++ .../Divi/Pages/WpValueSanitizer.php | 44 ++++ .../Divi/Compose/AttrWriterTest.php | 42 ++++ .../Divi/Compose/ShortformWriterTest.php | 35 ++- .../Divi/Pages/DiviCompositionTest.php | 113 +++++++++ .../Integrations/Divi/Pages/DiviFlagsTest.php | 41 ++++ .../Integrations/Divi/Pages/DiviSaverTest.php | 202 ++++++++++++++++ .../Divi/Pages/DiviWriterTest.php | 201 ++++++++++++++++ .../Divi/Pages/DynamicContentMapTest.php | 205 ++++++++++++++++ .../Divi/Pages/WpMediaResolverTest.php | 32 +++ .../Divi/Pages/WpValueSanitizerTest.php | 49 ++++ 20 files changed, 1716 insertions(+), 2 deletions(-) create mode 100644 src/Integrations/Divi/Pages/DiviChange.php create mode 100644 src/Integrations/Divi/Pages/DiviComposition.php create mode 100644 src/Integrations/Divi/Pages/DiviFlags.php create mode 100644 src/Integrations/Divi/Pages/DiviSaver.php create mode 100644 src/Integrations/Divi/Pages/DiviWriter.php create mode 100644 src/Integrations/Divi/Pages/DynamicContentMap.php create mode 100644 src/Integrations/Divi/Pages/WpMediaResolver.php create mode 100644 src/Integrations/Divi/Pages/WpValueSanitizer.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index bdd59c5..61e83ff 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -58,6 +58,8 @@ final class AttrWriter */ public function apply(string $module, array $attrs, array $changes, string $field): array { + // Ruling S35: controls go before placeholder resolution ("{\0{post:title}}" is a placeholder). + $changes = VariableRef::withoutControlsDeep($changes); $errors = []; $invalid = []; foreach ($changes as $path => $map) { diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index a709625..d9134ef 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -48,6 +48,8 @@ final class ShortformWriter */ public function apply(string $type, array $attrs, array $values, string $field): array { + // Ruling S35: controls go before placeholder resolution and trim ("{\0{post:title}}"). + $values = VariableRef::withoutControlsDeep($values); $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); $errors = []; foreach ($values as $name => $value) { diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 7367d58..60cac3d 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -146,6 +146,21 @@ final class VariableRef return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); } + /** + * withoutControls() on every string inside $value (keys stay: odd keys are rejected). Ruling + * S35: AttrWriter::apply() and ShortformWriter::apply() run it once on their raw input, before + * placeholder resolution and trim – so "{\0{post:title}}" is the placeholder, checked as one, + * and never literal text that slipped past the placeholder check. + */ + public static function withoutControlsDeep(mixed $value): mixed + { + if (is_string($value)) { + return self::withoutControls($value); + } + + return is_array($value) ? array_map([self::class, 'withoutControlsDeep'], $value) : $value; + } + /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { @@ -383,7 +398,8 @@ final class VariableRef } // Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …) - // name a meta key – never a protected one. + // name a meta key – never a protected one. Matched by the key SUFFIX "meta_key" (S29/S30): + // re-check Divi's dynamic content options for new meta key settings on every Divi version bump. if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) { throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", ' diff --git a/src/Integrations/Divi/Pages/DiviChange.php b/src/Integrations/Divi/Pages/DiviChange.php new file mode 100644 index 0000000..82f64c8 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviChange.php @@ -0,0 +1,88 @@ + */ + private array $changed = []; + + /** @var list */ + private array $touched = []; + + /** @var list */ + private array $warnings = []; + + public function __construct(public readonly string $summary) + { + } + + /** + * A node that still exists after the change: "inserted", "updated", "moved" or "duplicated". + * Every action but "moved" counts for the quality warnings. + */ + public function changed(Block $block, string $action): self + { + $this->changed[] = [ + 'block' => $block, + 'id' => null, + 'type' => $block->name(), + 'action' => $action, + 'label' => NodeSummary::label($block), + ]; + if ($action !== 'moved') { + $this->touched[] = $block; + } + + return $this; + } + + /** A node that is gone; $idBefore is its ID before the change. */ + public function removed(string $idBefore, Block $block): self + { + $this->changed[] = [ + 'block' => null, + 'id' => $idBefore, + 'type' => $block->name(), + 'action' => 'deleted', + 'label' => NodeSummary::label($block), + ]; + + return $this; + } + + public function warning(string $warning): self + { + $this->warnings[] = $warning; + + return $this; + } + + /** @return list */ + public function entries(): array + { + return $this->changed; + } + + /** @return list */ + public function touched(): array + { + return $this->touched; + } + + /** @return list */ + public function warnings(): array + { + return $this->warnings; + } +} diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php new file mode 100644 index 0000000..46cfdd6 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -0,0 +1,97 @@ +postId = $postId; + $bound->placeholders = null; + $bound->attrWriter = null; + $bound->composer = null; + + return $bound; + } + + /** The one Placeholders instance of this composition (ruling S31). */ + public function placeholders(): Placeholders + { + return $this->placeholders ??= new Placeholders( + $this->design->read(), + $this->postId === null ? null : $this->dynamicContent->forPost($this->postId) + ); + } + + public function attrWriter(): AttrWriter + { + return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->sanitizer); + } + + public function composer(): Composer + { + return $this->composer ??= new Composer( + $this->modules->catalog(), + $this->rules(), + new ShortformWriter($this->placeholders(), $this->media, $this->attrWriter()), + $this->attrWriter(), + $this->design->read(), + $this->modules->builderVersion() + ); + } + + public function rules(): StructureRules + { + return new StructureRules($this->modules->catalog()); + } + + public function editor(): TreeEditor + { + return new TreeEditor($this->rules()); + } + + public function warnings(): Warnings + { + return new Warnings($this->design->read()); + } +} diff --git a/src/Integrations/Divi/Pages/DiviFlags.php b/src/Integrations/Divi/Pages/DiviFlags.php new file mode 100644 index 0000000..d0a7463 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviFlags.php @@ -0,0 +1,33 @@ + 'on', '_et_pb_use_builder' => 'on']; + + /** @throws RuntimeException When a flag does not read back as set. */ + public static function set(int $postId): void + { + foreach (self::FLAGS as $key => $value) { + update_post_meta($postId, $key, $value); + } + + foreach (self::FLAGS as $key => $value) { + if (get_post_meta($postId, $key, true) !== $value) { + throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value)); + } + } + } +} diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php new file mode 100644 index 0000000..01c4db0 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -0,0 +1,157 @@ +} + * @throws WriteFailed + */ + public function save(WP_Post $post, string $content): array + { + $postId = (int) $post->ID; + $revisionId = RevisionPoint::capture($postId); + + try { + $result = $this->update($postId, $content); + } catch (Throwable $e) { + $this->restore($postId, (string) $post->post_content, $revisionId, $e); + } + + if ($result instanceof WP_Error || $result === 0) { + $message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0'; + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + null, + sprintf('wp_update_post(%d) refused: %s', $postId, $message), + rolledBackState: self::keeping($revisionId) + ); + } + + $warnings = []; + FollowUp::run( + static fn () => DiviFlags::set($postId), + 'divi builder flags', + 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' + . 'einmal im Divi-Builder öffnen und speichern.', + $warnings + ); + FollowUp::run( + static function () use ($postId): void { + if (class_exists('ET_Core_PageResource')) { + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + } + }, + 'divi static css', + 'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. ' + . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', + $warnings + ); + + $stored = get_post($postId); + if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) { + $warnings[] = self::CHANGED_ON_SAVE; + } + + return ['revision_id' => $revisionId, 'warnings' => $warnings]; + } + + /** wp_update_post() with kses' filters off for users without unfiltered_html (D7). */ + private function update(int $postId, string $content): int|WP_Error + { + $kses = ! current_user_can('unfiltered_html') + && has_filter('content_save_pre', 'wp_filter_post_kses') !== false; + if ($kses) { + kses_remove_filters(); + } + + try { + return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true); + } finally { + if ($kses) { + kses_init_filters(); + } + } + } + + /** @throws WriteFailed Always. */ + private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never + { + $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); + try { + $current = get_post($postId); + if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { + throw new RuntimeException('restoring would publish a missed schedule'); + } + + $restored = $this->update($postId, $before); + if ($restored instanceof WP_Error || $restored === 0) { + throw new RuntimeException('previous content could not be written back'); + } + } catch (Throwable $e) { + $detail .= '; rollback: ' . $e->getMessage(); + throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail); + } + + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + $cause, + $detail, + rolledBackState: self::keeping($revisionId) + ); + } + + /** write_failed must not claim "nothing was saved" while the restore-point revision stays. */ + private static function keeping(?int $revisionId): ?string + { + return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId)); + } +} diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php new file mode 100644 index 0000000..8e7d2a4 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -0,0 +1,224 @@ +> post_id and tree_rev of every Divi write. */ + public static function inputProperties(): array + { + return [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'tree_rev' => [ + 'type' => 'string', + 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', + ], + ]; + } + + /** @return array> Target position of insert and move. */ + public static function positionProperties(): array + { + return [ + 'parent' => [ + 'type' => 'string', + 'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: ' + . 'Ende der Seite.', + ], + 'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'], + 'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'], + 'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'], + ]; + } + + /** + * The page to change, after rights, type, layout and the tree_rev lock were checked. + * + * @param array $input + */ + public function page(array $input): DiviPage|WP_Error + { + $page = $this->loader->load($input['post_id'] ?? null, true); + if ($page instanceof WP_Error) { + return $page; + } + + $problem = $this->loader->writable($page); + if ($problem !== null) { + return $problem; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde ' + . 'geschrieben.', + DiviPageLoader::OUTLINE_HINT + ); + } + + return $page; + } + + /** + * The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$ + * references are checked against Divi's dynamic content options for it (ruling S22). + */ + public function composition(DiviPage $page): DiviComposition + { + return $this->composition->forPost((int) $page->post->ID); + } + + /** Section label around $block, for automatic admin labels ("" when none). */ + public static function sectionLabel(NodeIndex $index, ?Block $block): string + { + for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) { + if ($node->name() === 'divi/section') { + return NodeSummary::label($node); + } + } + + return ''; + } + + /** + * Saves the changed tree of $page and returns the ToolResult array. Call it only when the + * whole input validated – every error before means nothing is written. + * + * @param array $input + * @return array + * @throws WriteFailed + */ + public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array + { + $post = $page->post; + $content = Serializer::document($page->document); + if ($content === (string) $post->post_content) { + return ToolResult::ok('Keine Änderung – die Seite ist bereits so.') + ->rev(Rev::ofPost($post)) + ->with('tree_rev', $page->treeRev()) + ->warnings($change->warnings()) + ->toArray(); + } + + $index = NodeIndex::build($page->document); + $quality = $this->composition->warnings()->collect($index, $change->touched()); + $warnings = array_merge($change->warnings(), $quality); + + try { + $saved = $this->saver->save($post, $content); + } catch (WriteFailed $e) { + if (! $e->rolledBack) { + $this->log->recordPartial( + $ability, + (string) $post->post_type, + (string) $post->ID, + sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $e->revisionId + ); + } + throw $e; + } + + $outline = $this->outline($index, $change); + if ($outline['truncated']) { + $warnings[] = self::OUTLINE_TRUNCATED; + } + + $result = (new PostChangeResult( + $ability, + $this->log, + (int) $post->ID, + 'die Änderung', + array_merge($warnings, $saved['warnings']) + ))->afterSave($input); + $updated = $result->reread($post) ?? $post; + $summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary); + $result->log((string) $post->post_type, $summary, $saved['revision_id']); + + $status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status); + $tool = $result->result($status, $updated) + ->revisionId($saved['revision_id']) + ->outline($outline['text']) + ->with('tree_rev', Rev::of((string) $updated->post_content)) + ->urls((string) get_preview_post_link($updated), $page->editUrl()); + foreach ($change->entries() as $entry) { + $id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null); + $tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']); + } + + return $tool->toArray(); + } + + /** + * Outline of the sections that contain changed nodes (breadth limit of ruling S18). + * + * @return array{text: ?string, truncated: bool} + */ + private function outline(NodeIndex $index, DiviChange $change): array + { + $sections = []; + foreach ($change->entries() as $entry) { + $id = $entry['block'] !== null ? $index->idOf($entry['block']) : null; + if ($id !== null) { + $sections[explode('.', $id)[0]] = true; + } + } + + if ($sections === []) { + return ['text' => null, 'truncated' => false]; + } + + $outline = new Outline($this->design->read()->presetNames()); + $texts = []; + $truncated = false; + foreach (array_keys($sections) as $sectionId) { + $texts[] = $outline->text($outline->rows($index, (string) $sectionId)); + $truncated = $truncated || $outline->wasTruncated(); + } + + return ['text' => implode("\n", $texts), 'truncated' => $truncated]; + } + + private static function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } +} diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php new file mode 100644 index 0000000..d050cec --- /dev/null +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -0,0 +1,105 @@ + [setting key => field type], e.g. post_date => [before => richtext, …, + * date_format => select]. VariableRef routes each setting by that type and keeps its fixed + * rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28). + * + * - loop_* options never enter the map: Divi offers them only inside loop-enabled modules, + * which the tools do not track – VariableRef refuses them ("nur innerhalb von Divi-Schleifen"). + * - The map REPLACES VariableRef's default, so every Placeholders::POST option is added when + * the registry lacks it, with the settings the placeholders write (VariableRef's default). + * - Registry unavailable (no Divi, empty, error): null – VariableRef's default map applies. + * + * Not final so abilities can be tested with a fake registry. + */ +class DynamicContentMap +{ + private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; + + /** + * @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's. + */ + public function __construct(private ?Closure $registry = null) + { + } + + /** @return array>|null Null = use VariableRef's default map. */ + public function forPost(int $postId): ?array + { + try { + $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); + } catch (Throwable $e) { + $message = sprintf('[wp-lit-mcp] divi dynamic content options of post %d failed: %s', $postId, $e); + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + + return null; + } + + return is_array($options) && $options !== [] ? self::fromOptions($options) : null; + } + + /** + * @param array $options get_options() result: name => [fields => [key => [type => …]]]. + * @return array> + */ + public static function fromOptions(array $options): array + { + $map = []; + foreach ($options as $name => $option) { + if (! is_string($name) || $name === '' || str_starts_with($name, 'loop_') || ! is_array($option)) { + continue; + } + + $fields = []; + foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) { + if (is_string($key) && $key !== '') { + $fields[$key] = self::type($key, $field); + } + } + $map[$name] = $fields; + } + + foreach (Placeholders::POST as $option) { + $map[$option] = ($map[$option] ?? []) + (VariableRef::DEFAULT_DYNAMIC_CONTENT[$option] ?? []); + } + + return $map; + } + + /** + * Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi + * outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's + * unfiltered_html), and sanitize_text_field() would trim their meaningful spaces ("Von "). + * html() is kses for editors, the S28 marker checks hold either way. No readable type: '' – + * VariableRef's strictest branch (a scalar without markup). + */ + private static function type(string $key, mixed $field): string + { + $type = is_array($field) && is_string($field['type'] ?? null) ? $field['type'] : ''; + + return ($key === 'before' || $key === 'after') && $type === 'text' ? 'richtext' : $type; + } + + private static function divi(int $postId): mixed + { + if (! class_exists(self::REGISTRY)) { + return null; + } + + return call_user_func([self::REGISTRY, 'get_options'], $postId, 'edit'); + } +} diff --git a/src/Integrations/Divi/Pages/WpMediaResolver.php b/src/Integrations/Divi/Pages/WpMediaResolver.php new file mode 100644 index 0000000..a628693 --- /dev/null +++ b/src/Integrations/Divi/Pages/WpMediaResolver.php @@ -0,0 +1,28 @@ + $url, 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true)]; + } +} diff --git a/src/Integrations/Divi/Pages/WpValueSanitizer.php b/src/Integrations/Divi/Pages/WpValueSanitizer.php new file mode 100644 index 0000000..e953198 --- /dev/null +++ b/src/Integrations/Divi/Pages/WpValueSanitizer.php @@ -0,0 +1,44 @@ +allowsUnfilteredHtml() ? $value : wp_kses_post($value); + } + + public function code(string $value): string + { + return $this->allowsUnfilteredHtml() ? $value : wp_kses_post($value); + } + + public function url(string $value): string + { + return esc_url_raw(trim($value)); + } + + public function allowsUnfilteredHtml(): bool + { + return current_user_can('unfiltered_html'); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 78afb71..f996316 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -469,4 +469,46 @@ final class AttrWriterTest extends TestCase } } } + + /** + * Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a + * control-split placeholder is the placeholder (resolved and checked), never literal text + * that slipped past the placeholder check. Also deep inside values. + */ + public function testControlCharactersGoBeforePlaceholderResolution(): void + { + $writer = ComposeFixture::attrWriter(); + $reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; + + foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) { + $result = $writer->apply( + 'divi/heading', + [], + ['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']], + 'attrs' + ); + self::assertSame([], $result['errors'], (string) ord($control)); + self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']); + + // Embedded: refused like "{{post:title}}" inside text. + $embedded = $writer->apply( + 'divi/heading', + [], + ['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']], + 'attrs' + ); + self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']); + self::assertSame([], $embedded['attrs']); + + $deep = $writer->apply( + 'divi/button', + [], + ['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]], + 'attrs' + ); + self::assertSame([], $deep['errors']); + self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']); + } + } } + diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index f9c0e01..5849acc 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -193,7 +193,6 @@ final class ShortformWriterTest extends TestCase 'url: line-break octet marker' => ['url', '$variable%0a(y)$'], 'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'], 'color: tag-split marker' => ['color', '$variable(' . substr($color, 10)], - 'color: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . substr($color, 10)], 'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)], ]; } @@ -318,4 +317,38 @@ final class ShortformWriterTest extends TestCase self::assertSame([], $admin['errors']); self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']); } + + /** + * Ruling S35: controls go once at the entry of apply(), before placeholder resolution and + * trim – "{\0{post:title}}" is the placeholder, checked like any other. + */ + public function testControlCharactersGoBeforePlaceholderResolution(): void + { + $writer = ComposeFixture::shortform(); + + foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) { + $result = $writer->apply('heading', [], ['text' => '{' . $control . '{post:title}}'], 'n'); + self::assertSame([], $result['errors'], (string) ord($control)); + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + $result['attrs']['title']['innerContent']['desktop']['value'] + ); + + $embedded = $writer->apply('heading', [], ['text' => 'Hallo {' . $control . '{post:title}}'], 'n'); + self::assertStringContainsString('allein im Wert', $embedded['errors']['n.text']); + self::assertSame([], $embedded['attrs']); + + // A control-split whole reference IS the reference (S32a/S35): validated and re-serialized. + $reference = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'; + $split = $writer->apply('divider', [], ['color' => '$variable' . $control . substr($reference, 9)], 'n'); + self::assertSame([], $split['errors']); + self::assertSame($reference, $split['attrs']['divider']['advanced']['line']['desktop']['value']['color']); + + // Colors: the placeholder resolves after the strip (and trim), then the color check. + $color = $writer->apply('divider', [], ['color' => ' {{color:Nacht' . $control . 'blau}} '], 'n'); + self::assertSame([], $color['errors']); + self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']); + } + } } + diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php new file mode 100644 index 0000000..4e6c7c9 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -0,0 +1,113 @@ + */ + private array $registryCalls = []; + + private function composition(): DiviComposition + { + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $registry = new DynamicContentMap(function (int $postId): array { + $this->registryCalls[] = $postId; + + return DynamicContentMapTest::registry(); + }); + + return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry); + } + + private static function siteTitle(): string + { + return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']); + } + + public function testOneLiveMapIsSharedByTheFullAndTheShortForm(): void + { + $composition = $this->composition()->forPost(7); + + // Full form (AttrWriter). + $full = $composition->attrWriter()->apply( + 'divi/text', + [], + ['content.innerContent' => ['desktop' => self::siteTitle()]], + 'attrs' + ); + self::assertSame([], $full['errors']); + self::assertSame(self::siteTitle(), AttrMap::desktop($full['attrs'], 'content.innerContent')); + + // Short form through the Composer (ShortformWriter) – same map, same AttrWriter. + $short = $composition->composer()->compose( + [['type' => 'button', 'text' => self::siteTitle()]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $short['errors']); + self::assertStringContainsString('site_title', json_encode($short['blocks'][0]->attrs(), JSON_THROW_ON_ERROR)); + + self::assertSame([7], $this->registryCalls, 'the registry is read once per post'); + self::assertSame($composition->attrWriter(), $composition->attrWriter()); + self::assertSame($composition->placeholders(), $composition->placeholders()); + } + + public function testWithoutAPostTheDefaultMapApplies(): void + { + $composition = $this->composition(); + + $result = $composition->attrWriter()->apply( + 'divi/text', + [], + ['content.innerContent' => ['desktop' => self::siteTitle()]], + 'attrs' + ); + + self::assertStringContainsString('Unbekannter Name "site_title"', $result['errors']['attrs.content.innerContent']); + self::assertSame([], $this->registryCalls); + } + + public function testForPostIsAFreshCompositionPerPost(): void + { + $base = $this->composition(); + $seven = $base->forPost(7); + $eight = $base->forPost(8); + + self::assertNotSame($base, $seven); + self::assertNotSame($seven->placeholders(), $eight->placeholders()); + self::assertNotSame($base->placeholders(), $seven->placeholders()); + self::assertSame([7, 8], $this->registryCalls); + } + + public function testRulesEditorAndWarnings(): void + { + $composition = $this->composition(); + + self::assertNull($composition->rules()->violation(Block::create('divi/column'), 'divi/button')); + self::assertNotNull($composition->rules()->violation(null, 'divi/button')); + self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), [])); + self::assertInstanceOf(TreeEditor::class, $composition->editor()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php b/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php new file mode 100644 index 0000000..751d42a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php @@ -0,0 +1,41 @@ +alias(static function (int $id, string $key, string $value) use (&$written): bool { + $written[] = "$id:$key=$value"; + + return false; + }); + Functions\when('get_post_meta')->justReturn('on'); + + DiviFlags::set(7); + + self::assertSame(['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on'], $written); + } + + public function testThrowsWhenAFlagDoesNotReadBack(): void + { + Functions\when('update_post_meta')->justReturn(true); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on' + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('_et_pb_use_builder'); + DiviFlags::set(7); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php new file mode 100644 index 0000000..cf8981e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php @@ -0,0 +1,202 @@ + */ + private array $calls = []; + + /** @var array */ + private array $meta = []; + + private string $log = ''; + + private string|false $previousLog = false; + + protected function setUp(): void + { + parent::setUp(); + $this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']); + Functions\when('wp_save_post_revision')->justReturn(811); + Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); + Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool { + $this->calls[] = "meta:$key=$value"; + $this->meta[$key] = $value; + + return true; + }); + Functions\when('get_post_meta')->alias(fn (int $id, string $key): string => $this->meta[$key] ?? ''); + Functions\when('kses_remove_filters')->alias(function (): void { + $this->calls[] = 'kses off'; + }); + Functions\when('kses_init_filters')->alias(function (): void { + $this->calls[] = 'kses on'; + }); + // FollowUp logs failures with error_log() (a PHP internal): keep the test output clean. + $this->log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $this->previousLog = ini_set('error_log', $this->log); + } + + protected function tearDown(): void + { + ini_set('error_log', (string) $this->previousLog); + @unlink($this->log); + parent::tearDown(); + } + + public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\expect('wp_update_post')->once()->with(['ID' => 7, 'post_content' => 'NEU'], true) + ->andReturnUsing(function (): int { + $this->calls[] = 'update'; + + return 7; + }); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertSame(['revision_id' => 811, 'warnings' => []], $saved); + self::assertSame( + ['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'], + $this->calls + ); + } + + public function testKeepsKsesUntouchedForUnfilteredUsersAndWarnsWhenAFilterChangedTheContent(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU, gefiltert'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertNotContains('kses off', $this->calls); + self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']); + } + + public function testKsesStaysUntouchedWhenItIsNotActive(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(false); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + + (new DiviSaver())->save($this->post, 'NEU'); + + self::assertNotContains('kses off', $this->calls); + self::assertNotContains('kses on', $this->calls); + } + + public function testRefusedUpdateIsACleanFailure(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(new WP_Error('db_update_error', 'DB kaputt')); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(811, $e->revisionId); + self::assertStringContainsString('Revision 811 bleibt erhalten', $e->toError()->get_error_message()); + } + } + + public function testAThrowingHookRestoresThePreviousContentAndKsesComesBack(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + $written = []; + Functions\when('wp_update_post')->alias(function (array $data) use (&$written): int { + $written[] = $data['post_content']; + $this->calls[] = 'update'; + if ($data['post_content'] === 'NEU') { + throw new RuntimeException('save_post hook failed'); + } + + return 7; + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(['NEU', 'ALT'], $written); + // The kses bypass is restored in finally – after the failed save and after the restore. + self::assertSame(['kses off', 'update', 'kses on', 'kses off', 'update', 'kses on'], $this->calls); + } + } + + public function testFailedRestoreIsAPartialWrite(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + Functions\when('wp_update_post')->alias(static function (): int { + throw new RuntimeException('database gone'); + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } + + public function testRestoreNeverPublishesAMissedSchedule(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 7, + 'post_status' => 'future', + 'post_date_gmt' => '2020-01-01 00:00:00', + ])); + $written = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$written): int { + $written[] = $data['post_content']; + throw new RuntimeException('save_post hook failed'); + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(['NEU'], $written); + } + } + + public function testFlagFailureIsOnlyAWarning(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + Functions\when('get_post_meta')->justReturn(''); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertSame(811, $saved['revision_id']); + self::assertCount(1, $saved['warnings']); + self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]); + self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log)); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php new file mode 100644 index 0000000..4bcc6a9 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -0,0 +1,201 @@ + */ + private array $registryCalls = []; + + private function writer(DiviSaver $saver, ChangeLog $log): DiviWriter + { + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $registry = new DynamicContentMap(function (int $postId): array { + $this->registryCalls[] = $postId; + + return DynamicContentMapTest::registry(); + }); + + return new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry) + ); + } + + public function testPageChecksTheTreeRevLock(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class)); + + self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])); + self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code()); + $stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviPageLoaderTest::givenPost($content))]); + self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev'); + self::assertStringContainsString('nichts wurde', $stale->get_error_message()); + } + + public function testPageRefusesWhatIsNotWritable(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema(false)), + Mockery::mock(DiviSaver::class), + Mockery::mock(ChangeLog::class), + Mockery::mock(DesignSystemReader::class), + Mockery::mock(DiviComposition::class) + ); + + self::assertSame('lit_unsupported', $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])->get_error_code()); + } + + public function testSectionLabelOfANode(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $page = (new DiviPageLoader(DiviPageLoaderTest::schema()))->load(7, true); + + self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1'))); + self::assertSame('', DiviWriter::sectionLabel($page->index, null)); + } + + public function testCompositionIsBoundToThePage(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class)); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + + $writer->composition($page)->placeholders(); + + self::assertSame([7], $this->registryCalls, 'the live dynamic content map of post 7 (ruling S22)'); + } + + public function testUnchangedTreeIsNotSaved(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->expects('save')->never(); + $writer = $this->writer($saver, Mockery::mock(ChangeLog::class)); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + + $result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []); + + self::assertSame(Rev::of($content), $result['tree_rev']); + self::assertStringStartsWith('Keine Änderung', $result['summary']); + } + + public function testSavesAndReportsChangedNodesOutlineAndTheNewTreeRev(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $stored = null; + $saver = Mockery::mock(DiviSaver::class); + $saver->expects('save')->andReturnUsing(static function (WP_Post $post, string $new) use (&$stored): array { + $stored = $new; + DiviPageLoaderTest::givenPost($new); + + return ['revision_id' => 811, 'warnings' => ['vom Speichern']]; + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/divi-update', 'page', '7', 'Divi "Start": Button geändert', 811) + ->andReturn(55); + $input = ['post_id' => 7, 'tree_rev' => Rev::of($content)]; + Functions\expect('do_action')->once()->with('lit_mcp_after_save', 7, 'lit/divi-update', $input); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + $writer = $this->writer($saver, $log); + $page = $writer->page($input); + $button = $page->index->find('s0.r0.c1.m2'); + $removed = $page->index->find('s1'); + $button->setAttrs(AttrMap::set($button->attrs(), 'button.innerContent.desktop.value.text', 'Neu')); + $page->document->children()[0]->removeChild($removed); + $change = (new DiviChange('Button geändert')) + ->changed($button, 'updated') + ->removed('s1', $removed) + ->warning('eigene Warnung'); + + $result = $writer->save('lit/divi-update', $page, $change, $input); + + self::assertIsString($stored); + self::assertStringNotContainsString('wp:divi/blurb', $stored); + self::assertSame(Rev::of($stored), $result['tree_rev']); + self::assertSame(811, $result['revision_id']); + self::assertSame(55, $result['log_id']); + 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']); + self::assertSame('Button geändert (Status unverändert: draft).', $result['summary']); + self::assertSame( + [['s0.r0.c1.m2', 'divi/button', 'updated'], ['s1', 'divi/section', 'deleted']], + array_map(static fn (array $c): array => [$c['id'], $c['type'], $c['action']], $result['changed']) + ); + self::assertStringContainsString('Hero', (string) $result['outline']); + self::assertStringNotContainsString('Leistungen', (string) $result['outline'], 'only affected sections'); + self::assertContains('eigene Warnung', $result['warnings']); + self::assertContains('vom Speichern', $result['warnings']); + self::assertSame(Rev::ofPost(DiviPageLoaderTest::givenPost($stored)), $result['rev']); + } + + public function testFailedRollbackIsLoggedAsPartial(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811)); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811); + $writer = $this->writer($saver, $log); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + $page->document->children()[0]->removeChild($page->index->find('s1')); + + $this->expectException(WriteFailed::class); + $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); + } + + public function testCleanFailureIsNotLogged(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, true, 7, 811)); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->never(); + $log->expects('record')->never(); + $writer = $this->writer($saver, $log); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + $page->document->children()[0]->removeChild($page->index->find('s1')); + + $this->expectException(WriteFailed::class); + $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php new file mode 100644 index 0000000..69b3a4f --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -0,0 +1,205 @@ +> + */ + public static function registry(): array + { + $text = static fn (): array => ['label' => 'x', 'type' => 'text', 'default' => '']; + $option = static fn (string $id, string $group, array $fields): array => [ + 'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields, + ]; + + return [ + 'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]), + 'post_excerpt' => $option('post_excerpt', 'Default', [ + 'before' => $text(), 'after' => $text(), 'words' => $text(), 'read_more_label' => $text(), + ]), + 'post_date' => $option('post_date', 'Default', [ + 'before' => $text(), + 'after' => $text(), + 'date_format' => ['label' => 'x', 'type' => 'select', 'default' => 'default'], + 'custom_date_format' => $text(), + ]), + 'post_author' => $option('post_author', 'Default', [ + 'before' => $text(), + 'after' => $text(), + 'name_format' => ['type' => 'select'], + 'link' => ['type' => 'yes_no_button'], + ]), + 'site_title' => $option('site_title', 'Default', ['before' => $text(), 'after' => $text()]), + 'post_link_url' => $option('post_link_url', 'Default', []), + 'post_link_url_page' => $option('post_link_url_page', 'Default', ['post_id' => ['type' => 'select_post']]), + 'post_featured_image' => $option('post_featured_image', 'Default', []), + 'post_meta_key' => $option('post_meta_key', 'Standard', [ + 'before' => $text(), + 'after' => $text(), + 'select_meta_key' => ['type' => 'select'], + 'meta_key' => $text(), + 'enable_html' => ['type' => 'yes_no_button'], + ]), + 'loop_post_title' => $option('loop_post_title', 'Loop', [ + 'before' => $text(), 'after' => $text(), 'loop_position' => $text(), + ]), + 'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [ + 'loop_meta_key' => $text(), + ]), + // Odd entries a third-party filter could add. + 'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]), + 'no_fields' => ['id' => 'no_fields', 'type' => 'text'], + 7 => $option('7', 'Default', []), + 'not_an_array' => 'x', + ]; + } + + public function testFlattensTheRegistryToOptionNameAndFieldTypes(): void + { + $map = DynamicContentMap::fromOptions(self::registry()); + + // before/after: rich text (Divi outputs them as HTML; text() would trim "Von "). + self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_title']); + self::assertSame( + ['before' => 'richtext', 'after' => 'richtext', 'date_format' => 'select', 'custom_date_format' => 'text'], + $map['post_date'] + ); + self::assertSame(['post_id' => 'select_post'], $map['post_link_url_page']); + self::assertSame([], $map['post_link_url']); + self::assertSame('select', $map['post_meta_key']['select_meta_key']); + // Fields without a readable type become '' – VariableRef's strictest branch (scalar, no markup). + self::assertSame(['a' => '', 'b' => ''], $map['broken_fields']); + self::assertSame([], $map['no_fields']); + self::assertArrayNotHasKey('not_an_array', $map); + self::assertArrayNotHasKey(7, $map); + } + + public function testLoopOptionsNeverEnterTheMap(): void + { + $map = DynamicContentMap::fromOptions(self::registry()); + + self::assertSame([], array_filter(array_keys($map), static fn (string $name): bool => str_starts_with($name, 'loop_'))); + } + + public function testPlaceholderOptionsAreAlwaysThere(): void + { + // The map replaces VariableRef's default: every {{post:…}} placeholder must stay usable + // with the settings it writes (before/after), also where the registry lacks the option. + $map = DynamicContentMap::fromOptions(self::registry()); + + foreach (Placeholders::POST as $option) { + self::assertArrayHasKey($option, $map, $option); + foreach (array_keys(VariableRef::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) { + self::assertArrayHasKey($setting, $map[$option], "$option.$setting"); + } + } + // Supplemented with the default settings. + self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_categories']); + // Live fields win. + self::assertSame('select', $map['post_date']['date_format']); + self::assertSame('text', $map['post_excerpt']['words']); + + $placeholders = new Placeholders(DesignSystemTest::sample(), $map); + foreach (array_keys(Placeholders::POST) as $key) { + $reference = $placeholders->resolve('{{post:' . $key . '}}'); + self::assertSame( + $reference, + $placeholders->references()->canonical($reference, ComposeFixture::sanitizer()), + $key + ); + } + } + + public function testTheMapWorksInVariableRef(): void + { + $references = new VariableRef(DesignSystemTest::sample(), DynamicContentMap::fromOptions(self::registry())); + $sanitizer = ComposeFixture::sanitizer(); + $ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings); + + // An option only the live registry knows. + $site = $ref('site_title', ['before' => 'Willkommen bei ', 'after' => '']); + self::assertSame($site, $references->canonical($site, $sanitizer)); + // before/after are cleaned as rich text (kses for editors) and re-serialized; spaces stay. + self::assertSame( + $ref('post_title', ['before' => 'Neu: ', 'after' => '']), + $references->canonical($ref('post_title', ['before' => 'Neu: ', 'after' => '']), $sanitizer) + ); + // Other text fields go through text(). + self::assertSame( + $ref('post_excerpt', ['words' => '20']), + $references->canonical($ref('post_excerpt', ['words' => '20']), $sanitizer) + ); + // Meta keys still obey S29/S30, also through the live map. + $meta = $ref('post_meta_key', ['select_meta_key' => 'custom_meta__wp_page_template']); + self::assertRejected(static fn () => $references->canonical($meta, $sanitizer), 'Meta-Schlüssel'); + $ok = $ref('post_meta_key', ['meta_key' => 'telefon']); + self::assertSame($ok, $references->canonical($ok, $sanitizer)); + // enable_html stays forbidden even though the registry lists it. + $html = $ref('post_meta_key', ['enable_html' => 'on']); + self::assertRejected(static fn () => $references->canonical($html, $sanitizer), 'enable_html'); + // Loop content: Divi offers it only inside loops – never through the tools. + $loop = $ref('loop_post_title', []); + self::assertRejected(static fn () => $references->canonical($loop, $sanitizer), 'Schleifen'); + } + + public function testLiveRegistryForThePost(): void + { + $seen = []; + $map = (new DynamicContentMap(static function (int $postId) use (&$seen): array { + $seen[] = $postId; + + return DynamicContentMapTest::registry(); + }))->forPost(42); + + self::assertSame([42], $seen); + self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['site_title'] ?? null); + } + + public function testUnavailableRegistryFallsBackToTheDefault(): void + { + self::assertNull((new DynamicContentMap(static fn (): array => []))->forPost(7), 'empty (recursion guard)'); + self::assertNull((new DynamicContentMap(static fn (): string => 'x'))->forPost(7), 'no array'); + + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $map = (new DynamicContentMap(static function (): array { + throw new RuntimeException('filter exploded'); + }))->forPost(7); + } finally { + ini_set('error_log', (string) $previous); + } + self::assertNull($map); + self::assertStringContainsString('filter exploded', (string) file_get_contents($log)); + unlink($log); + + // Without Divi (no DynamicContentOptions class, as in these unit tests). + self::assertNull((new DynamicContentMap())->forPost(7)); + } + + private static function assertRejected(callable $call, string $fragment): void + { + try { + $call(); + self::fail('InvalidArgumentException expected'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php b/tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php new file mode 100644 index 0000000..563a6cd --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/WpMediaResolverTest.php @@ -0,0 +1,32 @@ +alias(static fn (int $id): bool => $id !== 5); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 6); + Functions\when('wp_get_attachment_url')->alias(static fn (int $id): string|false => $id === 8 ? false : "https://kunde.test/$id.jpg"); + Functions\when('get_post_meta')->justReturn('Alt-Text'); + } + + public function testReadableImagesOnly(): void + { + $media = new WpMediaResolver(); + + self::assertSame(['url' => 'https://kunde.test/33.jpg', 'alt' => 'Alt-Text'], $media->image(33)); + self::assertNull($media->image(0), 'no id'); + self::assertNull($media->image(5), 'no image'); + self::assertNull($media->image(6), 'not readable'); + self::assertNull($media->image(8), 'no url'); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php new file mode 100644 index 0000000..8debed7 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php @@ -0,0 +1,49 @@ +alias(static fn (string $value): string => 'kses(' . $value . ')'); + Functions\when('esc_url_raw')->alias(static fn (string $value): string => 'url(' . $value . ')'); + } + + public function testEditorsGetKsesForRichTextAndCode(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html'); + $sanitizer = new WpValueSanitizer(); + + self::assertSame('kses()', $sanitizer->html('')); + self::assertSame('kses()', $sanitizer->code('')); + self::assertFalse($sanitizer->allowsUnfilteredHtml()); + } + + public function testAdministratorsKeepRichTextAndCodeUnfiltered(): void + { + // Ruling S4: html() depends on unfiltered_html like code() – admin embeds survive updates. + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'unfiltered_html'); + $sanitizer = new WpValueSanitizer(); + + self::assertSame('', $sanitizer->html('')); + self::assertSame('', $sanitizer->code('')); + self::assertTrue($sanitizer->allowsUnfilteredHtml()); + } + + public function testTextAndUrlDoNotDependOnTheRole(): void + { + Functions\when('current_user_can')->justReturn(true); + $sanitizer = new WpValueSanitizer(); + + self::assertSame('Hallo', $sanitizer->text('Hallo')); + self::assertSame('url(https://x.test/a b)', $sanitizer->url(' https://x.test/a b ')); + } +} From b8cb01812efb878f108349dcb41cef1804b3ffca Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 15:08:48 +0200 Subject: [PATCH 116/185] fix: Task 5 review round 1 - restore point inside the kses window, strict map field types (S37) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Compose/ValueSanitizer.php | 6 +++ src/Integrations/Divi/Compose/VariableRef.php | 43 +++++++++++++++-- src/Integrations/Divi/Pages/DiviFlags.php | 15 ++++-- src/Integrations/Divi/Pages/DiviSaver.php | 45 +++++++++++++----- .../Divi/Pages/DynamicContentMap.php | 6 ++- .../Divi/Pages/WpValueSanitizer.php | 5 ++ .../Divi/Compose/ComposeFixture.php | 6 +++ .../Divi/Compose/VariableRefTest.php | 47 +++++++++++++++++++ .../Integrations/Divi/Pages/DiviFlagsTest.php | 21 ++++++++- .../Integrations/Divi/Pages/DiviSaverTest.php | 19 ++++++-- .../Divi/Pages/DynamicContentMapTest.php | 3 ++ .../Divi/Pages/WpValueSanitizerTest.php | 10 ++++ 12 files changed, 200 insertions(+), 26 deletions(-) diff --git a/src/Integrations/Divi/Compose/ValueSanitizer.php b/src/Integrations/Divi/Compose/ValueSanitizer.php index 7085816..1dfe715 100644 --- a/src/Integrations/Divi/Compose/ValueSanitizer.php +++ b/src/Integrations/Divi/Compose/ValueSanitizer.php @@ -29,4 +29,10 @@ interface ValueSanitizer * and attribute fields (*.advanced.html, *.decoration.attributes) need it (ruling S7). */ public function allowsUnfilteredHtml(): bool; + + /** + * Whether the current user may read post $id: select_post settings of dynamic content + * references (post_link_url_*) name a post whose link Divi renders (ruling S37). + */ + public function canReadPost(int $id): bool; } diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 60cac3d..a21ebfa 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -29,8 +29,10 @@ use stdClass; * Dynamic content map (ruling S22), constructor argument $dynamicContent: * array> option name => [setting key => field type] * e.g. ['post_title' => ['before' => 'text', 'after' => 'text'], 'post_date' => [… 'date_format' => 'select']] - * Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); anything else - * (number, select, …) → a scalar without markup ("<", ">"), strings cleaned with text(). + * Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); "url" → url() + * (refused when it empties a value); "select_post" → a numeric id of a post the user may read; + * "select", "yes_no_button", "number" → a scalar without markup ("<", ">"), strings cleaned with + * text(); any other type is refused (the map comes from a filter – untrusted, ruling S37). * Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => * field "type"). Always, whatever the map says: "enable_html" is rejected, loop_* names only @@ -371,13 +373,38 @@ final class VariableRef case 'text': case 'richtext': case 'html': + case 'url': if (! is_string($value)) { throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key)); } - $clean = $fieldType === 'text' ? $sanitizer->text($value) : $sanitizer->html($value); + $clean = match ($fieldType) { + 'text' => $sanitizer->text($value), + 'url' => $sanitizer->url($value), + default => $sanitizer->html($value), + }; + if ($fieldType === 'url' && $clean === '' && trim($value) !== '') { + throw new InvalidArgumentException( + sprintf('Einstellung "%s" im Divi-Verweis: ungültige URL.', $key) + ); + } break; - default: + case 'select_post': + // Ruling S37: a numeric post id the user may read – no slugs (Divi resolves those itself). + $digits = is_string($value) && preg_match('/^[1-9][0-9]*$/D', $value) === 1; + $id = is_int($value) ? $value : ($digits ? (int) $value : 0); + if ($id <= 0 || ! $sanitizer->canReadPost($id)) { + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet.', + $key + )); + } + + $clean = $value; + break; + case 'select': + case 'yes_no_button': + case 'number': if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) { throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis muss ein einfacher Wert ohne Markup sein.', @@ -386,6 +413,14 @@ final class VariableRef } $clean = is_string($value) ? $sanitizer->text($value) : $value; + break; + default: + // Ruling S37: map types come from a filter (untrusted) – unknown types are refused. + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis: Feldtyp "%s" wird nicht unterstützt.', + $key, + $fieldType + )); } // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). After the diff --git a/src/Integrations/Divi/Pages/DiviFlags.php b/src/Integrations/Divi/Pages/DiviFlags.php index d0a7463..2845f80 100644 --- a/src/Integrations/Divi/Pages/DiviFlags.php +++ b/src/Integrations/Divi/Pages/DiviFlags.php @@ -8,14 +8,19 @@ use RuntimeException; /** * Marks an entry as a Divi 5 builder layout, like the Visual Builder's own save does - * (Divi 5.2.1 SyncToServerController.php:382-384: _et_pb_use_divi_5 = "on", legacy - * _et_pb_use_builder = "on"). Ruling S11: update_post_meta() returns false for an unchanged value too, so success is - * checked by reading the flags back. Used by DiviSaver (inside FollowUp) and the Divi content - * creation. + * (Divi 5.2.1 SyncToServerController.php:382-387: _et_pb_use_divi_5 = "on", legacy + * _et_pb_use_builder = "on", and _et_pb_show_page_creation = "off" so the builder does not show + * its page creation flow on the first open). Ruling S11: update_post_meta() returns false for + * an unchanged value too, so success is checked by reading the flags back. Used by DiviSaver + * (inside FollowUp) and the Divi content creation. */ final class DiviFlags { - public const FLAGS = ['_et_pb_use_divi_5' => 'on', '_et_pb_use_builder' => 'on']; + public const FLAGS = [ + '_et_pb_use_divi_5' => 'on', + '_et_pb_use_builder' => 'on', + '_et_pb_show_page_creation' => 'off', + ]; /** @throws RuntimeException When a flag does not read back as set. */ public static function set(int $postId): void diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 01c4db0..2736367 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -15,17 +15,28 @@ use WP_Error; use WP_Post; /** - * Save pipeline of spec §5.6, steps 3–5: restore point, wp_update_post() with wp_slash() (the + * Save pipeline of spec §5.6, steps 3–5: restore point and wp_update_post() with wp_slash() (the * status stays), Divi 5 flags (DiviFlags), Divi's static CSS cache. A failure after saving is * only a warning (FollowUp); a filter that changed the stored content is reported as well. * * kses (spec D7): for users without unfiltered_html WordPress' kses would run * filter_block_content(), which re-parses and re-serializes EVERY block – untouched content * would no longer be byte-identical (spec §1.3, §5.2) and global modules could break. kses' - * filters (kses_remove_filters()) are therefore switched off for exactly this call and restored - * in finally (kses_init_filters()) – only when they were active. Every value the Divi tools - * write was sanitized and every $variable(…)$ re-serialized by the plugin before (AttrWriter, - * VariableRef, WpValueSanitizer). + * filters (kses_remove_filters()) are therefore switched off – only when they were active – and + * restored in finally (kses_init_filters()) right after wp_update_post() returns, before flags, + * cache and every other follow-up. Every value the Divi tools write was sanitized and every + * $variable(…)$ re-serialized by the plugin before (AttrWriter, VariableRef, WpValueSanitizer). + * + * The window (review M-5): kses_remove_filters() removes ALL core kses filters – post content, + * title, excerpt, content_filtered and comments (pre_comment_content) – while exactly two calls + * run: RevisionPoint::capture() (ruling S37: the restore point copies the STORED content; kses + * would strip an admin's embeds from it, so undoing via that revision would lose them) and + * wp_update_post() with every hook it fires: wp_insert_post_data, save_post(_{type}), + * wp_insert_post, post_updated (→ wp_save_post_revision / _wp_put_post_revision), + * transition_post_status, wp_after_insert_post and third-party listeners. No MCP input reaches + * those hooks except the content itself (already cleaned); anything a third-party hook writes + * there is saved without kses, as it would be for an administrator. The rollback write in + * restore() uses the same window with the previous stored content. * * Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth): * Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data @@ -52,10 +63,14 @@ class DiviSaver public function save(WP_Post $post, string $content): array { $postId = (int) $post->ID; - $revisionId = RevisionPoint::capture($postId); + $revisionId = null; try { - $result = $this->update($postId, $content); + $result = $this->withoutKses(static function () use ($postId, $content, &$revisionId): int|WP_Error { + $revisionId = RevisionPoint::capture($postId); + + return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true); + }); } catch (Throwable $e) { $this->restore($postId, (string) $post->post_content, $revisionId, $e); } @@ -101,8 +116,13 @@ class DiviSaver return ['revision_id' => $revisionId, 'warnings' => $warnings]; } - /** wp_update_post() with kses' filters off for users without unfiltered_html (D7). */ - private function update(int $postId, string $content): int|WP_Error + /** + * Runs $write with kses' filters off for users without unfiltered_html (D7); restored in + * finally, right when $write returns or throws. + * + * @param callable(): (int|WP_Error) $write + */ + private function withoutKses(callable $write): int|WP_Error { $kses = ! current_user_can('unfiltered_html') && has_filter('content_save_pre', 'wp_filter_post_kses') !== false; @@ -111,7 +131,7 @@ class DiviSaver } try { - return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true); + return $write(); } finally { if ($kses) { kses_init_filters(); @@ -129,7 +149,10 @@ class DiviSaver throw new RuntimeException('restoring would publish a missed schedule'); } - $restored = $this->update($postId, $before); + $restored = $this->withoutKses( + static fn (): int|WP_Error + => wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $before]), true) + ); if ($restored instanceof WP_Error || $restored === 0) { throw new RuntimeException('previous content could not be written back'); } diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index d050cec..aaea737 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -43,7 +43,11 @@ class DynamicContentMap try { $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); } catch (Throwable $e) { - $message = sprintf('[wp-lit-mcp] divi dynamic content options of post %d failed: %s', $postId, $e); + $message = sprintf( + '[wp-lit-mcp] divi dynamic content options of post %d failed: %s', + $postId, + $e->getMessage() + ); error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return null; diff --git a/src/Integrations/Divi/Pages/WpValueSanitizer.php b/src/Integrations/Divi/Pages/WpValueSanitizer.php index e953198..4da9814 100644 --- a/src/Integrations/Divi/Pages/WpValueSanitizer.php +++ b/src/Integrations/Divi/Pages/WpValueSanitizer.php @@ -41,4 +41,9 @@ final class WpValueSanitizer implements ValueSanitizer { return current_user_can('unfiltered_html'); } + + public function canReadPost(int $id): bool + { + return current_user_can('read_post', $id); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 83a8090..7ddce21 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -98,6 +98,12 @@ final class ComposeFixture { return $this->unfiltered; } + + /** Every post is readable but 666 (ruling S37, select_post settings). */ + public function canReadPost(int $id): bool + { + return $id !== 666; + } }; } diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 6659ea9..30835ae 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -431,4 +431,51 @@ final class VariableRefTest extends TestCase self::assertTrue(VariableRef::contains('a $variable( b')); self::assertFalse(VariableRef::contains('Preis: 5 $')); } + + /** + * Ruling S37 (M-1/M-3): map field types come from a filter – untrusted. url → url() (a URL the + * sanitizer empties is refused), select_post → a readable numeric post id, select / + * yes_no_button / number → plain values; every other type is refused. + */ + public function testMapFieldTypesAreRoutedStrictly(): void + { + $ref = self::ref([ + 'evil' => ['link' => 'url', 'color' => 'color', 'blank' => '', 'flag' => 'yes_no_button', 'n' => 'number'], + 'post_link_url_page' => ['post_id' => 'select_post'], + ]); + $sanitizer = ComposeFixture::sanitizer(); + + self::assertSame( + self::content('evil', '{"link":"https://kunde.test/a%20b","flag":"on","n":3}'), + $ref->canonical(self::content('evil', '{"link":" https://kunde.test/a b","flag":"on","n":3}'), $sanitizer) + ); + self::assertSame( + self::content('post_link_url_page', '{"post_id":"12"}'), + $ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer) + ); + self::assertSame( + self::content('post_link_url_page', '{"post_id":12}'), + $ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer) + ); + + $rejected = [ + 'javascript: in a url setting' => [self::content('evil', '{"link":"javascript:alert(1)"}'), 'URL'], + 'url setting not a string' => [self::content('evil', '{"link":5}'), 'nur Text'], + 'unknown field type' => [self::content('evil', '{"color":"red"}'), 'Feldtyp'], + 'empty field type' => [self::content('evil', '{"blank":"x"}'), 'Feldtyp'], + 'post slug' => [self::content('post_link_url_page', '{"post_id":"impressum"}'), 'Beitrags-ID'], + 'zero post id' => [self::content('post_link_url_page', '{"post_id":0}'), 'Beitrags-ID'], + 'negative post id' => [self::content('post_link_url_page', '{"post_id":"-3"}'), 'Beitrags-ID'], + 'unreadable post' => [self::content('post_link_url_page', '{"post_id":"666"}'), 'Beitrags-ID'], + ]; + foreach ($rejected as $case => [$value, $fragment]) { + try { + $ref->canonical($value, $sanitizer); + self::fail('Accepted: ' . $case); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString($fragment, $e->getMessage(), $case); + } + } + } } + diff --git a/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php b/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php index 751d42a..02374af 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviFlagsTest.php @@ -20,11 +20,17 @@ final class DiviFlagsTest extends TestCase return false; }); - Functions\when('get_post_meta')->justReturn('on'); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on' + ); DiviFlags::set(7); - self::assertSame(['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on'], $written); + // Like Divi's own save (SyncToServerController.php:382-387), incl. the page creation flow flag. + self::assertSame( + ['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on', '7:_et_pb_show_page_creation=off'], + $written + ); } public function testThrowsWhenAFlagDoesNotReadBack(): void @@ -38,4 +44,15 @@ final class DiviFlagsTest extends TestCase $this->expectExceptionMessage('_et_pb_use_builder'); DiviFlags::set(7); } + + public function testThrowsWhenThePageCreationFlagDoesNotReadBack(): void + { + Functions\when('update_post_meta')->justReturn(true); + Functions\when('get_post_meta')->justReturn('on'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('_et_pb_show_page_creation'); + DiviFlags::set(7); + } } + diff --git a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php index cf8981e..f0001f9 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php @@ -30,7 +30,11 @@ final class DiviSaverTest extends TestCase { parent::setUp(); $this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']); - Functions\when('wp_save_post_revision')->justReturn(811); + Functions\when('wp_save_post_revision')->alias(function (): int { + $this->calls[] = 'revision'; + + return 811; + }); Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool { $this->calls[] = "meta:$key=$value"; @@ -73,7 +77,12 @@ final class DiviSaverTest extends TestCase self::assertSame(['revision_id' => 811, 'warnings' => []], $saved); self::assertSame( - ['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'], + // Ruling S37: the restore-point revision is captured inside the same kses-off window – + // it copies the stored content, and kses would strip an admin's embeds from it. + [ + 'kses off', 'revision', 'update', 'kses on', + 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on', 'meta:_et_pb_show_page_creation=off', + ], $this->calls ); } @@ -87,6 +96,7 @@ final class DiviSaverTest extends TestCase $saved = (new DiviSaver())->save($this->post, 'NEU'); self::assertNotContains('kses off', $this->calls); + self::assertSame(['revision'], array_slice($this->calls, 0, 1)); self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']); } @@ -141,7 +151,10 @@ final class DiviSaverTest extends TestCase self::assertTrue($e->rolledBack); self::assertSame(['NEU', 'ALT'], $written); // The kses bypass is restored in finally – after the failed save and after the restore. - self::assertSame(['kses off', 'update', 'kses on', 'kses off', 'update', 'kses on'], $this->calls); + self::assertSame( + ['kses off', 'revision', 'update', 'kses on', 'kses off', 'update', 'kses on'], + $this->calls + ); } } diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index 69b3a4f..919f416 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -187,6 +187,9 @@ final class DynamicContentMapTest extends TestCase } self::assertNull($map); self::assertStringContainsString('filter exploded', (string) file_get_contents($log)); + // The message only – no stack trace in the log (review M-4). + self::assertStringNotContainsString('#0 ', (string) file_get_contents($log)); + self::assertStringNotContainsString('RuntimeException', (string) file_get_contents($log)); unlink($log); // Without Divi (no DynamicContentOptions class, as in these unit tests). diff --git a/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php index 8debed7..4e5ac15 100644 --- a/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php +++ b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php @@ -46,4 +46,14 @@ final class WpValueSanitizerTest extends TestCase self::assertSame('Hallo', $sanitizer->text('Hallo')); self::assertSame('url(https://x.test/a b)', $sanitizer->url(' https://x.test/a b ')); } + + public function testCanReadPostAsksWordPress(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $cap === 'read_post' && $id === 12); + $sanitizer = new WpValueSanitizer(); + + self::assertTrue($sanitizer->canReadPost(12)); + self::assertFalse($sanitizer->canReadPost(13)); + } } + From 110beb76987b156a168b83936a2ba2c56e438fa8 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 15:15:37 +0200 Subject: [PATCH 117/185] feat: lit/divi-insert and lit/divi-update All-or-nothing at ability level (S39), duplicate ids refused (S14), set and attrs on the same path refused (S38), invalid_attr_path error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 3 +- src/Abilities/Errors.php | 18 ++ src/Integrations/Divi/Compose/Composer.php | 34 ++ .../Divi/Compose/ShortformWriter.php | 19 ++ src/Integrations/Divi/DiviIntegration.php | 15 +- .../Divi/Pages/DiviInsertAbility.php | 130 ++++++++ .../Divi/Pages/DiviUpdateAbility.php | 175 +++++++++++ src/Plugin.php | 2 +- .../Divi/Compose/ComposerTest.php | 20 ++ .../Integrations/Divi/DiviIntegrationTest.php | 5 +- .../Divi/Pages/DiviWriteAbilitiesTest.php | 295 ++++++++++++++++++ 11 files changed, 711 insertions(+), 5 deletions(-) create mode 100644 src/Integrations/Divi/Pages/DiviInsertAbility.php create mode 100644 src/Integrations/Divi/Pages/DiviUpdateAbility.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index f453861..7ca9f5d 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -20,7 +20,8 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, `lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, -`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. +`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, +`lit-divi-update`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index f64576e..a5d1da7 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -101,6 +101,24 @@ final class Errors return self::make('unsupported', $message, $hint, 400); } + /** + * Unknown Divi attribute paths (spec §5.4, §8.2). $fields names every problem of the call, + * the unknown paths with their closest known ones. + * + * @param array $fields Input path => problem. + */ + public static function invalidAttrPath(array $fields): WP_Error + { + return self::make( + 'invalid_attr_path', + 'Unbekannte Attributpfade; nichts wurde gespeichert.', + 'Vorschläge unter fields prüfen; alle gültigen Pfade eines Moduls liefert lit/divi-catalog ' + . '(direkt: lit-divi-catalog) mit module.', + 400, + ['fields' => $fields] + ); + } + /** * A write failed and was rolled back completely. * diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index 81e9c93..f53107d 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -116,6 +116,7 @@ final class Composer } } + $this->overlaps($block->shortName(), $change, $field); $attrs = $this->presetAndAttrs($block->name(), $attrs, $change, $field); if (array_key_exists('label', $change)) { $attrs = $change['label'] === null || $change['label'] === '' @@ -126,6 +127,39 @@ final class Composer return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; } + /** + * Ruling S38: one change may not reach the same attribute path through "set" and "attrs" – + * composite values are replaced whole per breakpoint, so combining them would silently lose + * sub-keys. Paths overlap when equal or when one lies below the other. + * + * @param array $change + */ + private function overlaps(string $type, array $change, string $field): void + { + $set = $change['set'] ?? null; + $full = $change['attrs'] ?? null; + if (! is_array($set) || ! is_array($full) || $set === [] || $full === []) { + return; + } + + $short = $this->shortform->paths($type, array_map('strval', array_keys($set))); + foreach (array_keys($full) as $path) { + $path = (string) $path; + foreach ($short as $name => $shortPath) { + if ( + $path === $shortPath || str_starts_with($path, $shortPath . '.') + || str_starts_with($shortPath, $path . '.') + ) { + $this->errors[$field . '.attrs.' . $path] = sprintf( + 'derselbe Attributpfad wird auch über set.%s geändert; bitte als zwei Änderungen senden', + $name + ); + break; + } + } + } + } + /** Admin label for a new module: " – " or the title alone. */ public function autoLabel(string $name, string $context): string { diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index d9134ef..6caf160 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -72,6 +72,25 @@ final class ShortformWriter return ['attrs' => $attrs, 'errors' => $errors]; } + /** + * Attribute paths the given short-form fields of $type write to (unknown fields left out). + * + * @param list $names Field names. + * @return array Field => attribute path. + */ + public function paths(string $type, array $names): array + { + $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); + $paths = []; + foreach ($names as $name) { + if (isset($fields[$name]['path'])) { + $paths[$name] = (string) $fields[$name]['path']; + } + } + + return $paths; + } + /** * The short-form color check (ruling S13; Task 3 reuses it for background_color): a * placeholder or reference that is a color reference, or a plain CSS color – checked diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 52b66fa..749d7f6 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -4,11 +4,17 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; +use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; +use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -16,14 +22,15 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; /** * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded - * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). + * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one + * DiviWriter; each binds the composition to its entry via DiviWriter::composition(). */ final class DiviIntegration implements Integration { private DiviModules $modules; private DesignSystemReader $design; - public function __construct(private ContentSchema $schema) + public function __construct(private ContentSchema $schema, private ChangeLog $log) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); @@ -42,12 +49,16 @@ final class DiviIntegration implements Integration public function abilities(): array { $loader = new DiviPageLoader($this->schema); + $composition = new DiviComposition($this->modules, $this->design); + $writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition); return [ new DiviOutlineAbility($loader, $this->design), new DiviNodeGetAbility($loader, $this->design), new DiviCatalogAbility($this->modules), new DiviDesignSystemAbility($this->design), + new DiviInsertAbility($writer), + new DiviUpdateAbility($writer), ]; } } diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php new file mode 100644 index 0000000..9474f3e --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php @@ -0,0 +1,130 @@ + 'object', + 'properties' => array_merge(DiviWriter::inputProperties(), [ + 'nodes' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 20, + 'items' => ['type' => 'object'], + 'description' => 'Einzufügende Knoten in Reihenfolge; Abschnitte mit rows/modules, Zeilen mit ' + . 'columns und modules, Module mit Kurzform-Feldern, items (Kind-Module) und attrs.', + ], + ], DiviWriter::positionProperties()), + 'required' => ['post_id', 'tree_rev', 'nodes'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $page = $this->writer->page($input); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $page = $this->writer->page($input); + if ($page instanceof WP_Error) { + return $page; + } + + $composition = $this->writer->composition($page); + $editor = $composition->editor(); + $position = $editor->position($page->index, $input); + if (is_string($position)) { + // The nodes are checked against their parent – without a valid position there is none. + return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]); + } + + $context = DiviWriter::sectionLabel($page->index, $position['parent']); + $nodes = is_array($input['nodes'] ?? null) ? array_values($input['nodes']) : []; + if ($nodes === []) { + return Errors::invalidInput('Keine Knoten angegeben; nichts wurde gespeichert.', [ + 'nodes' => 'mindestens ein Knoten', + ]); + } + + $composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context); + if ($composed['errors'] !== []) { + return $composed['invalid_paths'] !== [] + ? Errors::invalidAttrPath($composed['errors']) + : Errors::invalidInput('Knoten sind ungültig; nichts wurde gespeichert.', $composed['errors']); + } + + $violation = $editor->insert($position, $composed['blocks']); + if ($violation !== null) { + return Errors::invalidInput( + 'Knoten passen nicht an diese Stelle; nichts wurde gespeichert.', + ['nodes' => $violation] + ); + } + + $change = new DiviChange(sprintf('%d Divi-Knoten eingefügt', count($composed['blocks']))); + foreach ($composed['blocks'] as $block) { + $change->changed($block, 'inserted'); + } + + return $this->writer->save($this->name(), $page, $change, $input); + } +} diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php new file mode 100644 index 0000000..2792428 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -0,0 +1,175 @@ + 'object', + 'properties' => array_merge(DiviWriter::inputProperties(), [ + 'changes' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 50, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'set' => ['type' => 'object', 'description' => 'Kurzform-Felder, z. B. {"text": "Neu"}.'], + 'attrs' => [ + 'type' => 'object', + 'description' => 'Vollform: Attributpfad → Breakpoint-Werte.', + ], + 'preset' => ['type' => ['string', 'null'], 'description' => 'Preset-Name; null entfernt.'], + 'label' => [ + 'type' => ['string', 'null'], + 'description' => 'Admin-Label im Divi-Builder; null entfernt.', + ], + ], + 'required' => ['id'], + 'additionalProperties' => false, + ], + ], + ]), + 'required' => ['post_id', 'tree_rev', 'changes'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $page = $this->writer->page($input); + + return $page instanceof WP_Error ? $page : true; + } + + public function execute(array $input): mixed + { + $page = $this->writer->page($input); + if ($page instanceof WP_Error) { + return $page; + } + + $changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : []; + if ($changes === []) { + return Errors::invalidInput('Keine Änderungen angegeben; nichts wurde gespeichert.', [ + 'changes' => 'mindestens eine Änderung', + ]); + } + + $composer = $this->writer->composition($page)->composer(); + $errors = []; + $invalidPaths = false; + /** @var array $seen id => position of its first change */ + $seen = []; + /** @var list}> $updates */ + $updates = []; + foreach ($changes as $position => $change) { + $field = sprintf('changes[%d]', $position); + if (! is_array($change)) { + $errors[$field] = 'Objekt mit id erwartet'; + continue; + } + + $id = is_string($change['id'] ?? null) ? $change['id'] : ''; + if ($id !== '' && isset($seen[$id])) { + // Ruling S14: a second change of the same node would overwrite the first silently. + $errors[$field . '.id'] = sprintf( + 'Knoten "%s" steht schon in changes[%d]; alle Änderungen eines Knotens in einer Änderung bündeln', + $id, + $seen[$id] + ); + continue; + } + + $block = $id === '' ? null : $page->index->find($id); + if ($id !== '') { + $seen[$id] = $position; + } + + if ($block === null) { + $errors[$field . '.id'] = sprintf('Knoten "%s" gibt es nicht (lit-divi-outline neu lesen)', $id); + continue; + } + + $result = $composer->update($block, $change, $field); + $errors += $result['errors']; + $invalidPaths = $invalidPaths || $result['invalid_paths'] !== []; + $updates[] = [$block, $result['attrs']]; + } + + if ($errors !== []) { + return $invalidPaths + ? Errors::invalidAttrPath($errors) + : Errors::invalidInput('Änderungen sind ungültig; nichts wurde gespeichert.', $errors); + } + + $updates = array_values(array_filter( + $updates, + static fn (array $update): bool => $update[1] !== $update[0]->attrs() + )); + $change = new DiviChange(sprintf('%d Divi-Knoten geändert', count($updates))); + foreach ($updates as [$block, $attrs]) { + // Composer::update() output: every client value already passed the choke point. + $block->setAttrs($attrs); + $change->changed($block, 'updated'); + } + + return $this->writer->save($this->name(), $page, $change, $input); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 9ed09db..4de2e5d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -68,7 +68,7 @@ final class Plugin new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog), new MediaIntegration($schema, $changeLog, $limits), - new DiviIntegration($schema) + new DiviIntegration($schema, $changeLog) ); $policy = ExposurePolicy::fromOption(); diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php index f3f22bd..fe47b7e 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -183,6 +183,26 @@ final class ComposerTest extends TestCase self::assertStringContainsString('nur lesbar', $readOnly['errors']['changes[2].id']); } + /** Ruling S38: one change may not reach the same attribute path through set and attrs. */ + public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void + { + $block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); + + $result = ComposeFixture::composer()->update($block, [ + 'set' => ['text' => 'Neu'], + 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/kontakt']]], + ], 'changes[0]'); + + self::assertStringContainsString('set.text', $result['errors']['changes[0].attrs.button.innerContent']); + self::assertSame([], $result['invalid_paths']); + + $separate = ComposeFixture::composer()->update($block, [ + 'set' => ['text' => 'Neu'], + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#ffffff']]], + ], 'changes[0]'); + self::assertSame([], $separate['errors'], 'different paths may be combined'); + } + /** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */ public function testLabelsAreCleanedOrReported(): void { diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index d007b41..6572107 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi; use LIT\MCP\Abilities\Ability; +use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Unit\TestCase; @@ -18,11 +19,13 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-node-get', 'lit/divi-catalog', 'lit/divi-design-system', + 'lit/divi-insert', + 'lit/divi-update', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void { - $integration = new DiviIntegration(Mockery::mock(ContentSchema::class)); + $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class)); self::assertSame('divi', $integration->id()); self::assertFalse($integration->isAvailable(), 'no Divi classes in unit tests'); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php new file mode 100644 index 0000000..0672cfb --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php @@ -0,0 +1,295 @@ + Contents handed to DiviSaver::save(). */ + private array $saved = []; + + private DiviWriter $writer; + + protected function setUp(): void + { + parent::setUp(); + $this->original = DiviPageLoaderTest::fixture('synthetic-basic.html'); + $this->post = DiviPageLoaderTest::givenPost($this->original); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()); + + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array { + $this->saved[] = $content; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(4711); + + $this->writer = new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + $composition + ); + } + + private function treeRev(): string + { + return Rev::of($this->original); + } + + private function insert(): DiviInsertAbility + { + return new DiviInsertAbility($this->writer); + } + + private function update(): DiviUpdateAbility + { + return new DiviUpdateAbility($this->writer); + } + + /** @return array The structured error the client sees (message JSON, spec §8.2). */ + private static function clientError(WP_Error $error): array + { + return (array) json_decode($error->get_error_message(), true); + } + + public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void + { + $result = $this->insert()->execute([ + 'post_id' => 7, + 'tree_rev' => $this->treeRev(), + 'after' => 's0.r0.c1.m1', + 'nodes' => [['type' => 'text', 'content' => '

    Neu

    ']], + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + self::assertCount(1, $this->saved); + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + self::assertSame(Rev::ofPost($this->post), $result['rev']); + self::assertSame(811, $result['revision_id']); + self::assertSame(4711, $result['log_id']); + self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']); + self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); + + // Byte identity: only the new block was added, every other byte is unchanged. + $inserted = ''; + self::assertSame(str_replace('' . $inserted . '', $this->saved[0]); + self::assertSame('s0', $result['changed'][0]['id']); + self::assertSame('Start', $result['changed'][0]['label']); + } + + public function testUpdateChangesSeveralNodesAllOrNothing(): void + { + $failed = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], + ['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']], + ['id' => 's0.r0.c1.m9', 'label' => 'x'], + ]]); + self::assertSame('lit_validation_failed', $failed->get_error_code()); + $fields = self::clientError($failed)['fields']; + self::assertArrayHasKey('changes[1].set.level', $fields); + self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)'); + self::assertSame([], $this->saved); + + $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], + ['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]], + ]]); + + self::assertTrue($result['ok']); + self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id')); + self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action')); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']); + self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']); + // Untouched section s1 keeps its exact bytes. + self::assertStringContainsString(substr($this->original, (int) strpos($this->original, ''; + self::assertStringContainsString($buttonBytes, $this->original, 'precondition: exact bytes as expected'); + + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s0.r0.c1.m2'], 'confirm' => true, + ]); + + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'deleted', 'label' => 'Hero – Button']], $result['changed']); + self::assertSame(str_replace($buttonBytes, '', $this->original), $this->saved[0]); + self::assertSame(811, $result['revision_id'], 'the restore-point revision'); + self::assertStringContainsString('Hero', (string) $result['outline'], 'the outline is embedded in the result'); + } + + /** An opaque/global module is removed as a whole – it never gets descended into. */ + public function testDeleteRemovesAnOpaqueGlobalModuleAsAWhole(): void + { + $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); + $this->post->post_content = $mixed; + $globalRow = ''; + self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); + + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s1.g1'], 'confirm' => true, + ]); + + self::assertSame([['id' => 's1.g1', 'type' => 'divi/row', 'action' => 'deleted', 'label' => 'Globale Zeile']], $result['changed']); + self::assertSame(str_replace($globalRow, '', $mixed), $this->saved[0]); + } + + public function testDeletingTheLastChildLeavesAValidTree(): void + { + $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); + $this->post->post_content = $mixed; + + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s0.c1.r0.c0.m0'], 'confirm' => true, + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + $emptyColumn = $index->find('s0.c1.r0.c0'); + self::assertNotNull($emptyColumn, 'the now-empty column-inner is still a valid, findable node'); + self::assertSame([], $emptyColumn->children()); + } + + public function testDeleteUnknownIdReturnsNotFound(): void + { + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s9'], 'confirm' => true, + ]); + + self::assertSame('lit_not_found', $result->get_error_code()); + self::assertSame('not_found', self::clientError($result)['code']); + self::assertSame([], $this->saved); + } + + /** Same annotations as the existing destructive tools (e.g. MediaDeleteAbility). */ + public function testDeleteAnnotationsMatchExistingDestructiveTools(): void + { + self::assertSame( + ['readonly' => false, 'destructive' => true, 'idempotent' => false], + $this->delete()->annotations() + ); + } + + /** Ruling S14 (Task 7): the deleted node's own confirm question, unaffected by other tests. */ + public function testDeleteAsksBeforeRemovingNestedNodesOnlyOnce(): void + { + $duplicated = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); self::assertSame('s2', $duplicated['changed'][0]['id']); - self::assertSame('Leistungen (Kopie)', $duplicated['changed'][0]['label']); $delete = $this->delete(); - $ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[2]), 'ids' => ['s2', 's2.r0']]); + $ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2', 's2.r0']]); self::assertSame('lit_confirm_required', $ask->get_error_code()); self::assertSame( '1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).', json_decode($ask->get_error_message(), true)['message'] ); - $deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[2]), 'ids' => ['s2'], 'confirm' => true]); + $deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2'], 'confirm' => true]); self::assertSame([['id' => 's2', 'type' => 'divi/section', 'action' => 'deleted', 'label' => 'Leistungen (Kopie)']], $deleted['changed']); - self::assertSame($this->saved[1], $this->saved[3], 'deleting the copy restores the exact bytes'); - self::assertTrue($delete->annotations()['destructive']); + self::assertSame($this->original, $this->saved[1], 'deleting the copy restores the exact bytes'); + } + + /** The copy sits directly after the original; its own child content is byte-identical, nothing else changes. */ + public function testDuplicateInsertsTheCopyDirectlyAfterTheOriginal(): void + { + $sectionOpen = ''; + $sectionClose = ''; + $start = strpos($this->original, $sectionOpen) + strlen($sectionOpen); + $end = strpos($this->original, $sectionClose, $start); + $rowBytes = substr($this->original, $start, $end - $start); + $original = $sectionOpen . $rowBytes . $sectionClose; + self::assertStringContainsString($original, $this->original, 'precondition: exact bytes as expected'); + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); + + self::assertSame('s2', $result['changed'][0]['id']); + self::assertSame('Leistungen (Kopie)', $result['changed'][0]['label']); + self::assertStringContainsString('Leistungen', (string) $result['outline'], 'the outline is embedded in the result'); + + $copyOpen = str_replace('"Leistungen"', '"Leistungen (Kopie)"', $sectionOpen); + $copy = $copyOpen . $rowBytes . $sectionClose; + self::assertSame( + str_replace($original, $original . $copy, $this->original), + $this->saved[0], + 'copy directly after the original, byte-identical child content, nothing else changes' + ); + } + + /** A global module copy stays a byte-identical reference (read-only nodes move/duplicate as a whole). */ + public function testDuplicateGlobalModuleCopyIsAByteIdenticalReference(): void + { + $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); + $this->post->post_content = $mixed; + $globalRow = ''; + self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($mixed), 'id' => 's1.g1']); + + self::assertSame('s1.g2', $result['changed'][0]['id']); + self::assertSame('divi/row', $result['changed'][0]['type']); + self::assertSame('Globale Zeile', $result['changed'][0]['label'], 'opaque copies keep their label unchanged'); + self::assertSame(str_replace($globalRow, $globalRow . $globalRow, $mixed), $this->saved[0]); + } + + /** Permission denied (edit_post false) for all three new abilities. */ + public function testPermissionDeniedWhenNotEditable(): void + { + Functions\when('current_user_can')->justReturn(false); + + foreach ([$this->move(), $this->delete(), $this->duplicate()] as $ability) { + $result = $ability->permission(['post_id' => 7, 'tree_rev' => $this->treeRev()]); + self::assertInstanceOf(WP_Error::class, $result, $ability->name()); + self::assertSame('lit_forbidden', $result->get_error_code(), $ability->name()); + } + self::assertSame([], $this->saved); } public function testReadOnlyNodesCannotBeUpdated(): void From ede0221b50c68f147751aab4f9c25bdd62af41db Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 16:02:33 +0200 Subject: [PATCH 124/185] fix: Task 7 review round 1 - copy the raw admin label verbatim on duplicate Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Pages/DiviDuplicateNodeAbility.php | 15 +++-- .../Divi/Pages/DiviWriteAbilitiesTest.php | 55 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php b/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php index 02d9d90..88aa8cc 100644 --- a/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php +++ b/src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php @@ -7,7 +7,6 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; -use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use WP_Error; /** @@ -86,10 +85,16 @@ final class DiviDuplicateNodeAbility implements Ability $copy = $this->writer->composition($page)->editor()->duplicate($page->index, $node); if ($copy->isDivi() && ! $copy->isOpaque()) { - $label = NodeSummary::label($node); - $copy->setAttrs(AttrMap::set($copy->attrs(), 'module.meta.adminLabel', [ - 'desktop' => ['value' => ($label === '' ? $node->shortName() : $label) . ' (Kopie)'], - ])); + // The raw stored desktop value (never NodeSummary::label()'s display-shortened + // text): a long or multi-line label must be copied verbatim, only " (Kopie)" added. + // Setting only the ".desktop.value" leaf keeps every other breakpoint untouched. + $raw = AttrMap::desktop($node->attrs(), 'module.meta.adminLabel'); + $label = is_string($raw) ? $raw : ''; + $copy->setAttrs(AttrMap::set( + $copy->attrs(), + 'module.meta.adminLabel.desktop.value', + ($label === '' ? $node->shortName() : $label) . ' (Kopie)' + )); } $change = (new DiviChange(sprintf('Knoten %s dupliziert', $id)))->changed($copy, 'duplicated'); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php index ca60114..9aa40ca 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Rev; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; @@ -458,6 +459,60 @@ final class DiviWriteAbilitiesTest extends TestCase self::assertSame(str_replace($globalRow, $globalRow . $globalRow, $mixed), $this->saved[0]); } + /** + * Fix round 1 (Important-1): the copy's admin label must be the raw stored desktop value + * plus " (Kopie)" – never NodeSummary::label()'s display-shortened text (80-char limit). + */ + public function testDuplicateCopiesALongAdminLabelVerbatim(): void + { + $long = str_repeat('Ausführliche Beschriftung, ', 4) . 'Ende'; + self::assertGreaterThan(80, strlen($long), 'precondition: longer than the NodeSummary::label() display limit'); + $content = str_replace( + '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', + '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $long . '"}}}}', + $this->original + ); + self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); + $this->post->post_content = $content; + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); + + self::assertTrue($result['ok'], (string) json_encode($result)); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + $copy = $index->find('s0.r0.c1.m3'); + self::assertNotNull($copy, 'the duplicate sits right after the original module'); + self::assertSame( + $long . ' (Kopie)', + AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), + 'stored verbatim, not shortened/ellipsised for display' + ); + } + + public function testDuplicateCopiesALabelWithNewlinesAndMultipleSpacesVerbatim(): void + { + $messy = "Erste Zeile mit viel Leerraum\nZweite Zeile"; + $encoded = substr((string) json_encode($messy, JSON_UNESCAPED_UNICODE), 1, -1); + $content = str_replace( + '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', + '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $encoded . '"}}}}', + $this->original + ); + self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); + $this->post->post_content = $content; + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); + + self::assertTrue($result['ok'], (string) json_encode($result)); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + $copy = $index->find('s0.r0.c1.m3'); + self::assertNotNull($copy, 'the duplicate sits right after the original module'); + self::assertSame( + $messy . ' (Kopie)', + AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), + 'whitespace/newlines kept verbatim, not collapsed' + ); + } + /** Permission denied (edit_post false) for all three new abilities. */ public function testPermissionDeniedWhenNotEditable(): void { From d67cd0ede6e8fac47e50938663b1173327c9e8da Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 16:16:01 +0200 Subject: [PATCH 125/185] feat: lit/divi-replace-text with dry run and per-page tree_rev targets TextReplacer replaces only in plain-text segments (S8 split), outside script/style bodies, inserts the replacement cleaned per field kind and refuses any result whose tags, entities or references differ from the original plus the inserted text. Unchanged strings are never cleaned. Per page all or nothing with the tree_rev lock; Throwables are reported per page (S10). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 2 +- src/Integrations/Divi/Compose/AttrWriter.php | 11 + .../Divi/Compose/TextReplacer.php | 324 ++++++++++++ src/Integrations/Divi/DiviIntegration.php | 2 + .../Divi/Pages/DiviReplaceTextAbility.php | 479 ++++++++++++++++++ .../Divi/Compose/AttrWriterTest.php | 14 + .../Divi/Compose/TextReplacerTest.php | 239 +++++++++ .../Integrations/Divi/DiviIntegrationTest.php | 1 + .../Divi/Pages/DiviReplaceTextAbilityTest.php | 456 +++++++++++++++++ 9 files changed, 1527 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Divi/Compose/TextReplacer.php create mode 100644 src/Integrations/Divi/Pages/DiviReplaceTextAbility.php create mode 100644 tests/Unit/Integrations/Divi/Compose/TextReplacerTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index b1942e3..7cc5165 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -32,7 +32,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`). Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 9f879c6..17829ec 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -135,6 +135,17 @@ final class AttrWriter return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind); } + /** + * The kind cleanValue() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text + * uses it to leave URL and code fields alone (D10). + * + * @return 'html'|'code'|'url'|'text' + */ + public function kindOf(string $module, string $path, ?string $subKey = null): string + { + return $this->kind($module, $path, $subKey, $subKey); + } + /** * @return array> Breakpoint => state => value. * @throws InvalidArgumentException diff --git a/src/Integrations/Divi/Compose/TextReplacer.php b/src/Integrations/Divi/Compose/TextReplacer.php new file mode 100644 index 0000000..2de5fc2 --- /dev/null +++ b/src/Integrations/Divi/Compose/TextReplacer.php @@ -0,0 +1,324 @@ +/

    Müller

    ' + . ' Müller'; + + $result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text($value), self::same()); + + self::assertSame( + '

    Schmidt

    ' + . ' Schmidt', + self::valueOf($result['attrs']) + ); + self::assertSame(2, $result['count']); + + $unclosed = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text('', 'Müller', '";alert(1);//', false], + ]; + } + + /** @dataProvider malicious */ + public function testMaliciousReplacementsStoreNothing(string $html, string $search, string $replace, bool $unfiltered): void + { + $ability = $this->ability(self::page([self::text($html)]), $unfiltered); + + $dry = $ability->execute(['search' => $search, 'replace' => $replace]); + $result = $this->replaceFor($ability, ['search' => $search, 'replace' => $replace]); + + self::assertSame([], $this->saved); + if ($result instanceof WP_Error) { + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertInstanceOf(WP_Error::class, $dry); + + return; + } + + $entry = $result['results'][0]; + self::assertTrue(! $entry['ok'] || $entry['count'] === 0, (string) json_encode($entry)); + self::assertSame([], $dry['matches'], 'the dry run shows no hit either'); + } + + /** Ruling S10: an unexpected error on one page is logged, reported neutrally, and the batch goes on. */ + public function testAnUnexpectedErrorOnOnePageDoesNotStopTheBatch(): void + { + $ability = $this->ability(); + $treeRev = Rev::of((string) $this->post->post_content); + $this->failures = [new RuntimeException('SQLSTATE secret detail')]; + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $result = $ability->execute([ + 'search' => 'Termin', + 'replace' => 'Rückruf', + 'dry_run' => false, + 'confirm' => true, + 'targets' => [['post_id' => 7, 'tree_rev' => $treeRev], ['post_id' => 7, 'tree_rev' => $treeRev]], + ]); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertFalse($result['results'][0]['ok']); + self::assertStringNotContainsString('SQLSTATE', $result['results'][0]['error']); + self::assertStringContainsString('Interner Fehler', $result['results'][0]['error']); + self::assertTrue($result['results'][1]['ok']); + self::assertCount(1, $this->saved); + self::assertStringContainsString('SQLSTATE secret detail', (string) file_get_contents($log)); + unlink($log); + } + + public function testWriteFailedKeepsItsOwnMessage(): void + { + $ability = $this->ability(); + $this->failures = [new WriteFailed('Die Datenbank hat das Speichern abgelehnt.', true, 7)]; + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $result = $this->replaceFor($ability, ['search' => 'Termin', 'replace' => 'Rückruf']); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertFalse($result['results'][0]['ok']); + self::assertStringContainsString('Die Datenbank hat das Speichern abgelehnt.', $result['results'][0]['error']); + self::assertStringContainsString('Eintrag 7:', $result['warnings'][0]); + unlink($log); + } + + /** A refused string makes its whole page fail (all or nothing per page); other pages go on. */ + public function testARefusedStringFailsOnlyItsPage(): void + { + $ability = $this->ability(self::page([self::heading('Müller'), self::text('a < b Müller')])); + + $result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'x>']); + + self::assertSame([], $this->saved); + self::assertFalse($result['results'][0]['ok']); + self::assertStringContainsString('s0.r0.c0.m1', $result['results'][0]['error']); + self::assertStringContainsString('content.innerContent', $result['results'][0]['error']); + } +} From 776dbf8f4abe7a08c2005639e525970b1380f7b4 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 16:17:05 +0200 Subject: [PATCH 126/185] feat: group tools by integration with inherited group mode and "nur lesen" switch Tools tab shows one block per integration with a group mode (inherited by its tools) and a read-only switch that disables all non-readonly abilities of the group. ExposurePolicy resolves tool override > group override > tool default; lit/guide keeps its default, direct-only tools fall back to direct under a group "indirekt". Group entries live in lit_mcp_exposure under "@group:" and "@readonly:"; the old flat format stays valid. The ability->group map comes lazily from IntegrationRegistry (Divi is not loaded at plugins_loaded). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Abilities/ExposurePolicy.php | 206 +++++++++++++++-- src/Admin/ToolsTab.php | 217 ++++++++++++++---- src/Integrations/IntegrationRegistry.php | 66 +++++- src/Plugin.php | 2 +- tests/Unit/Abilities/AbilityRegistrarTest.php | 14 ++ tests/Unit/Abilities/ExposurePolicyTest.php | 157 ++++++++++++- tests/Unit/Admin/ToolsTabTest.php | 216 +++++++++++++++-- tests/Unit/Guides/InstructionsBuilderTest.php | 20 ++ .../Integrations/IntegrationRegistryTest.php | 22 ++ tests/Unit/Server/ServerRegistrarTest.php | 19 ++ 10 files changed, 847 insertions(+), 92 deletions(-) diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index 3f5443b..1db96b8 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -7,6 +7,12 @@ namespace LIT\MCP\Abilities; /** * Decides per ability whether it is a direct MCP tool, only reachable through the * adapter's meta-tools, or disabled (spec §4.3). Ability::isDirect() is the default. + * + * Abilities are grouped by the integration they come from. Resolution: tool override › + * group override › tool default. A group's "nur lesen" switch disables every ability of the + * group that is not annotated readonly, whatever the modes say. REQUIRED abilities always + * keep their default. Group entries live in the same option under keys that start with "@" + * (ability names always contain "/", so they cannot collide). */ final class ExposurePolicy { @@ -16,6 +22,12 @@ final class ExposurePolicy public const DISABLED = 'disabled'; public const MODES = [self::DIRECT, self::INDIRECT, self::DISABLED]; + /** Option key prefix of a group's mode: "@group:" => mode. */ + public const GROUP_PREFIX = '@group:'; + + /** Option key prefix of a group's read-only switch: "@readonly:" => true. */ + public const READONLY_PREFIX = '@readonly:'; + /** * Image/embedded-resource results only work for direct tools (ruling Q6 / task 6 * dispatch): lit/media-preview (ImageContent) and lit/media-download (embedded @@ -26,9 +38,41 @@ final class ExposurePolicy /** Entry point for every conversation – must always stay a direct tool. */ public const REQUIRED = ['lit/guide']; - /** @param array $overrides Ability name => mode. */ - public function __construct(private array $overrides = []) + /** @var array Ability name => mode. */ + private array $tools = []; + + /** @var array Group id => mode. */ + private array $groupModes = []; + + /** @var array Group ids switched to "nur lesen". */ + private array $readonlyGroups = []; + + /** @var array|null Ability name => group id; null until resolved. */ + private ?array $groups = []; + + /** @var (callable(): array)|null Lazy source of $groups. */ + private $groupSource = null; + + /** + * @param array $overrides Option content: ability name => mode, plus + * "@group:" => mode and "@readonly:" => true. + */ + public function __construct(array $overrides = []) { + foreach ($overrides as $key => $value) { + $key = (string) $key; + if (str_starts_with($key, self::GROUP_PREFIX)) { + if (is_string($value) && in_array($value, self::MODES, true)) { + $this->groupModes[substr($key, strlen(self::GROUP_PREFIX))] = $value; + } + } elseif (str_starts_with($key, self::READONLY_PREFIX)) { + if ($value === true) { + $this->readonlyGroups[substr($key, strlen(self::READONLY_PREFIX))] = true; + } + } elseif (is_string($value)) { + $this->tools[$key] = $value; + } + } } public static function fromOption(): self @@ -36,11 +80,98 @@ final class ExposurePolicy return new self(self::sanitize(get_option(self::OPTION, []))); } + /** + * Copy of this policy that knows which group (integration id) each ability belongs to. + * Without it, group entries have no effect. A callable is resolved on first use – boot runs + * on plugins_loaded, before the theme (Divi) is loaded, so the map must not be built there. + * + * @param array|(callable(): array) $groups Ability name => group id. + */ + public function withGroups(array|callable $groups): self + { + $clone = clone $this; + if (is_array($groups)) { + $clone->groups = $groups; + $clone->groupSource = null; + } else { + $clone->groups = null; + $clone->groupSource = $groups; + } + + return $clone; + } + public function mode(Ability $ability): string { - $mode = $this->overrides[$ability->name()] ?? null; + if (in_array($ability->name(), self::REQUIRED, true)) { + return $this->defaultMode($ability); + } - return is_string($mode) && self::isAllowed($ability->name(), $mode) ? $mode : $this->defaultMode($ability); + if ($this->disabledByReadonly($ability)) { + return self::DISABLED; + } + + return $this->toolOverride($ability->name()) ?? $this->inheritedMode($ability); + } + + /** Mode the ability gets without an override of its own: group override › tool default. */ + public function inheritedMode(Ability $ability): string + { + $name = $ability->name(); + $groupMode = $this->groupModeOf($name); + if ($groupMode === null || in_array($name, self::REQUIRED, true)) { + return $this->defaultMode($ability); + } + + return self::isAllowed($name, $groupMode) ? $groupMode : self::DIRECT; + } + + /** True if the group's mode is not allowed for this ability and it falls back to "direct". */ + public function groupModeAdjusted(Ability $ability): bool + { + $name = $ability->name(); + $groupMode = $this->groupModeOf($name); + + return $groupMode !== null + && ! in_array($name, self::REQUIRED, true) + && ! self::isAllowed($name, $groupMode); + } + + /** True if the group's "nur lesen" switch disables this (writing) ability. */ + public function disabledByReadonly(Ability $ability): bool + { + $group = $this->groupOf($ability->name()); + if ($group === null || ! isset($this->readonlyGroups[$group])) { + return false; + } + + if (in_array($ability->name(), self::REQUIRED, true)) { + return false; + } + + return ($ability->annotations()['readonly'] ?? false) !== true; + } + + public function hasToolOverride(string $name): bool + { + return $this->toolOverride($name) !== null; + } + + public function toolOverride(string $name): ?string + { + $mode = $this->tools[$name] ?? null; + + return $mode !== null && self::isAllowed($name, $mode) ? $mode : null; + } + + public function groupMode(string $group): ?string + { + return $this->groupModes[$group] ?? null; + } + + public function isReadonly(string $group): bool + { + return isset($this->readonlyGroups[$group]); } public function defaultMode(Ability $ability): string @@ -94,29 +225,76 @@ final class ExposurePolicy } /** - * Settings API sanitize callback for the option. + * Settings API sanitize callback for the option. Keeps valid ability entries (name => allowed + * mode), "@group:" => mode and "@readonly:" => true; drops everything else. With + * $groupIds, only those group ids are accepted. * - * @return array + * @param list|null $groupIds Known group (integration) ids; null = any well-formed id. + * @return array */ - public static function sanitize(mixed $input): array + public static function sanitize(mixed $input, ?array $groupIds = null): array { if (! is_array($input)) { return []; } $clean = []; - foreach ($input as $name => $mode) { - if ( - is_string($name) - && is_string($mode) - && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $name) === 1 - && self::isAllowed($name, $mode) + foreach ($input as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (str_starts_with($key, self::GROUP_PREFIX)) { + if ( + self::isGroupId(substr($key, strlen(self::GROUP_PREFIX)), $groupIds) + && is_string($value) + && in_array($value, self::MODES, true) + ) { + $clean[$key] = $value; + } + } elseif (str_starts_with($key, self::READONLY_PREFIX)) { + if ( + self::isGroupId(substr($key, strlen(self::READONLY_PREFIX)), $groupIds) + && ($value === true || $value === '1' || $value === 1) + ) { + $clean[$key] = true; + } + } elseif ( + is_string($value) + && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $key) === 1 + && self::isAllowed($key, $value) ) { - $clean[$name] = $mode; + $clean[$key] = $value; } } ksort($clean); return $clean; } + + /** @param list|null $groupIds */ + private static function isGroupId(string $id, ?array $groupIds): bool + { + if (preg_match('#^[a-z0-9-]+$#', $id) !== 1) { + return false; + } + + return $groupIds === null || in_array($id, $groupIds, true); + } + + private function groupOf(string $name): ?string + { + if ($this->groups === null) { + $this->groups = $this->groupSource !== null ? ($this->groupSource)() : []; + } + + return $this->groups[$name] ?? null; + } + + private function groupModeOf(string $name): ?string + { + $group = $this->groupOf($name); + + return $group === null ? null : ($this->groupModes[$group] ?? null); + } } diff --git a/src/Admin/ToolsTab.php b/src/Admin/ToolsTab.php index 00b7aa4..c18b264 100644 --- a/src/Admin/ToolsTab.php +++ b/src/Admin/ToolsTab.php @@ -4,18 +4,24 @@ declare(strict_types=1); namespace LIT\MCP\Admin; +use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Integrations\IntegrationRegistry; /** - * Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4). Only the modes - * ExposurePolicy::isAllowed() allows for a given ability are offered; "Standard" saves nothing, - * so a later version's changed default takes effect automatically. + * Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4), grouped by the + * integration the ability comes from. Each group has its own mode (inherited by its tools) + * and a "nur lesen" switch. Only the modes ExposurePolicy::isAllowed() allows for a given + * ability are offered; "Standard"/"wie Gruppe" saves nothing, so a later version's changed + * default takes effect automatically. */ final class ToolsTab implements AdminTab { public const GROUP = 'lit_mcp_tools'; + /** More direct tools than this cost noticeable context in every claude.ai conversation. */ + public const DIRECT_WARNING_THRESHOLD = 25; + public function __construct(private IntegrationRegistry $registry, private ExposurePolicy $policy) { } @@ -34,11 +40,21 @@ final class ToolsTab implements AdminTab { register_setting(self::GROUP, ExposurePolicy::OPTION, [ 'type' => 'array', - 'sanitize_callback' => [ExposurePolicy::class, 'sanitize'], + 'sanitize_callback' => [$this, 'sanitize'], 'default' => [], ]); } + /** + * Sanitize callback: ExposurePolicy::sanitize() restricted to the known group ids. + * + * @return array + */ + public function sanitize(mixed $input): array + { + return ExposurePolicy::sanitize($input, $this->registry->ids()); + } + public function registerActions(): void { } @@ -64,50 +80,169 @@ final class ToolsTab implements AdminTab 'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.', 'wp-lit-mcp' ) . '

    '; + $policy = $this->policy->withGroups($this->registry->groupMap()); + $this->renderCounter(count($policy->directNames($this->registry->abilities()))); + echo '
    '; settings_fields(self::GROUP); - echo ''; - - foreach ($this->registry->abilities() as $ability) { - $name = $ability->name(); - $current = $this->policy->mode($ability); - $isDefault = $current === $this->policy->defaultMode($ability); - printf( - ''; + foreach ($this->registry->grouped() as $group => $abilities) { + $this->renderGroup($policy, $group, $abilities); } - - echo '
    ' . esc_html__('Tool', 'wp-lit-mcp') . '' - . esc_html__('Einstellung', 'wp-lit-mcp') . '
    %s
    %s
    '; submit_button(); echo '
    '; } + private function renderCounter(int $direct): void + { + echo '

    ' . esc_html(sprintf( + /* translators: %d: number of direct tools */ + _n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'), + $direct + )) . '

    '; + + if ($direct > self::DIRECT_WARNING_THRESHOLD) { + echo '

    ' . esc_html__('Achtung:', 'wp-lit-mcp') . ' ' + . esc_html__( + 'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.', + 'wp-lit-mcp' + ) . ' ' + . esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp') + . '

    '; + } + } + + /** @param list $abilities */ + private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void + { + $count = count($abilities); + $mixed = false; + foreach ($abilities as $ability) { + $mixed = $mixed || $policy->hasToolOverride($ability->name()); + } + + printf( + '
    %s', + esc_html(sprintf( + /* translators: 1: group label, 2: number of tools */ + _n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'), + $this->groupLabel($group), + $count + )) + ); + + $groupMode = $policy->groupMode($group); + printf( + '

    '; + printf( + '', + esc_attr(ExposurePolicy::OPTION), + esc_attr(ExposurePolicy::READONLY_PREFIX . $group), + checked($policy->isReadonly($group), true, false), + esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp') + ); + if ($mixed) { + echo ' ' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . ''; + } + echo '

    '; + + echo ''; + foreach ($abilities as $ability) { + $this->renderTool($policy, $ability, $groupMode !== null); + } + echo '
    ' . esc_html__('Tool', 'wp-lit-mcp') . '' + . esc_html__('Einstellung', 'wp-lit-mcp') . '
    '; + } + + private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void + { + $name = $ability->name(); + $own = $policy->toolOverride($name); + $locked = $policy->disabledByReadonly($ability); + $required = in_array($name, ExposurePolicy::REQUIRED, true); + + printf( + '%s
    %s'; + + if ($locked && $own !== null) { + // A disabled select is not submitted – keep the tool's own setting for later. + printf( + '', + esc_attr(ExposurePolicy::OPTION), + esc_attr($name), + esc_attr($own) + ); + } + + $note = match (true) { + $locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'), + $own === null && $policy->groupModeAdjusted($ability) + => __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'), + default => '', + }; + if ($note !== '') { + echo ' ' . esc_html($note) . ''; + } + echo ''; + } + + private function groupLabel(string $group): string + { + return match ($group) { + 'core' => __('Grundlagen', 'wp-lit-mcp'), + 'wordpress' => __('WordPress', 'wp-lit-mcp'), + 'media' => __('Medien', 'wp-lit-mcp'), + 'pods' => __('Pods', 'wp-lit-mcp'), + 'divi' => __('Divi', 'wp-lit-mcp'), + default => $group, + }; + } + private function modeLabel(string $mode): string { return match ($mode) { diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index d9f7341..68345e7 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -11,8 +11,8 @@ final class IntegrationRegistry /** @var list */ private array $integrations; - /** @var list|null */ - private ?array $abilities = null; + /** @var list}>|null Available integrations with their abilities. */ + private ?array $loaded = null; public function __construct(Integration ...$integrations) { @@ -31,15 +31,61 @@ final class IntegrationRegistry /** @return list */ public function abilities(): array { - if ($this->abilities === null) { - $abilities = []; - foreach ($this->available() as $integration) { - array_push($abilities, ...$integration->abilities()); - } - - $this->abilities = $abilities; + $abilities = []; + foreach ($this->loaded() as [, $own]) { + array_push($abilities, ...$own); } - return $this->abilities; + return $abilities; + } + + /** + * Abilities of the available integrations, grouped by integration id (registry order). + * + * @return array> + */ + public function grouped(): array + { + $groups = []; + foreach ($this->loaded() as [$integration, $own]) { + $groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own); + } + + return $groups; + } + + /** @return array Ability name => integration id (the ability's group). */ + public function groupMap(): array + { + $map = []; + foreach ($this->grouped() as $id => $abilities) { + foreach ($abilities as $ability) { + $map[$ability->name()] = $id; + } + } + + return $map; + } + + /** @return list Ids of all known integrations, available or not. */ + public function ids(): array + { + return array_values(array_unique(array_map( + static fn (Integration $integration): string => $integration->id(), + $this->integrations + ))); + } + + /** @return list}> */ + private function loaded(): array + { + if ($this->loaded === null) { + $this->loaded = array_map( + static fn (Integration $integration): array => [$integration, $integration->abilities()], + $this->available() + ); + } + + return $this->loaded; } } diff --git a/src/Plugin.php b/src/Plugin.php index eeb8a72..1b0e48b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -71,7 +71,7 @@ final class Plugin new DiviIntegration($schema, $changeLog) ); - $policy = ExposurePolicy::fromOption(); + $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar($registry, $policy))->register(); (new ServerRegistrar( $registry, diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index 6b96417..4837b58 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -217,4 +217,18 @@ final class AbilityRegistrarTest extends TestCase (new AbilityRegistrar(new IntegrationRegistry($integration), new ExposurePolicy(['lit/test' => 'disabled']))) ->registerAbilities(); } + + public function testGroupDisabledAbilitiesAreNotRegistered(): void + { + $ability = $this->ability(); + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn('divi'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn([$ability]); + $registry = new IntegrationRegistry($integration); + Functions\expect('wp_register_ability')->never(); + + (new AbilityRegistrar($registry, (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups($registry->groupMap()))) + ->registerAbilities(); + } } diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index e889cdb..d68f258 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -12,11 +12,15 @@ use Mockery; final class ExposurePolicyTest extends TestCase { - private function ability(string $name, bool $direct): Ability + /** @param array|null $annotations */ + private function ability(string $name, bool $direct, ?array $annotations = null): Ability { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn($name); $ability->allows('isDirect')->andReturn($direct); + if ($annotations !== null) { + $ability->allows('annotations')->andReturn($annotations); + } return $ability; } @@ -113,4 +117,155 @@ final class ExposurePolicyTest extends TestCase self::assertSame('direct', ExposurePolicy::fromOption()->mode($this->ability('lit/a', true))); } + + private const GROUPS = [ + 'lit/guide' => 'core', + 'lit/site-status' => 'core', + 'lit/divi-outline' => 'divi', + 'lit/divi-update' => 'divi', + 'lit/divi-delete' => 'divi', + 'lit/media-preview' => 'media', + 'lit/media-download' => 'media', + 'lit/media-get' => 'media', + ]; + + public function testToolOverrideBeatsGroupOverrideBeatsToolDefault(): void + { + $policy = (new ExposurePolicy([ + '@group:divi' => 'indirect', + 'lit/divi-update' => 'direct', + ]))->withGroups(self::GROUPS); + + self::assertSame('direct', $policy->mode($this->ability('lit/divi-update', false))); + self::assertSame('indirect', $policy->mode($this->ability('lit/divi-outline', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true))); + self::assertTrue($policy->hasToolOverride('lit/divi-update')); + self::assertFalse($policy->hasToolOverride('lit/divi-outline')); + self::assertSame('indirect', $policy->groupMode('divi')); + self::assertNull($policy->groupMode('core')); + self::assertSame('indirect', $policy->inheritedMode($this->ability('lit/divi-update', false))); + } + + public function testGroupSettingsAreIgnoredWithoutAGroupMap(): void + { + $policy = new ExposurePolicy(['@group:divi' => 'disabled', '@readonly:divi' => true]); + + self::assertSame('direct', $policy->mode($this->ability('lit/divi-outline', true))); + } + + public function testGuideKeepsItsDefaultWhateverTheGroupSays(): void + { + $policy = (new ExposurePolicy(['@group:core' => 'disabled', '@readonly:core' => true])) + ->withGroups(self::GROUPS); + + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true, []))); + self::assertSame('disabled', $policy->mode($this->ability('lit/site-status', true, ['readonly' => true]))); + } + + public function testDirectOnlyToolsTakeTheNearestAllowedModeWhenTheGroupSaysIndirect(): void + { + $policy = (new ExposurePolicy(['@group:media' => 'indirect', 'lit/media-download' => 'disabled'])) + ->withGroups(self::GROUPS); + + $preview = $this->ability('lit/media-preview', true); + self::assertSame('direct', $policy->mode($preview)); + self::assertTrue($policy->groupModeAdjusted($preview)); + self::assertSame('disabled', $policy->mode($this->ability('lit/media-download', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/media-get', true))); + self::assertFalse($policy->groupModeAdjusted($this->ability('lit/media-get', true))); + } + + public function testReadonlyGroupDisablesWriteToolsEvenAgainstAToolOverride(): void + { + $policy = (new ExposurePolicy([ + '@readonly:divi' => true, + '@group:divi' => 'direct', + 'lit/divi-update' => 'direct', + 'lit/divi-outline' => 'indirect', + ]))->withGroups(self::GROUPS); + + $update = $this->ability('lit/divi-update', false, ['readonly' => false, 'destructive' => false]); + $delete = $this->ability('lit/divi-delete', false, ['destructive' => true]); + $outline = $this->ability('lit/divi-outline', true, ['readonly' => true]); + + self::assertSame('disabled', $policy->mode($update)); + self::assertSame('disabled', $policy->mode($delete)); + self::assertTrue($policy->disabledByReadonly($update)); + self::assertSame('indirect', $policy->mode($outline)); + self::assertFalse($policy->disabledByReadonly($outline)); + self::assertTrue($policy->isReadonly('divi')); + self::assertFalse($policy->isReadonly('media')); + self::assertSame([$outline], $policy->enabled([$update, $delete, $outline])); + self::assertSame([], $policy->directNames([$update, $delete, $outline])); + } + + public function testSanitizeValidatesGroupEntries(): void + { + self::assertSame( + [ + '@group:divi' => 'indirect', + '@readonly:divi' => true, + '@readonly:media' => true, + 'lit/menu-list' => 'disabled', + ], + ExposurePolicy::sanitize([ + 'lit/menu-list' => 'disabled', + '@group:divi' => 'indirect', + '@group:media' => 'sometimes', + '@group:core' => '', + '@group:unknown' => 'direct', + '@group:' => 'direct', + '@group:' => 'direct', + '@readonly:divi' => '1', + '@readonly:media' => true, + '@readonly:core' => 'yes', + '@readonly:pods' => ['1'], + '@readonly:unknown' => '1', + '@other:divi' => 'direct', + '@group:divi/x' => 'direct', + 0 => 'direct', + ], ['core', 'divi', 'media', 'pods']) + ); + } + + public function testSanitizeWithoutKnownGroupsStillChecksTheIdSyntax(): void + { + self::assertSame( + ['@group:divi' => 'disabled', '@readonly:divi' => true], + ExposurePolicy::sanitize(['@group:divi' => 'disabled', '@readonly:divi' => true, '@group:a b' => 'direct']) + ); + } + + public function testOldFlatOptionStaysValid(): void + { + $old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct']; + + self::assertSame($old, ExposurePolicy::sanitize($old, ['core', 'wordpress'])); + } + + public function testFromOptionReadsGroupEntries(): void + { + Functions\when('get_option')->justReturn(['@group:divi' => 'disabled', 'lit/divi-update' => 'indirect']); + + $policy = ExposurePolicy::fromOption()->withGroups(self::GROUPS); + + self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/divi-update', false))); + } + + public function testGroupMapCanBeResolvedLazily(): void + { + $calls = 0; + $policy = (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups( + static function () use (&$calls): array { + $calls++; + return self::GROUPS; + } + ); + + self::assertSame(0, $calls, 'The map must not be built while booting (Divi is not loaded yet).'); + self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true))); + self::assertSame(1, $calls); + } } diff --git a/tests/Unit/Admin/ToolsTabTest.php b/tests/Unit/Admin/ToolsTabTest.php index 9ec0249..09f2cfd 100644 --- a/tests/Unit/Admin/ToolsTabTest.php +++ b/tests/Unit/Admin/ToolsTabTest.php @@ -15,53 +15,219 @@ use Mockery; final class ToolsTabTest extends TestCase { - private function registry(): IntegrationRegistry + /** @param list $abilities name, isDirect, readonly */ + private function integration(string $id, array $abilities, bool $available = true): Integration { - $abilities = []; - foreach ([['lit/guide', true], ['lit/media-preview', true], ['lit/menu-list', false]] as [$name, $direct]) { + $mocks = []; + foreach ($abilities as [$name, $direct, $readonly]) { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn($name); $ability->allows('label')->andReturn('Label ' . $name); $ability->allows('isDirect')->andReturn($direct); - $abilities[] = $ability; + $ability->allows('annotations')->andReturn(['readonly' => $readonly]); + $mocks[] = $ability; } $integration = Mockery::mock(Integration::class); - $integration->allows('isAvailable')->andReturn(true); - $integration->allows('abilities')->andReturn($abilities); + $integration->allows('id')->andReturn($id); + $integration->allows('isAvailable')->andReturn($available); + $integration->allows('abilities')->andReturn($mocks); - return new IntegrationRegistry($integration); + return $integration; } - public function testRegistersTheExposureOptionWithThePolicySanitizer(): void + private function registry(): IntegrationRegistry { - Functions\expect('register_setting')->once()->with( - 'lit_mcp_tools', - 'lit_mcp_exposure', - Mockery::on(static fn (array $args): bool => $args['sanitize_callback'] === [ExposurePolicy::class, 'sanitize']) + return new IntegrationRegistry( + $this->integration('core', [['lit/guide', true, true], ['lit/site-status', true, true]]), + $this->integration('media', [['lit/media-preview', true, true], ['lit/media-upload', false, false]]), + $this->integration('divi', [['lit/divi-outline', true, true], ['lit/divi-update', true, false]]), + $this->integration('pods', [['lit/pods-x', true, true]], false) ); - - (new ToolsTab($this->registry(), new ExposurePolicy()))->registerSettings(); } - public function testRendersOneSelectPerAbilityWithOnlyAllowedModes(): void + /** @param array $option */ + private function render(array $option, ?IntegrationRegistry $registry = null): string { Functions\stubEscapeFunctions(); Functions\stubTranslationFunctions(); Functions\when('settings_fields')->justReturn(null); Functions\when('submit_button')->justReturn(null); - Functions\when('selected')->alias(static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''); + Functions\when('selected')->alias( + static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : '' + ); + Functions\when('checked')->alias( + static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' checked="checked"' : '' + ); ob_start(); - (new ToolsTab($this->registry(), new ExposurePolicy(['lit/menu-list' => 'disabled'])))->render(); - $html = (string) ob_get_clean(); + (new ToolsTab($registry ?? $this->registry(), new ExposurePolicy($option)))->render(); - $selects = array_slice(explode('', $start) - $start); + } + + /** The table row of one tool (from its to ). */ + private function row(string $html, string $name): string + { + $at = strpos($html, '' . $name . ''); + self::assertNotFalse($at, 'No row for ' . $name); + $start = (int) strrpos(substr($html, 0, $at), '', $at) - $start); + } + + /** The
    block of one group. */ + private function group(string $html, string $label): string + { + $at = strpos($html, '' . $label); + self::assertNotFalse($at, 'No group ' . $label); + $start = (int) strrpos(substr($html, 0, $at), '', $at) - $start); + } + + public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void + { + $tab = new ToolsTab($this->registry(), new ExposurePolicy()); + $callback = null; + Functions\expect('register_setting')->once()->with( + 'lit_mcp_tools', + 'lit_mcp_exposure', + Mockery::on(static function (array $args) use (&$callback): bool { + $callback = $args['sanitize_callback']; + return true; + }) + ); + + $tab->registerSettings(); + + self::assertSame([$tab, 'sanitize'], $callback); + self::assertSame( + ['@group:pods' => 'disabled', '@readonly:divi' => true, 'lit/menu-list' => 'disabled'], + $tab->sanitize([ + '@readonly:divi' => '1', + '@group:pods' => 'disabled', + '@group:shop' => 'direct', + 'lit/menu-list' => 'disabled', + 'lit/guide' => 'disabled', + ]) + ); + } + + public function testGroupsToolsByAvailableIntegrationWithOnlyAllowedModes(): void + { + $html = $this->render(['lit/divi-update' => 'disabled']); + + self::assertSame(3, substr_count($html, '
    Grundlagen (2 Tools)', $html); + self::assertStringContainsString('Medien (2 Tools)', $html); + self::assertStringContainsString('Divi (2 Tools)', $html); + self::assertStringNotContainsString('pods', $html); + self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi')); + self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi')); + + $groupSelect = $this->select($html, '@group:divi'); + self::assertStringContainsString('', $groupSelect); + foreach (['direct', 'indirect', 'disabled'] as $mode) { + self::assertStringContainsString('value="' . $mode . '"', $groupSelect); + } + self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html); + self::assertStringContainsString('nur lesen (schreibende Tools dieser Gruppe aus)', $html); + + $guide = $this->select($html, 'lit/guide'); self::assertStringNotContainsString('value="disabled"', $guide); - self::assertStringNotContainsString('value="indirect"', $preview); - self::assertStringContainsString('value="disabled" selected="selected"', $menu); - self::assertStringContainsString('value="" selected="selected"', $guide); + self::assertStringNotContainsString('value="indirect"', $guide); + self::assertStringContainsString('value="" selected="selected">Standard (direkt)', $guide); + self::assertStringNotContainsString('value="indirect"', $this->select($html, 'lit/media-preview')); + self::assertStringContainsString('value="disabled" selected="selected"', $this->select($html, 'lit/divi-update')); + } + + public function testToolsOfAGroupWithOverrideOfferWieGruppeAndTheGroupSaysGemischt(): void + { + $html = $this->render(['@group:divi' => 'indirect', 'lit/divi-update' => 'direct']); + + self::assertStringContainsString('value="indirect" selected="selected"', $this->select($html, '@group:divi')); + self::assertStringContainsString( + '', + $this->select($html, 'lit/divi-outline') + ); + $update = $this->select($html, 'lit/divi-update'); + self::assertStringContainsString('', $update); + self::assertStringContainsString('value="direct" selected="selected"', $update); + self::assertStringContainsString('gemischt', $this->group($html, 'Divi')); + self::assertStringNotContainsString('gemischt', $this->group($html, 'Medien')); + self::assertStringContainsString('Standard (direkt)', $this->select($html, 'lit/site-status')); + } + + public function testDirectOnlyToolsExplainThatIndirectDoesNotApply(): void + { + $html = $this->render(['@group:media' => 'indirect']); + + self::assertStringContainsString('wie Gruppe (direkt)', $this->select($html, 'lit/media-preview')); + self::assertStringContainsString('„indirekt“ geht hier nicht', $this->row($html, 'lit/media-preview')); + self::assertStringNotContainsString('geht hier nicht', $this->row($html, 'lit/media-upload')); + } + + public function testReadonlyDisablesWriteToolSelectsButKeepsTheirOverride(): void + { + $html = $this->render(['@readonly:divi' => true, 'lit/divi-update' => 'indirect']); + + self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html); + $update = $this->row($html, 'lit/divi-update'); + self::assertStringContainsString('', true], + ]; + } + + /** @dataProvider probes */ + public function testKnowsWhatIsText(string $marked, bool $text): void + { + $start = (int) strpos($marked, '[['); + $html = str_replace(['[[', ']]'], '', $marked); + $end = (int) strpos($marked, ']]') - 2; + + $spans = HtmlContext::markupSpans($html); + + self::assertSame($text, HtmlContext::isText($spans, $start, $end), $marked); + } + + public function testBoundaries(): void + { + $spans = HtmlContext::markupSpans('

    ab

    '); + + self::assertSame([[0, 3], [5, 9]], $spans); + self::assertTrue(HtmlContext::isBoundary($spans, 3)); + self::assertTrue(HtmlContext::isBoundary($spans, 5)); + self::assertFalse(HtmlContext::isBoundary($spans, 1)); + } + + public function testLongInputsStayLinear(): void + { + $start = microtime(true); + foreach (['<', '" inside a quoted attribute value does not end the tag, so nothing after it + * in the tag is text. With the old "<[^>]*>" split these tests fail (the rest of the tag + * was searched), which is how a reverted tokenizer shows. + */ + public function testQuotedGreaterThanDoesNotEndTheTag(): void + { + $double = 'Kontakt und Kontakt'; + $single = "x > Kontakt Kontakt"; + + $result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($double), self::same()); + $image = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($single), self::same()); + + self::assertSame('Anfrage und Anfrage', self::valueOf($result['attrs'])); + self::assertSame(2, $result['count']); + self::assertSame("x > Kontakt Anfrage", self::valueOf($image['attrs'])); + } + + public function testCommentsAreNoText(): void + { + $value = "

    Kontakt

    Kontakt"; + + $result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same()); + + self::assertSame("

    Anfrage

    Anfrage", self::valueOf($result['attrs'])); + } + + /** More raw-text elements; a closing tag must really close ("" does not). */ + public function testRawTextBodiesOfAllKinds(): void + { + $value = '' + . ''; + + $result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same()); + + self::assertSame(1, $result['count']); + self::assertStringEndsWith('', (string) self::valueOf($result['attrs'])); + } + + /** Search == replacement (or a cleaned replacement equal to the match) is no change and no hit. */ + public function testNoEffectiveChangeIsNoHit(): void + { + $same = (new TextReplacer('Müller', 'Müller'))->apply(self::ATTRS, self::same()); + $cleanedAway = (new TextReplacer('Müller', 'Müller'))->apply( + self::ATTRS, + static fn (string $p, ?string $k, string $v): string => strip_tags($v) + ); + $partly = (new TextReplacer('müller', 'Müller', false))->apply(self::ATTRS, self::same()); + + self::assertSame(0, $same['count']); + self::assertSame(self::ATTRS, $same['attrs']); + self::assertSame(0, $cleanedAway['count']); + self::assertSame(self::ATTRS, $cleanedAway['attrs']); + self::assertSame(1, $partly['count'], 'only MÜLLER changes'); + } + + /** + * Ruling S45 malicious rows: either refused (exception) or no change at all. + * + * @return array + */ + public static function s45(): array + { + return [ + 'double-quoted ">" then handler' => ['Link', ' B', '" onmouseover="alert(1)'], + 'single-quoted ">" then handler' => ["A > B", ' B', "' onerror='alert(1)"], + 'href rewrite after quoted ">"' => ['Link', 'kontakt', 'javascript:alert(1)'], + 'comment with ">"' => ['

    x

    ', ' B', '-->'], + 'comment closed by --!>' => ['

    x

    ', ' B', 'x'], + 'unterminated comment' => ['

    x B

    ', ' B', 'y'], + 'unterminated reference' => ['$variable({"type":"content"} B', ' B', ')$'], + ]; + } + + /** @dataProvider s45 */ + public function testS45RowsAreRefusedOrUnchanged(string $value, string $search, string $replace): void + { + try { + $result = (new TextReplacer($search, $replace))->apply(self::text($value), self::same()); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('content.innerContent', $e->getMessage()); + + return; + } + + self::assertSame(0, $result['count']); + self::assertSame($value, self::valueOf($result['attrs'])); + } + + /** @return array */ + public static function longInputs(): array + { + $n = 200000; + + return [ + 'many "<"' => [str_repeat('<', $n) . ' Kontakt'], + 'many " [str_repeat(' [str_repeat(' ['Müller', "\$variable\x0B(", 'replace'], 'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'], + 'NUL in search' => ["Mül\0ler", 'x', 'search'], + 'VT in search' => ["Mül\x0Bler", 'x', 'search'], 'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'], ]; } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php index 9269ac8..5d7247f 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php @@ -394,6 +394,141 @@ final class DiviReplaceTextAbilityTest extends TestCase self::assertSame([], $dry['matches'], 'the dry run shows no hit either'); } + /** + * Ruling S45 end to end (the live stored XSS): a ">" inside a quoted attribute, comments, + * unterminated tags and "<" in text – no stored change, error or no-op. + * + * @return array + */ + public static function s45(): array + { + return [ + 'double quotes' => ['

    Kontakt

    ', ' B', '" onmouseover="alert(1)'], + 'single quotes' => ["

    A > B

    ", ' B', "' onerror='alert(1)"], + 'href after a quoted ">"' => ['

    Link

    ', 'kontakt', 'javascript:alert(1)'], + 'comment' => ['

    x

    ', ' B', '-->'], + 'unterminated tag' => ['

    ok B

    B B', ' B', 'y'], + 'lone "<" in text' => ['a < B', ' B', 'img src=x onerror=alert(1)>'], + '"<" + letter in text' => ['asaved = []; + $ability = $this->ability(self::page([self::text($html)]), $unfiltered); + + $dry = $ability->execute(['search' => $search, 'replace' => $replace]); + $result = $this->replaceFor($ability, ['search' => $search, 'replace' => $replace]); + + self::assertSame([], $this->saved); + $entry = $result['results'][0]; + self::assertTrue(! $entry['ok'] || $entry['count'] === 0, (string) json_encode($entry)); + self::assertSame([], array_filter($dry['matches'], static fn (array $match): bool => $match['count'] > 0)); + } + } + + /** Global modules are never changed – but reported, also when they hold the only hits. */ + public function testGlobalModulesAreReportedNotChanged(): void + { + $global = ['divi/text', ['globalModule' => '123', 'content' => ['innerContent' => ['desktop' => ['value' => '

    Müller

    ']]]]]; + $ability = $this->ability(self::page([$global, self::text('

    Meier

    ')])); + + $dry = $ability->execute(['search' => 'müller', 'replace' => 'Schmidt', 'case_sensitive' => false]); + $result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']); + + $node = ['post_id' => 7, 'id' => 's0.r0.c0.g0', 'reason' => 'globales Modul – in der Bibliothek bearbeiten']; + self::assertSame([$node], $dry['skipped_nodes']); + self::assertSame(7, $dry['matches'][0]['post_id'], 'the page does not vanish'); + self::assertSame(0, $dry['matches'][0]['count']); + self::assertSame(1, $dry['matches'][0]['skipped_nodes']); + self::assertStringContainsString('globale Module', $dry['summary']); + self::assertSame([$node], $result['skipped_nodes']); + self::assertSame(0, $result['results'][0]['count']); + self::assertStringContainsString('Knoten s0.r0.c0.g0', implode("\n", $result['warnings'])); + self::assertSame([], $this->saved); + } + + /** No effective change (search == replacement, cleaned replacement == match): nothing saved. */ + public function testNoEffectiveChangeSavesNothing(): void + { + $ability = $this->ability(self::page([self::heading('Müller'), self::text('

    Müller

    ')])); + + $same = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Müller']); + $caseOnly = $this->replaceFor($ability, ['search' => 'müller', 'replace' => 'Müller', 'case_sensitive' => false]); + $dry = $ability->execute(['search' => 'Müller', 'replace' => 'Müller']); + + self::assertSame(0, $same['results'][0]['count']); + self::assertSame(0, $caseOnly['results'][0]['count']); + self::assertArrayNotHasKey('revision_id', $same['results'][0]); + self::assertSame([], $dry['matches']); + self::assertSame([], $this->saved); + self::assertSame([], $same['changed']); + } + + public function testDescriptionMentionsTrimming(): void + { + self::assertStringContainsString('Leerzeichen am Anfang und Ende', $this->ability()->description()); + } + + public function testControlCharactersAndEmptySearch(): void + { + $ability = $this->ability(); + + foreach (['', "Ter\0min", "Ter\x0Bmin"] as $search) { + $error = $ability->execute(['search' => $search, 'replace' => 'X']); + self::assertSame('lit_validation_failed', $error->get_error_code(), json_encode($search)); + self::assertArrayHasKey('search', self::clientError($error)['fields']); + } + + $result = $this->replaceFor($ability, ['search' => 'Termin', 'replace' => "R\0\x0Bück\x7Fruf"]); + + self::assertTrue($result['results'][0]['ok'], (string) json_encode($result)); + self::assertSame(['text' => 'Rückruf buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'off'], $this->savedValue('s0.r0.c1.m2', 'button.innerContent')); + self::assertSame([], $this->queries); + } + + public function testCursorAndLimit(): void + { + $ability = $this->ability(null, false, [7, 8, 9]); + + $first = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 2]); + $second = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 2, 'cursor' => $first['next_cursor']]); + $invalid = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'cursor' => 'kaputt']); + $capped = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 5000]); + + self::assertCount(2, $first['matches']); + self::assertStringContainsString('LIMIT 3 OFFSET 0', $this->queries[0]); + self::assertStringContainsString('LIMIT 3 OFFSET 2', $this->queries[1]); + self::assertIsString($second['next_cursor'], 'the fake query returns three rows again'); + self::assertSame('lit_validation_failed', $invalid->get_error_code()); + self::assertStringContainsString('LIMIT 101 OFFSET 0', $this->queries[2]); + self::assertCount(3, $capped['matches']); + } + + /** Byte identity on a page whose other blocks use dynamic content names outside the allow map. */ + public function testByteIdentityNextToUnknownDynamicContent(): void + { + $odd = self::text('

    ' . '$variable({"type":"content","value":{"name":"acf_field_x","settings":{"before":""}}})$' . '

    '); + $odd[1]['module'] = ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$']]]]]; + $hit = self::heading('Willkommen bei Müller'); + $ability = $this->ability(self::page([$odd, $hit, $odd])); + $original = (string) $this->post->post_content; + + $result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']); + + self::assertTrue($result['results'][0]['ok'], (string) json_encode($result)); + $changed = $hit; + $changed[1]['title']['innerContent']['desktop']['value'] = 'Willkommen bei Schmidt'; + self::assertSame( + str_replace(Serializer::attributes($hit[1]), Serializer::attributes($changed[1]), $original), + $this->saved[0] + ); + } + /** Ruling S10: an unexpected error on one page is logged, reported neutrally, and the batch goes on. */ public function testAnUnexpectedErrorOnOnePageDoesNotStopTheBatch(): void { From 5965e32acb30fe36146780602daae83ecc36ff5a Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 16:46:50 +0200 Subject: [PATCH 129/185] feat: lit/content-create with Divi short form New input divi (1-30 sections, short form like lit-divi-insert) builds a Divi 5 layout in one divi/placeholder. The whole description is checked before the insert (Divi's map for "no entry yet"); the draft is inserted with its normal WordPress fields only, then DiviContentComposer composes again bound to the new entry and stores it via DiviSaver::saveNew (same kses window as save(), strict DiviFlags). A refusal or failure deletes the draft. Result adds tree_rev and the outline. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/DiviIntegration.php | 14 + .../Divi/Pages/DiviContentComposer.php | 119 ++++++++ src/Integrations/Divi/Pages/DiviSaver.php | 31 +- .../WordPress/Content/ContentComposer.php | 42 +++ .../Content/ContentCreateAbility.php | 139 +++++++-- .../WordPress/WordPressIntegration.php | 11 +- src/Plugin.php | 5 +- .../Integrations/Divi/DiviIntegrationTest.php | 11 + .../Divi/Pages/DiviContentComposerTest.php | 153 ++++++++++ .../Integrations/Divi/Pages/DiviSaverTest.php | 55 ++++ .../Content/ContentCreateDiviTest.php | 283 ++++++++++++++++++ .../WordPress/WordPressIntegrationTest.php | 21 ++ 12 files changed, 861 insertions(+), 23 deletions(-) create mode 100644 src/Integrations/Divi/Pages/DiviContentComposer.php create mode 100644 src/Integrations/WordPress/Content/ContentComposer.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php create mode 100644 tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 9174bb8..ad5b9ca 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -8,6 +8,7 @@ use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; @@ -22,12 +23,14 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; /** * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one * DiviWriter; each binds the composition to its entry via DiviWriter::composition(). + * contentComposer() gives lit/content-create the Divi short form (spec §6.2). */ final class DiviIntegration implements Integration { @@ -50,6 +53,17 @@ final class DiviIntegration implements Integration return DiviModules::isAvailable(); } + /** Divi short form for lit/content-create; available() reports whether Divi 5 is loaded. */ + public function contentComposer(): ContentComposer + { + return new DiviContentComposer( + $this->schema, + new DiviComposition($this->modules, $this->design), + new DiviSaver(), + $this->design + ); + } + public function abilities(): array { $loader = new DiviPageLoader($this->schema); diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php new file mode 100644 index 0000000..5c39cd7 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -0,0 +1,119 @@ +schema->diviEnabled($postType)) { + return Errors::invalidInput( + 'Für diesen Inhaltstyp ist der Divi-Builder nicht aktiviert; nichts wurde angelegt.', + ['divi' => sprintf('Divi-Builder für "%s" nicht aktiv', $postType)] + ); + } + + $layout = $this->layout($this->composition->forPost(0), $sections); + + return $layout instanceof WP_Error ? $layout : null; + } + + public function write(int $postId, string $postType, array $sections): array|WP_Error + { + $composition = $this->composition->forPost($postId); + $layout = $this->layout($composition, $sections); + if ($layout instanceof WP_Error) { + return $layout; + } + + $index = NodeIndex::build($layout['document']); + $warnings = $composition->warnings()->collect($index, $layout['blocks']); + $outline = new Outline($this->design->read()->presetNames()); + $text = $outline->text($outline->rows($index)); + if ($outline->wasTruncated()) { + $warnings[] = DiviWriter::OUTLINE_TRUNCATED; + } + + $saved = $this->saver->saveNew($postId, Serializer::document($layout['document'])); + + return ['warnings' => array_merge($warnings, $saved), 'outline' => $text]; + } + + /** + * The whole description composed and inserted into an empty document – or every error. + * + * @param list $sections + * @return array{document: Document, blocks: list}|WP_Error + */ + private function layout(DiviComposition $composition, array $sections): array|WP_Error + { + if ($sections === []) { + return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [ + 'divi' => 'mindestens ein Abschnitt', + ]); + } + + $composed = $composition->composer()->compose(array_values($sections), null, 'divi'); + if ($composed['errors'] !== []) { + return $composed['invalid_paths'] !== [] + ? Errors::invalidAttrPath($composed['errors']) + : Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']); + } + + $document = new Document(); + $violation = $composition->editor()->insert( + ['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX], + $composed['blocks'] + ); + if ($violation !== null) { + return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [ + 'divi' => $violation, + ]); + } + + return ['document' => $document, 'blocks' => $composed['blocks']]; + } +} diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 2736367..0ee0c78 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -36,7 +36,8 @@ use WP_Post; * transition_post_status, wp_after_insert_post and third-party listeners. No MCP input reaches * those hooks except the content itself (already cleaned); anything a third-party hook writes * there is saved without kses, as it would be for an administrator. The rollback write in - * restore() uses the same window with the previous stored content. + * restore() uses the same window with the previous stored content; saveNew() (the first layout + * of a new draft, lit/content-create) uses it for its single wp_update_post(). * * Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth): * Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data @@ -116,6 +117,34 @@ class DiviSaver return ['revision_id' => $revisionId, 'warnings' => $warnings]; } + /** + * The first content of a brand-new draft (lit/content-create with divi, which inserted the + * draft with its normal WordPress fields – title and excerpt went through kses as usual). + * Same kses window as save(), holding only this wp_update_post() and its hooks; no restore + * point (the draft has no earlier state) and no static CSS to clear. The Divi flags are part + * of the write here: without them the new entry is no Divi layout. Every failure throws – + * the caller deletes the draft (all or nothing). + * + * @return list Warnings (CHANGED_ON_SAVE). + * @throws RuntimeException + */ + public function saveNew(int $postId, string $content): array + { + $result = $this->withoutKses( + static fn (): int|WP_Error => wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true) + ); + if ($result instanceof WP_Error || $result === 0) { + $message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0'; + throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $message)); + } + + DiviFlags::set($postId); + + $stored = get_post($postId); + + return $stored instanceof WP_Post && (string) $stored->post_content !== $content ? [self::CHANGED_ON_SAVE] : []; + } + /** * Runs $write with kses' filters off for users without unfiltered_html (D7); restored in * finally, right when $write returns or throws. diff --git a/src/Integrations/WordPress/Content/ContentComposer.php b/src/Integrations/WordPress/Content/ContentComposer.php new file mode 100644 index 0000000..df6cddb --- /dev/null +++ b/src/Integrations/WordPress/Content/ContentComposer.php @@ -0,0 +1,42 @@ + $sections + * @return WP_Error|null Every problem of the description; null = valid. + */ + public function check(string $postType, array $sections): ?WP_Error; + + /** + * Composes the description for the new draft $postId and stores it. A WP_Error means the + * description is invalid for this entry (nothing was written) – like a throw, it makes + * lit/content-create delete the draft again. + * + * @param list $sections + * @return array{warnings: list, outline: ?string}|WP_Error + * @throws Throwable When storing failed. + */ + public function write(int $postId, string $postType, array $sections): array|WP_Error; +} diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index 8bb622c..3211cac 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; @@ -22,6 +23,11 @@ use WP_Post; * afterwards, created terms and the brand-new draft are deleted again and a WriteFailed * is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup * failed or the insert itself threw; partial writes are logged with result "partial"). + * + * Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole + * description before the insert; the draft is inserted with its normal WordPress fields only + * (title/excerpt keep kses), then the composer stores the layout (DiviSaver's kses window, spec + * D7) – a refusal or failure there deletes the draft again. */ final class ContentCreateAbility implements Ability { @@ -31,7 +37,8 @@ final class ContentCreateAbility implements Ability public function __construct( private ContentSchema $schema, private PostWriter $writer, - private ChangeLog $log + private ChangeLog $log, + private ?ContentComposer $divi = null ) { } @@ -49,7 +56,9 @@ final class ContentCreateAbility implements Ability { return 'Legt einen neuen Eintrag an – immer als Entwurf (veröffentlichen nur über lit-post-publish). ' . 'Kerndaten, Begriffe, Felder (gegen das Schema aus lit-content-types geprüft) und Beitragsbild ' - . 'in einem Schritt; bei einem Fehler wird nichts angelegt.'; + . 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste ' + . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content; das Ergebnis enthält ' + . 'dann tree_rev für die lit-divi-* Werkzeuge.'; } public function inputSchema(): array @@ -59,6 +68,14 @@ final class ContentCreateAbility implements Ability 'properties' => array_merge([ 'post_type' => ['type' => 'string'], 'title' => ['type' => 'string', 'minLength' => 1], + 'divi' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 30, + 'items' => ['type' => 'object'], + 'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit ' + . 'content). Nur wenn Divi 5 aktiv und der Divi-Builder für den Inhaltstyp eingeschaltet ist.', + ], ], PostWriter::inputProperties()), 'required' => ['post_type', 'title'], 'additionalProperties' => false, @@ -67,7 +84,7 @@ final class ContentCreateAbility implements Ability public function outputSchema(): ?array { - return ToolResult::schema(); + return ToolResult::schema(['tree_rev' => ['type' => 'string']]); } public function annotations(): array @@ -95,9 +112,18 @@ final class ContentCreateAbility implements Ability } $postType = (string) $input['post_type']; + $withDivi = array_key_exists('divi', $input); + if ($withDivi && ($this->divi === null || ! $this->divi->available())) { + return Errors::unsupported( + 'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.', + 'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.' + ); + } + $prepared = $this->writer->prepare($postType, $input, true); - if ($prepared instanceof WP_Error) { - return $prepared; + $problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null); + if ($problem !== null) { + return $problem; } $data = array_merge($this->writer->postData($input), [ @@ -123,21 +149,89 @@ final class ContentCreateAbility implements Ability return $this->refuse($postId, $postType, $denied); } + $layout = null; + try { + $layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null; + } catch (Throwable $e) { + $this->discard($postId, $postType, $e); + } + if ($layout instanceof WP_Error) { + return $this->refuse($postId, $postType, $layout); + } + try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { $this->discard($postId, $postType, $e); } - return $this->result($postId, $postType, $input, $prepared['warnings']); + $warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []); + + return $this->result($postId, $postType, $input, $warnings, $layout['outline'] ?? null); + } + + /** + * The Divi description, checked completely before anything is created (spec §6.2). + * + * @param array $input + */ + private function checkDivi(string $postType, array $input): ?WP_Error + { + if (array_key_exists('content', $input)) { + return Errors::invalidInput('content und divi schließen sich aus; nichts wurde angelegt.', [ + 'content' => 'bei Divi-Inhalt weglassen', + ]); + } + + return $this->divi?->check($postType, self::sections($input)); + } + + /** + * @param array $input + * @return list + */ + private static function sections(array $input): array + { + return is_array($input['divi'] ?? null) ? array_values($input['divi']) : []; + } + + /** + * One error with the fields of both checks (all or nothing: every problem comes back at once); + * invalid_attr_path wins, so its suggestions stay visible. Null when both passed. + * + * @param array|WP_Error $prepared + */ + private static function combined(array|WP_Error $prepared, ?WP_Error $divi): ?WP_Error + { + if (! $prepared instanceof WP_Error || $divi === null) { + return $prepared instanceof WP_Error ? $prepared : $divi; + } + + $fields = array_merge(self::fields($prepared), self::fields($divi)); + if ($fields === []) { + return $prepared; + } + + return $divi->get_error_code() === 'lit_invalid_attr_path' + ? Errors::invalidAttrPath($fields) + : Errors::invalidInput('Eingaben sind ungültig; nichts wurde angelegt.', $fields); + } + + /** @return array */ + private static function fields(WP_Error $error): array + { + $data = $error->get_error_data(); + + return is_array($data) && is_array($data['fields'] ?? null) ? $data['fields'] : []; } /** * @param array $input * @param list $warnings + * @param string|null $outline Divi outline of the new layout (with divi only). * @return array */ - private function result(int $postId, string $postType, array $input, array $warnings): array + private function result(int $postId, string $postType, array $input, array $warnings, ?string $outline): array { // The draft is saved – failures from here on are logged and reported as warnings only. $change = (new PostChangeResult($this->name(), $this->log, $postId, 'der Entwurf', $warnings)) @@ -146,10 +240,16 @@ final class ContentCreateAbility implements Ability $title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title']; $change->log($postType, sprintf('Entwurf "%s" angelegt', $title)); - return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) + $result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) ->changed((string) $postId, $postType, 'created', $title) - ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) - ->toArray(); + ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')); + if (array_key_exists('divi', $input)) { + // The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away. + $result->outline($outline) + ->with('tree_rev', Rev::of($post instanceof WP_Post ? (string) $post->post_content : '')); + } + + return $result->toArray(); } /** @@ -193,13 +293,14 @@ final class ContentCreateAbility implements Ability } /** - * Meta rights can only be checked once the draft exists (add_post_meta needs its ID): a - * refused field removes the brand-new draft again and is reported like any invalid input. + * Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout + * against the new entry's own dynamic content options. A refusal removes the brand-new draft + * again and is reported like any invalid input. * - * @param array $denied fields. => error + * @param array|WP_Error $denied fields. => error, or the error itself. * @throws WriteFailed When the draft cannot be deleted. */ - private function refuse(int $postId, string $postType, array $denied): WP_Error + private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error { try { $deleted = (bool) wp_delete_post($postId, true); @@ -210,17 +311,21 @@ final class ContentCreateAbility implements Ability if (! $deleted) { $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); $this->logPartial($postType, (string) $postId, $summary); + $fields = $denied instanceof WP_Error ? self::fields($denied) : $denied; throw new WriteFailed( - 'Keine Berechtigung für ' . implode(', ', array_keys($denied)) . '.', + ($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ') + . implode(', ', array_keys($fields)) . '.', false, $postId, null, null, - sprintf('Meta rights denied for new post %d; draft could not be deleted', $postId) + sprintf('New post %d refused after the insert; draft could not be deleted', $postId) ); } - return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); + return $denied instanceof WP_Error + ? $denied + : Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); } private function logPartial(string $postType, string $postId, string $summary): void diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index 3e6e31b..d440821 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility; use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility; @@ -45,8 +46,12 @@ use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; */ final class WordPressIntegration implements Integration { - public function __construct(private ContentSchema $schema, private ChangeLog $log) - { + /** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */ + public function __construct( + private ContentSchema $schema, + private ChangeLog $log, + private ?ContentComposer $divi = null + ) { } public function id(): string @@ -70,7 +75,7 @@ final class WordPressIntegration implements Integration new ContentTypesAbility($this->schema), new ContentQueryAbility($this->schema, $presenter), new ContentGetAbility($this->schema, $presenter), - new ContentCreateAbility($this->schema, $writer, $this->log), + new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi), new ContentUpdateAbility($this->schema, $writer, $this->log), new PostPublishAbility($this->schema, $this->log), new PostUnpublishAbility($this->schema, $this->log), diff --git a/src/Plugin.php b/src/Plugin.php index 162fd13..1d8a7ba 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -66,11 +66,12 @@ final class Plugin (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); + $divi = new DiviIntegration($schema, $changeLog); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), - new WordPressIntegration($schema, $changeLog), + new WordPressIntegration($schema, $changeLog, $divi->contentComposer()), new MediaIntegration($schema, $changeLog, $limits), - new DiviIntegration($schema, $changeLog) + $divi ); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 81fecd7..004e343 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi; use LIT\MCP\Abilities\Ability; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\DiviIntegration; +use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -44,4 +45,14 @@ final class DiviIntegrationTest extends TestCase self::assertNotNull($ability->outputSchema(), $ability->name()); } } + + public function testGivesContentCreateTheDiviShortForm(): void + { + $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class)); + + $composer = $integration->contentComposer(); + + self::assertInstanceOf(DiviContentComposer::class, $composer); + self::assertFalse($composer->available(), 'no Divi classes in unit tests'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php new file mode 100644 index 0000000..7272a4c --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php @@ -0,0 +1,153 @@ + Post IDs the dynamic content registry was asked for. */ + private array $registryCalls = []; + + /** @var array Content handed to DiviSaver::saveNew() by post ID. */ + private array $saved = []; + + /** @var DiviSaver&MockInterface */ + private DiviSaver $saver; + + protected function setUp(): void + { + parent::setUp(); + $this->saver = Mockery::mock(DiviSaver::class); + $this->saver->allows('saveNew')->andReturnUsing(function (int $postId, string $content): array { + $this->saved[$postId] = $content; + + return []; + }); + } + + /** + * @param (callable(int): array)|null $registry Divi's dynamic content options per post ID. + */ + private function composer(bool $diviEnabled = true, ?callable $registry = null): DiviContentComposer + { + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $map = new DynamicContentMap(function (int $postId) use ($registry): array { + $this->registryCalls[] = $postId; + + return $registry !== null ? $registry($postId) : DynamicContentMapTest::registry(); + }); + + return new DiviContentComposer( + DiviPageLoaderTest::schema($diviEnabled), + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $map), + $this->saver, + $design + ); + } + + /** @return list> */ + private static function sections(): array + { + return [ + ['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'Willkommen']]], + ['type' => 'section', 'modules' => [['type' => 'image', 'src' => 'https://kunde.test/a.jpg']]], + ]; + } + + public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void + { + self::assertNull($this->composer()->check('page', self::sections())); + self::assertSame([0], $this->registryCalls, 'Divi\'s options for "no entry yet" (post ID 0)'); + self::assertSame([], $this->saved, 'check writes nothing'); + } + + public function testWritesAPlaceholderWrappedLayoutBoundToTheNewEntry(): void + { + $written = $this->composer()->write(42, 'page', self::sections()); + + self::assertIsArray($written); + self::assertSame([42], $this->registryCalls, 'the final layout uses the live map of the new entry'); + $content = $this->saved[42]; + // Ruling S9: exactly one divi/placeholder around the sections, as TreeEditor does for empty pages. + self::assertStringStartsWith('', $content); + self::assertSame(1, substr_count($content, '')); + $document = (new Parser())->parse($content); + self::assertCount(1, $document->children()); + self::assertCount(2, $document->children()[0]->children()); + self::assertContains('s1.r0.c0.m0: Bild ohne Alt-Text.', $written['warnings']); + self::assertStringContainsString('s0 section "Hero"', (string) $written['outline']); + } + + public function testRefusesTypesWithoutBuilderAndInvalidNodes(): void + { + $disabled = $this->composer(false)->check('post', [['type' => 'section']]); + self::assertInstanceOf(WP_Error::class, $disabled); + self::assertSame('lit_validation_failed', $disabled->get_error_code()); + self::assertArrayHasKey('divi', $disabled->get_error_data()['fields']); + + $invalid = $this->composer()->check('page', [ + ['type' => 'section', 'modules' => [['type' => 'button', 'attrs' => ['x.y' => ['desktop' => 1]]]]], + ['type' => 'section', 'modules' => [['type' => 'button', 'url' => 'javascript:alert(1)', 'text' => 'x']]], + ]); + self::assertInstanceOf(WP_Error::class, $invalid); + self::assertSame('lit_invalid_attr_path', $invalid->get_error_code()); + $fields = $invalid->get_error_data()['fields']; + self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.x.y', $fields); + self::assertGreaterThanOrEqual(2, count($fields), 'every error of every section comes back'); + self::assertSame([], $this->saved); + } + + /** The entry's own map can be narrower than the one for "no entry": then nothing is written. */ + public function testWriteRefusesWhatTheNewEntrysMapDoesNotOffer(): void + { + $registry = static function (int $postId): array { + $options = DynamicContentMapTest::registry(); + if ($postId !== 0) { + unset($options['site_title']); + } + + return $options; + }; + $title = VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']); + $sections = [['type' => 'section', 'modules' => [['type' => 'heading', 'text' => $title]]]]; + + self::assertNull($this->composer(true, $registry)->check('page', $sections)); + $written = $this->composer(true, $registry)->write(42, 'page', $sections); + + self::assertInstanceOf(WP_Error::class, $written); + self::assertSame('lit_validation_failed', $written->get_error_code()); + self::assertSame([], $this->saved); + } + + public function testAFailingSaveIsThrown(): void + { + $this->saver = Mockery::mock(DiviSaver::class); + $this->saver->allows('saveNew')->andThrow(new RuntimeException('Divi flag missing')); + + $this->expectException(RuntimeException::class); + $this->composer()->write(42, 'page', self::sections()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php index f0001f9..a7d7498 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php @@ -212,4 +212,59 @@ final class DiviSaverTest extends TestCase self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]); self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log)); } + + public function testSaveNewWritesTheFirstContentWithoutKsesAndSetsTheFlagsStrictly(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\expect('wp_update_post')->once()->with(['ID' => 42, 'post_content' => 'NEU'], true) + ->andReturnUsing(function (): int { + $this->calls[] = 'update'; + + return 42; + }); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU'])); + Functions\expect('wp_save_post_revision')->never(); + + self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU')); + self::assertSame( + // Same kses window as save(), but no restore point: the new draft has no earlier state. + [ + 'kses off', 'update', 'kses on', + 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on', 'meta:_et_pb_show_page_creation=off', + ], + $this->calls + ); + } + + public function testSaveNewReportsAContentChangedOnSaveAndKeepsKsesForUnfilteredUsers(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('has_filter')->justReturn(10); + Functions\when('wp_update_post')->justReturn(42); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'ANDERS'])); + + self::assertSame([DiviSaver::CHANGED_ON_SAVE], (new DiviSaver())->saveNew(42, 'NEU')); + self::assertNotContains('kses off', $this->calls); + } + + public function testSaveNewThrowsWhenTheUpdateIsRefusedOrAFlagIsMissing(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\when('wp_update_post')->justReturn(new WP_Error('db', 'kaputt')); + + try { + (new DiviSaver())->saveNew(42, 'NEU'); + self::fail('RuntimeException expected'); + } catch (RuntimeException $e) { + self::assertStringContainsString('kaputt', $e->getMessage()); + } + self::assertSame(['kses off', 'kses on'], $this->calls, 'kses comes back, no flags on a failed write'); + + Functions\when('wp_update_post')->justReturn(42); + Functions\when('get_post_meta')->justReturn(''); + $this->expectException(RuntimeException::class); + (new DiviSaver())->saveNew(42, 'NEU'); + } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php new file mode 100644 index 0000000..054490f --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -0,0 +1,283 @@ +'; + private const SECTIONS = [['type' => 'section']]; + private const INPUT = ['post_type' => 'page', 'title' => 'Start', 'divi' => self::SECTIONS]; + + private function schema(): ContentSchema + { + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('diviEnabled')->andReturn(true); + + return $schema; + } + + /** @return PostWriter&MockInterface */ + private function writer(): PostWriter + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Start']); + $writer->allows('deniedMeta')->andReturn([]); + $writer->allows('writeExtras'); + $writer->allows('deleteCreatedTerms'); + + return $writer; + } + + /** @return ContentComposer&MockInterface */ + private function composer(bool $available = true): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn($available); + $composer->allows('check')->with('page', self::SECTIONS)->andReturn(null); + + return $composer; + } + + private function log(): ChangeLog + { + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(9); + $log->allows('recordPartial'); + + return $log; + } + + private function ability(?ContentComposer $composer, ?PostWriter $writer = null): ContentCreateAbility + { + return new ContentCreateAbility($this->schema(), $writer ?? $this->writer(), $this->log(), $composer); + } + + private function stubStoredDraft(): void + { + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => self::MARKUP, + ])); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit'); + } + + public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void + { + $composer = $this->composer(); + $composer->expects('write')->with(42, 'page', self::SECTIONS) + ->andReturn(['warnings' => ['s0: leer (section ohne Inhalt).'], 'outline' => 's0 divi/section']); + // D7: the insert carries only the normal WordPress fields (kses as usual) – never Divi markup; + // the layout is written by the Divi composer through DiviSaver's single kses window. + Functions\expect('wp_insert_post')->once()->with([ + 'post_title' => 'Start', + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_author' => 3, + ], true)->andReturn(42); + $this->stubStoredDraft(); + + $result = $this->ability($composer)->execute(self::INPUT); + + self::assertTrue($result['ok']); + self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']); + self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']); + self::assertSame('s0 divi/section', $result['outline']); + self::assertStringContainsString('Entwurf', $result['summary'], 'drafts stay drafts'); + self::assertArrayHasKey('tree_rev', $this->ability(null)->outputSchema()['properties']); + self::assertArrayHasKey('divi', $this->ability(null)->inputSchema()['properties']); + } + + public function testWithoutDiviTheComposerIsNotUsed(): void + { + $composer = Mockery::mock(ContentComposer::class); + $composer->expects('available')->never(); + Functions\when('wp_insert_post')->justReturn(42); + $this->stubStoredDraft(); + + $result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']); + + self::assertArrayNotHasKey('tree_rev', $result); + } + + public function testDiviNeedsDiviAndExcludesContent(): void + { + Functions\expect('wp_insert_post')->never(); + + $inactive = $this->ability($this->composer(false))->execute(self::INPUT); + self::assertSame('lit_unsupported', $inactive->get_error_code()); + + $none = $this->ability(null)->execute(self::INPUT); + self::assertSame('lit_unsupported', $none->get_error_code()); + + $both = $this->ability($this->composer())->execute(self::INPUT + ['content' => '

    x

    ']); + self::assertSame('lit_validation_failed', $both->get_error_code()); + self::assertArrayHasKey('content', $both->get_error_data()['fields']); + } + + public function testEveryValidationErrorComesBackAndNothingIsCreated(): void + { + Functions\expect('wp_insert_post')->never(); + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn(true); + $composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt'])); + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.'])); + + $error = $this->ability($composer, $writer)->execute(self::INPUT); + + self::assertSame('lit_invalid_attr_path', $error->get_error_code()); + self::assertSame( + ['fields.role' => 'Pflichtfeld fehlt.', 'divi[0].attrs.x' => 'unbekannt'], + $error->get_error_data()['fields'] + ); + } + + public function testAFailingLayoutWriteDiscardsTheDraft(): void + { + $composer = $this->composer(); + $composer->allows('write')->andThrow(new RuntimeException('Divi flag missing')); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + + try { + $this->ability($composer)->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + public function testALayoutTheNewEntryRefusesDeletesTheDraftAndReturnsTheErrors(): void + { + $composer = $this->composer(); + $composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten'])); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + + $error = $this->ability($composer)->execute(self::INPUT); + + self::assertInstanceOf(WP_Error::class, $error); + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('divi[0].modules[0].text', $error->get_error_data()['fields']); + } + + public function testARefusedLayoutWhoseDraftCannotBeDeletedIsAPartialWrite(): void + { + $composer = $this->composer(); + $composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten'])); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->andReturn(false); + + try { + $this->ability($composer)->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(42, $e->postId); + self::assertStringContainsString('divi[0].modules[0].text', $e->reason); + } + } + + /** + * Rulings S40/S41 hold for content-create too – they live in the Composer: the same path via + * short form and attrs is refused (nothing created); an admin label in attrs wins over label. + */ + public function testShortFormAndAttrsRulesApplyThroughTheRealComposer(): void + { + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $saved = []; + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('saveNew')->andReturnUsing(static function (int $postId, string $content) use (&$saved): array { + $saved[] = $content; + + return []; + }); + $real = new DiviContentComposer( + $this->schema(), + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()), + $saver, + $design + ); + self::assertFalse($real->available(), 'no Divi classes in unit tests'); + // Only available() is replaced (Divi's classes are missing here); check/write are the real ones. + $composer = new class ($real) implements ContentComposer { + public function __construct(private DiviContentComposer $real) + { + } + + public function available(): bool + { + return true; + } + + public function check(string $postType, array $sections): ?WP_Error + { + return $this->real->check($postType, $sections); + } + + public function write(int $postId, string $postType, array $sections): array|WP_Error + { + return $this->real->write($postId, $postType, $sections); + } + }; + Functions\when('is_admin')->justReturn(false); + $this->stubStoredDraft(); + + Functions\expect('wp_insert_post')->never(); + $overlap = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[ + 'type' => 'section', + 'label' => 'Kurz', + 'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Lang']], + 'modules' => [['type' => 'button', 'text' => 'Los', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'x']]]]], + ]]]); + self::assertSame('lit_validation_failed', $overlap->get_error_code()); + $fields = $overlap->get_error_data()['fields']; + self::assertArrayHasKey('divi[0].attrs.module.meta.adminLabel', $fields, 'S41: label and attrs label'); + self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.button.innerContent', $fields, 'S40'); + self::assertSame([], $saved); + + Functions\expect('wp_insert_post')->once()->andReturn(42); + $label = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[ + 'type' => 'section', + 'label' => 'Hero', + 'modules' => [['type' => 'heading', 'text' => 'Hallo', 'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Aus attrs']]]], + ]]]); + self::assertTrue($label['ok']); + self::assertStringContainsString('"adminLabel":{"desktop":{"value":"Aus attrs"}}', $saved[0], 'S41: no auto label over it'); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index dd64491..946f087 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\WordPress; use LIT\MCP\Abilities\Ability; +use Brain\Monkey\Functions; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\WordPressIntegration; use LIT\MCP\Tests\Unit\TestCase; @@ -61,4 +63,23 @@ final class WordPressIntegrationTest extends TestCase self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name()); } } + + public function testContentCreateGetsTheContentComposer(): void + { + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('current_user_can')->justReturn(true); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $composer = Mockery::mock(ContentComposer::class); + $composer->expects('available')->andReturn(false); + $integration = new WordPressIntegration($schema, Mockery::mock(ChangeLog::class), $composer); + + $create = array_values(array_filter( + $integration->abilities(), + static fn (Ability $ability): bool => $ability->name() === 'lit/content-create' + ))[0]; + $result = $create->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + } } From 843f45aeecd81e94e2de6e941b4b8ffe4bcb0146 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 16:50:58 +0200 Subject: [PATCH 130/185] feat: admin settings page UI/UX pass with WordPress-native patterns - Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs, aria-current on the active tab; own CSS/JS enqueued only on this screen. - Verbindung: ordered "So verbindest du Claude" steps, copy button (core clipboard.js + wp.a11y.speak), Site-Health-style status list, German details. - Zugriff: roles as form-table fieldset, connections with site date format, empty-state row, confirm on revoke; ConnectionUsage records last use via WP_Application_Passwords::record_application_password_usage (once a day). - Tools: plain-language legend, counter as inline notice, groups as core postboxes (postbox.js toggles, per-user closed state, summary when closed), fixed column widths with new "Art" column, submit above and below. - Protokoll: site-timezone times with UTC tooltip, result icons, filters with labels in the list table nav, retention link to Limits. - Limits: short labels with units and descriptions; snapshot count hidden but preserved. Co-Authored-By: Claude Opus 5.5 (1M context) --- assets/css/admin.css | 340 ++++++++++++++++++++++ assets/js/admin.js | 45 +++ src/Admin/AccessTab.php | 97 ++++-- src/Admin/ConnectionStatus.php | 17 +- src/Admin/ConnectionTab.php | 52 +++- src/Admin/ConnectionUsage.php | 81 ++++++ src/Admin/LimitsTab.php | 56 ++-- src/Admin/LogListTable.php | 80 ++++- src/Admin/LogTab.php | 79 +++-- src/Admin/SettingsPage.php | 60 +++- src/Admin/ToolsTab.php | 267 ++++++++++++----- src/Plugin.php | 12 +- tests/Stubs/WpListTable.php | 7 + tests/Unit/Admin/AccessTabTest.php | 98 +++++-- tests/Unit/Admin/ConnectionStatusTest.php | 24 +- tests/Unit/Admin/ConnectionTabTest.php | 76 +++++ tests/Unit/Admin/ConnectionUsageTest.php | 78 +++++ tests/Unit/Admin/LimitsTabTest.php | 23 +- tests/Unit/Admin/LogListTableTest.php | 64 +++- tests/Unit/Admin/LogTabTest.php | 16 +- tests/Unit/Admin/SettingsPageTest.php | 51 +++- tests/Unit/Admin/ToolsTabTest.php | 154 ++++++++-- 22 files changed, 1535 insertions(+), 242 deletions(-) create mode 100644 assets/css/admin.css create mode 100644 assets/js/admin.js create mode 100644 src/Admin/ConnectionUsage.php create mode 100644 tests/Unit/Admin/ConnectionTabTest.php create mode 100644 tests/Unit/Admin/ConnectionUsageTest.php diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..5007c14 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,340 @@ +/* Einstellungen → LIT MCP. Only small additions to WordPress' own admin styles. */ + +/* Header: title left, logo right; wraps instead of overlapping on narrow screens. */ +.lit-mcp-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; +} + +.lit-mcp-logo { + display: block; + height: 30px; + width: auto; +} + +@media screen and (max-width: 782px) { + .lit-mcp-logo { + height: 22px; + } +} + +.lit-mcp .nav-tab-wrapper { + margin-bottom: 1em; +} + +/* Muted secondary text (tool names, client URLs). */ +.lit-mcp-muted, +.lit-mcp-tool-name { + color: #646970; + font-size: 12px; + overflow-wrap: anywhere; +} + +.lit-mcp-tool-name { + font-family: Consolas, Monaco, monospace; +} + +/* Tab "Verbindung". */ +.lit-mcp-steps { + max-width: 50rem; +} + +.lit-mcp-steps li { + margin-bottom: 0.75em; +} + +.lit-mcp-copy-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + margin-top: 6px; +} + +.lit-mcp-copy-row input.large-text { + flex: 1 1 22rem; + width: auto; +} + +.lit-mcp-copy-row .success { + color: #007017; +} + +.lit-mcp-status { + margin: 0 0 1em; +} + +.lit-mcp-status li { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0 6px; + margin-bottom: 8px; +} + +.lit-mcp-status .dashicons { + align-self: center; +} + +.lit-mcp-status-detail { + color: #50575e; +} + +.lit-mcp-status-yes-alt .dashicons, +.lit-mcp-result-yes-alt .dashicons { + color: #00a32a; +} + +.lit-mcp-status-warning .dashicons, +.lit-mcp-result-warning .dashicons { + color: #dba617; +} + +.lit-mcp-status-dismiss .dashicons, +.lit-mcp-result-dismiss .dashicons { + color: #d63638; +} + +/* Tab "Zugriff". */ +.lit-mcp-connections + .description { + margin-top: 8px; +} + +.lit-mcp-connections .column-actions { + width: 8em; + text-align: right; +} + +@media screen and (max-width: 782px) { + .lit-mcp-connections .column-created { + display: none; + } + + .lit-mcp-connections .column-actions { + width: auto; + } + + .lit-mcp-copy-row input.large-text { + font-size: 13px; + } +} + +/* Tab "Tools": legend, collapsible groups (WordPress postboxes) with aligned columns. */ +.lit-mcp-legend { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 12px; + margin: 1em 0; +} + +.lit-mcp-legend dt { + font-weight: 600; +} + +.lit-mcp-legend dd { + margin: 0; +} + +.lit-mcp-groups .postbox-header { + flex-wrap: wrap; + gap: 0 12px; + padding-left: 12px; +} + +.lit-mcp-groups .postbox .hndle { + cursor: pointer; + justify-content: flex-start; + gap: 6px; + padding: 8px 0; + margin: 0; + font-size: 14px; + line-height: 1.4; +} + +.lit-mcp-groups .hndle .count { + color: #646970; + font-weight: 400; +} + +.lit-mcp-group-summary { + color: #646970; + font-size: 13px; + font-weight: 400; +} + +.lit-mcp-group-summary::before { + content: "– "; +} + +.lit-mcp-groups .postbox:not(.closed) .lit-mcp-group-summary { + display: none; +} + +.lit-mcp-group-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + padding: 6px 0; +} + +.lit-mcp-group-controls .description { + font-style: italic; +} + +.lit-mcp-groups .handle-actions { + padding-right: 6px; +} + +/* Same arrow indicator as WordPress' own meta boxes (core scopes it to .meta-box-sortables). */ +.lit-mcp-groups .postbox .toggle-indicator::before { + content: "\f142"; + content: "\f142" / ''; + display: inline-block; + font: normal 20px/1 dashicons; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-decoration: none; +} + +.lit-mcp-groups .postbox.closed .toggle-indicator::before { + content: "\f140"; + content: "\f140" / ''; +} + +.lit-mcp-groups .postbox .inside { + margin: 0; + padding: 0; +} + +.lit-mcp-tools-table { + table-layout: fixed; + border: 0; + box-shadow: none; +} + +.lit-mcp-tools-table .column-tool { + width: 55%; +} + +.lit-mcp-tools-table .column-kind { + width: 15%; +} + +.lit-mcp-tools-table .column-mode { + width: 30%; +} + +.lit-mcp-tools-table td { + vertical-align: middle; +} + +.lit-mcp-tools-table select { + max-width: 100%; +} + +.lit-mcp-tools-table .description { + margin: 4px 0 0; +} + +.lit-mcp-kind { + color: #50575e; + white-space: nowrap; +} + +.lit-mcp-kind .dashicons { + font-size: 16px; + width: 16px; + height: 16px; + vertical-align: text-bottom; + color: #787c82; +} + +@media screen and (max-width: 782px) { + .lit-mcp-group-controls { + order: 3; + flex-basis: 100%; + padding-bottom: 10px; + } + + .lit-mcp-tools-table .column-tool { + width: 45%; + } + + .lit-mcp-tools-table .column-kind { + width: 12%; + } + + .lit-mcp-tools-table .column-mode { + width: 43%; + } + + .lit-mcp-tools-table th, + .lit-mcp-tools-table td { + padding-left: 6px; + padding-right: 6px; + } + + /* Icon only on phones; the word stays for screen readers (and as tooltip-free fallback). */ + .lit-mcp-kind-text { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .lit-mcp-tools-table select { + width: 100%; + font-size: 14px; + padding-right: 20px; + background-position: right 2px top 55%; + } +} + +/* Tab "Protokoll". */ +.lit-mcp .wp-list-table .column-created_at { + width: 10em; +} + +.lit-mcp .wp-list-table .column-user, +.lit-mcp .wp-list-table .column-client { + width: 8em; +} + +.lit-mcp .wp-list-table .column-ability, +.lit-mcp .wp-list-table .column-object { + width: 11em; +} + +.lit-mcp .wp-list-table .column-revision, +.lit-mcp .wp-list-table .column-result { + width: 8em; +} + +.lit-mcp .wp-list-table .lit-mcp-partial { + background: #fcf9e8; +} + +.lit-mcp-result .dashicons { + font-size: 18px; + width: 18px; + height: 18px; + vertical-align: text-bottom; +} + +.lit-mcp .tablenav .actions select, +.lit-mcp .tablenav .actions input[type="date"] { + float: left; + margin-right: 6px; + max-width: 12.5rem; +} + +/* Tab "Limits". */ +.lit-mcp-unit { + margin-left: 4px; +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..e950819 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,45 @@ +/* Einstellungen → LIT MCP: copy the server URL, collapsible tool groups, filter paging. */ +( function () { + 'use strict'; + + document.addEventListener( 'DOMContentLoaded', function () { + var hideTimeout; + + // "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library. + if ( window.ClipboardJS && document.querySelector( '.lit-mcp-copy' ) ) { + new window.ClipboardJS( '.lit-mcp-copy' ).on( 'success', function ( event ) { + var success = event.trigger.parentNode.querySelector( '.success' ); + + event.clearSelection(); + event.trigger.focus(); + if ( success ) { + clearTimeout( hideTimeout ); + success.classList.remove( 'hidden' ); + hideTimeout = setTimeout( function () { + success.classList.add( 'hidden' ); + }, 3000 ); + } + if ( window.wp && window.wp.a11y && window.litMcpAdmin ) { + window.wp.a11y.speak( window.litMcpAdmin.copied ); + } + } ); + } + + // Tool groups: WordPress' own postbox toggles, closed state saved per user. + if ( window.postboxes && window.pagenow && document.querySelector( '.lit-mcp-groups' ) ) { + window.postboxes.add_postbox_toggles( window.pagenow ); + } + + // Protokoll: a changed filter starts again on page 1. + document.querySelectorAll( '.lit-mcp .tablenav .actions select, .lit-mcp .tablenav .actions input' ).forEach( + function ( control ) { + control.addEventListener( 'change', function () { + var paged = control.form && control.form.querySelector( 'input[name="paged"]' ); + if ( paged ) { + paged.value = '1'; + } + } ); + } + ); + } ); +}() ); diff --git a/src/Admin/AccessTab.php b/src/Admin/AccessTab.php index c183f59..5ddd9be 100644 --- a/src/Admin/AccessTab.php +++ b/src/Admin/AccessTab.php @@ -89,16 +89,17 @@ final class AccessTab implements AdminTab public function render(): void { $allowed = $this->access->allowedRoles(); + $legend = __('Rollen mit Zugriff', 'wp-lit-mcp'); - echo '

    ' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '

    '; - echo '

    ' . esc_html__( - 'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.', - 'wp-lit-mcp' - ) . '

    '; echo '
    '; settings_fields(self::GROUP); // Makes an all-unchecked submission arrive as [''] instead of dropping the field. printf('', esc_attr(Access::OPTION)); + printf( + ''; submit_button(); echo '
    '; @@ -160,31 +165,42 @@ final class AccessTab implements AdminTab private function renderConnections(): void { - echo '

    ' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '

    '; + echo '

    ' . esc_html__('Verbindungen', 'wp-lit-mcp') . '

    '; $connections = $this->connections->all(); + + printf( + '' + . '' + . '' + . '' + . '', + esc_html__('Benutzer', 'wp-lit-mcp'), + esc_html__('Client', 'wp-lit-mcp'), + esc_html__('Verbunden am', 'wp-lit-mcp'), + esc_html__('Zuletzt genutzt', 'wp-lit-mcp'), + esc_html__('Aktionen', 'wp-lit-mcp') + ); + if ($connections === []) { - echo '

    ' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '

    '; - return; + printf( + '', + esc_html__('Noch keine Verbindung.', 'wp-lit-mcp'), + esc_html__('So verbindest du Claude:', 'wp-lit-mcp'), + esc_url(SettingsPage::url('verbindung')), + esc_html__('Tab „Verbindung“', 'wp-lit-mcp') + ); } - - echo '
    %s%s%s%s%s
    %s %s %s
    '; - $headings = [ - __('Benutzer', 'wp-lit-mcp'), - __('Client', 'wp-lit-mcp'), - __('Erstellt', 'wp-lit-mcp'), - __('Zuletzt genutzt', 'wp-lit-mcp'), - '', - ]; - foreach ($headings as $heading) { - echo ''; - } - echo ''; - foreach ($connections as $connection) { $this->renderConnectionRow($connection); } - echo '
    ' . esc_html($heading) . '
    '; + + if ($connections !== []) { + echo '

    ' . esc_html__( + '„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.', + 'wp-lit-mcp' + ) . '

    '; + } } /** @@ -193,21 +209,42 @@ final class AccessTab implements AdminTab */ private function renderConnectionRow(array $connection): void { + $dateFormat = (string) get_option('date_format', 'd.m.Y'); + $timeFormat = (string) get_option('time_format', 'H:i'); + echo ''; - echo '' . esc_html($connection['user_login']) . ''; - printf('%s
    %s', esc_html($connection['name']), esc_html($connection['app_id'])); - echo '' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . ''; + echo '' . esc_html($connection['user_login']) . ''; + printf( + '%s
    %s', + esc_html($connection['name']), + esc_html($connection['app_id']) + ); + printf( + '%s', + esc_html((string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created'])) + ); $lastUsed = $connection['last_used'] === null ? '–' - : (string) wp_date('Y-m-d H:i', $connection['last_used']); - echo '' . esc_html($lastUsed) . ''; - echo ''; + : (string) wp_date($dateFormat, $connection['last_used']); + echo '' . esc_html($lastUsed) . ''; + echo ''; printf('
    ', esc_url(admin_url('admin-post.php'))); printf('', esc_attr(self::REVOKE_ACTION)); printf('', esc_attr((string) $connection['user_id'])); printf('', esc_attr($connection['uuid'])); wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']); - submit_button(__('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false); + $confirm = esc_js(sprintf( + /* translators: %s: client name */ + __('Verbindung „%s“ widerrufen? Claude muss sich danach neu anmelden.', 'wp-lit-mcp'), + $connection['name'] + )); + submit_button( + __('Widerrufen', 'wp-lit-mcp'), + 'delete small', + 'submit', + false, + ['onclick' => "return window.confirm('" . $confirm . "');"] + ); echo '
    '; } } diff --git a/src/Admin/ConnectionStatus.php b/src/Admin/ConnectionStatus.php index 89fe22e..08ba27d 100644 --- a/src/Admin/ConnectionStatus.php +++ b/src/Admin/ConnectionStatus.php @@ -93,7 +93,7 @@ class ConnectionStatus $code = (int) wp_remote_retrieve_response_code($response); $challenge = (string) wp_remote_retrieve_header($response, 'www-authenticate'); $result = $code === 401 && str_starts_with($challenge, 'Bearer') - ? ['ok' => true, 'message' => 'Ohne Token: 401 mit WWW-Authenticate – korrekt.'] + ? ['ok' => true, 'message' => 'Erreichbar; verlangt wie erwartet eine Anmeldung (HTTP 401).'] : ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)]; } @@ -132,14 +132,19 @@ class ConnectionStatus : __('Website läuft ohne HTTPS – claude.ai verbindet sich nur über HTTPS', 'wp-lit-mcp'), ], [ - 'label' => __('Discovery (/.well-known/)', 'wp-lit-mcp'), + 'label' => __('OAuth-Discovery', 'wp-lit-mcp'), 'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good', - 'message' => $discovery === null - ? __('noch nicht geprüft', 'wp-lit-mcp') - : wp_strip_all_tags((string) ($discovery['label'] ?? '')), + 'message' => match (true) { + $discovery === null => __('noch nicht geprüft', 'wp-lit-mcp'), + ($discovery['status'] ?? '') === 'good' => __('Discovery-Dokumente erreichbar', 'wp-lit-mcp'), + default => __( + 'Discovery-Dokumente (/.well-known/) nicht erreichbar – Details unter Website-Zustand', + 'wp-lit-mcp' + ), + }, ], [ - 'label' => __('Endpunkt ohne Token', 'wp-lit-mcp'), + 'label' => __('MCP-Endpunkt', 'wp-lit-mcp'), 'ok' => $endpoint['ok'] ?? null, 'message' => $endpoint['message'] ?? __('noch nicht geprüft', 'wp-lit-mcp'), ], diff --git a/src/Admin/ConnectionTab.php b/src/Admin/ConnectionTab.php index 7f4ec63..1e30beb 100644 --- a/src/Admin/ConnectionTab.php +++ b/src/Admin/ConnectionTab.php @@ -30,29 +30,53 @@ final class ConnectionTab implements AdminTab public function render(): void { - echo '

    ' . esc_html__('MCP-Server', 'wp-lit-mcp') . '

    '; + echo '

    ' . esc_html__('So verbindest du Claude', 'wp-lit-mcp') . '

    '; + echo '
    1. '; printf( - '


      ' - . '

      ', - esc_html__('Server-URL für den Connector', 'wp-lit-mcp'), - esc_attr(ConnectionStatus::serverUrl()) + '' + . '' + . '' + . '' + . '', + esc_html__('Server-URL kopieren:', 'wp-lit-mcp'), + esc_attr(ConnectionStatus::serverUrl()), + esc_html__('URL kopieren', 'wp-lit-mcp'), + esc_html__('Kopiert!', 'wp-lit-mcp') ); - echo '

      ' . esc_html__( - 'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen;', + echo '

    2. ' . esc_html__( + 'In claude.ai unter Einstellungen → Connectors auf „Add custom connector“ klicken und die URL einfügen.', 'wp-lit-mcp' - ) . ' ' . esc_html__('Die Anmeldung läuft danach über diese Website.', 'wp-lit-mcp') . '

      '; + ) . '
    3. '; + printf( + /* translators: %s: link to the "Zugriff" tab */ + esc_html__('Anmelden und den Zugriff bestätigen. Danach erscheint die Verbindung unter %s.', 'wp-lit-mcp'), + sprintf('%s', esc_url(SettingsPage::url('zugriff')), esc_html__('Zugriff', 'wp-lit-mcp')) + ); + echo '
    '; - echo '

    ' . esc_html__('Status', 'wp-lit-mcp') . '

    '; + echo '

    ' . esc_html__('Status', 'wp-lit-mcp') . '

      '; foreach ($this->status->checks(true) as $check) { - $state = $check['ok'] === null ? '–' : ($check['ok'] ? '✔' : '✖'); + [$icon, $state] = match ($check['ok']) { + true => ['yes-alt', __('In Ordnung:', 'wp-lit-mcp')], + false => ['dismiss', __('Problem:', 'wp-lit-mcp')], + default => ['warning', __('Hinweis:', 'wp-lit-mcp')], + }; printf( - '
    ', - esc_html($check['label']), + '
  • ' + . '%2$s %3$s' + . ' %4$s
  • ', + esc_attr($icon), esc_html($state), + esc_html($check['label']), esc_html($check['message']) ); } - echo '
    %s%s%s
    '; - echo '

    ' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '

    '; + echo ''; + printf( + '

    %s %s.

    ', + esc_html__('Weitere Prüfungen unter', 'wp-lit-mcp'), + esc_url(admin_url('site-health.php')), + esc_html__('Werkzeuge → Website-Zustand', 'wp-lit-mcp') + ); } } diff --git a/src/Admin/ConnectionUsage.php b/src/Admin/ConnectionUsage.php new file mode 100644 index 0000000..8e51666 --- /dev/null +++ b/src/Admin/ConnectionUsage.php @@ -0,0 +1,81 @@ + */ + private $decode; + + /** @var callable(int, string): mixed */ + private $recordUsage; + + /** + * @param (callable(string): ?array)|null $decode JWT → claims, null if invalid. + * @param (callable(int, string): mixed)|null $recordUsage user id, application password uuid. + */ + public function __construct(?callable $decode = null, ?callable $recordUsage = null) + { + $this->decode = $decode ?? static function (string $token): ?array { + $claims = JWT::decode($token, SecretManager::get_secret()); + + return is_array($claims) ? $claims : null; + }; + $this->recordUsage = $recordUsage ?? static function (int $userId, string $uuid): void { + \WP_Application_Passwords::record_application_password_usage($userId, $uuid); + }; + } + + public function register(): void + { + add_filter('rest_request_after_callbacks', [$this, 'record'], 10, 3); + } + + /** + * @param array $handler + * @return mixed The response, unchanged. + */ + public function record(mixed $response, array $handler, WP_REST_Request $request): mixed + { + $route = '/' . ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE; + if ($request->get_route() !== $route || is_wp_error($response)) { + return $response; + } + + $header = $request->get_header('authorization'); + $userId = get_current_user_id(); + if (! is_string($header) || stripos($header, 'Bearer ') !== 0 || $userId <= 0) { + return $response; + } + + try { + $claims = ($this->decode)(substr($header, 7)); + $uuid = is_array($claims) ? (string) ($claims['app_pass_id'] ?? '') : ''; + // Only the password of the user this request actually authenticated as. + if ($uuid !== '' && (int) ($claims['sub'] ?? 0) === $userId) { + ($this->recordUsage)($userId, $uuid); + } + } catch (Throwable) { + // Bookkeeping only – never break the MCP response. + } + + return $response; + } +} diff --git a/src/Admin/LimitsTab.php b/src/Admin/LimitsTab.php index f591d53..16183cc 100644 --- a/src/Admin/LimitsTab.php +++ b/src/Admin/LimitsTab.php @@ -7,7 +7,8 @@ namespace LIT\MCP\Admin; use LIT\MCP\Limits; /** - * Tab "Limits": upload/download size, log retention, snapshot count (spec §8.4). + * Tab "Limits": upload/download size and log retention (spec §8.4). The snapshot count is + * kept as a hidden field until the design system feature that uses it exists. * Changes apply immediately – Limits::all() always reads the current option. */ final class LimitsTab implements AdminTab @@ -41,39 +42,56 @@ final class LimitsTab implements AdminTab { $values = (new Limits())->all(); $bounds = Limits::bounds(); - $labels = [ - 'upload_mb' => sprintf( - /* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */ - __( - 'Maximale Upload-/Download-Größe (MB, höchstens %1$d; Base64-Upload/Download höchstens %2$d MB)', - 'wp-lit-mcp' + $fields = [ + 'upload_mb' => [ + __('Maximale Dateigröße', 'wp-lit-mcp'), + __('MB', 'wp-lit-mcp'), + sprintf( + /* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */ + __('Höchstens %1$d MB; für Base64-Übertragungen höchstens %2$d MB.', 'wp-lit-mcp'), + $bounds['upload_mb'][1], + Limits::INLINE_MAX_MB ), - $bounds['upload_mb'][1], - Limits::INLINE_MAX_MB - ), - 'log_retention_days' => __('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'), - 'snapshot_count' => __('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'), + ], + 'log_retention_days' => [ + __('Protokoll aufbewahren', 'wp-lit-mcp'), + __('Tage', 'wp-lit-mcp'), + __('Ältere Einträge im Protokoll werden automatisch gelöscht.', 'wp-lit-mcp'), + ], ]; echo '
    '; settings_fields(self::GROUP); - echo ''; - foreach ($labels as $key => $label) { + echo '
    '; + foreach ($fields as $key => [$label, $unit, $description]) { [$min, $max] = $bounds[$key]; printf( '', + . '' + . ' %7$s' + . '

    %8$s

    ', esc_attr($key), esc_html($label), esc_attr((string) $min), esc_attr((string) $max), esc_attr(Limits::OPTION), - esc_attr((string) $values[$key]) + esc_attr((string) $values[$key]), + esc_html($unit), + esc_html($description) ); + if ($key === 'upload_mb') { + $this->maybeRenderUploadSizeNotice($values['upload_mb']); + } + echo ''; } echo ''; - $this->maybeRenderUploadSizeNotice($values['upload_mb']); + // Design system snapshots are not available yet: no visible setting, but keep the saved value. + printf( + '', + esc_attr(Limits::OPTION), + (int) $values['snapshot_count'] + ); submit_button(); echo '
    '; } @@ -96,6 +114,6 @@ final class LimitsTab implements AdminTab $uploadMb, $serverMaxMb ); - printf('

    %s

    ', esc_html($message)); + printf('

    %s

    ', esc_html($message)); } } diff --git a/src/Admin/LogListTable.php b/src/Admin/LogListTable.php index b4ce973..b07c966 100644 --- a/src/Admin/LogListTable.php +++ b/src/Admin/LogListTable.php @@ -18,13 +18,18 @@ final class LogListTable extends \WP_List_Table { private const PER_PAGE = 50; + /** @var (callable(): void)|null */ + private $renderFilters; + /** * @param array{ * ability: string, user_id: int, result?: string, date_from?: string, date_to?: string * } $filters + * @param (callable(): void)|null $renderFilters prints the filter controls into the top table nav */ - public function __construct(private ChangeLog $log, private array $filters) + public function __construct(private ChangeLog $log, private array $filters, ?callable $renderFilters = null) { + $this->renderFilters = $renderFilters; parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]); } @@ -32,7 +37,7 @@ final class LogListTable extends \WP_List_Table public function get_columns(): array { return [ - 'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'), + 'created_at' => __('Zeit', 'wp-lit-mcp'), 'user' => __('Benutzer', 'wp-lit-mcp'), 'client' => __('Client', 'wp-lit-mcp'), 'ability' => __('Tool', 'wp-lit-mcp'), @@ -57,6 +62,22 @@ final class LogListTable extends \WP_List_Table esc_html_e('Keine Einträge.', 'wp-lit-mcp'); } + /** + * Filters left of the pagination in the top table nav, as on WordPress' own list screens. + * + * @param string $which + */ + protected function extra_tablenav($which): void + { + if ($which !== 'top' || $this->renderFilters === null) { + return; + } + + echo '
    '; + ($this->renderFilters)(); + echo '
    '; + } + /** * Highlights rows with result "partial" (a write that failed after an irreversible * step, spec §2 principle 7/8.2) so admins notice them without reading every row. @@ -65,12 +86,61 @@ final class LogListTable extends \WP_List_Table */ public function single_row($item): void { - $style = ($item['result'] ?? '') === 'partial' ? ' style="background:#fcf3e3;"' : ''; - printf('', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string. + $class = ($item['result'] ?? '') === 'partial' ? ' class="lit-mcp-partial"' : ''; + printf('', $class); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string. $this->single_row_columns($item); echo ''; } + /** + * Stored in UTC (MySQL format); shown in the site's timezone and date/time format, + * with the UTC value as tooltip. + * + * @param array $item + */ + protected function column_created_at($item): string + { + $utc = (string) ($item['created_at'] ?? ''); + $timestamp = preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $utc) === 1 + ? strtotime($utc . ' UTC') + : false; + if ($timestamp === false) { + return '–'; + } + + $format = get_option('date_format') . ' ' . get_option('time_format'); + + return sprintf( + '%s', + esc_attr('UTC: ' . $utc), + esc_html((string) wp_date($format, $timestamp)) + ); + } + + /** @param array $item */ + protected function column_ability($item): string + { + return '' . esc_html((string) ($item['ability'] ?? '')) . ''; + } + + /** @param array $item */ + protected function column_result($item): string + { + $result = (string) ($item['result'] ?? ''); + [$icon, $word] = match ($result) { + 'ok' => ['yes-alt', __('erfolgreich', 'wp-lit-mcp')], + 'partial' => ['warning', __('teilweise', 'wp-lit-mcp')], + default => ['dismiss', $result], + }; + + return sprintf( + '' + . ' %2$s', + esc_attr($icon), + esc_html($word) + ); + } + /** * @param array $item * @param string $column_name @@ -144,7 +214,7 @@ final class LogListTable extends \WP_List_Table return sprintf( '%s', esc_url(admin_url('revision.php?revision=' . $revisionId)), - esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp') + esc_html__('Revision ansehen', 'wp-lit-mcp') ); } } diff --git a/src/Admin/LogTab.php b/src/Admin/LogTab.php index 7aeddc1..b411b0b 100644 --- a/src/Admin/LogTab.php +++ b/src/Admin/LogTab.php @@ -94,21 +94,34 @@ final class LogTab implements AdminTab $filters = $this->filters(); $extra = $this->extraFilters(); + $days = $this->limits->logRetentionDays(); printf( - '

    %s

    ', - esc_html(sprintf( - /* translators: %d: number of days */ - __('Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“).', 'wp-lit-mcp'), - $this->limits->logRetentionDays() - )) + '

    %s %s

    ', + esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'), + sprintf( + /* translators: %s: link "N Tagen" to the Limits tab */ + esc_html__('Einträge werden nach %s automatisch gelöscht.', 'wp-lit-mcp'), + sprintf( + '%s', + esc_url(SettingsPage::url('limits')), + esc_html(sprintf( + /* translators: %d: number of days */ + _n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'), + $days + )) + ) + ) ); - printf('

    %s

    ', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp')); - $this->renderFilterForm($filters, $extra); - - $table = new LogListTable($this->log, $filters + $extra); + echo '
    '; + printf('', esc_attr(SettingsPage::SLUG)); + printf('', esc_attr($this->slug())); + $table = new LogListTable($this->log, $filters + $extra, function () use ($filters, $extra): void { + $this->renderFilterControls($filters, $extra); + }); $table->prepare_items(); $table->display(); + echo '
    '; $this->renderClearForm(); } @@ -117,13 +130,11 @@ final class LogTab implements AdminTab * @param array{ability: string, user_id: int} $filters * @param array{result: string, date_from: string, date_to: string} $extra */ - private function renderFilterForm(array $filters, array $extra): void + private function renderFilterControls(array $filters, array $extra): void { - echo '
    '; - printf('', esc_attr(SettingsPage::SLUG)); - printf('', esc_attr($this->slug())); - - echo ''; foreach ($this->log->abilities() as $ability) { printf( '', @@ -131,39 +142,51 @@ final class LogTab implements AdminTab selected($filters['ability'], $ability, false) ); } - echo ' '; + echo ''; + self::filterLabel('user-id', __('Nach Benutzer filtern', 'wp-lit-mcp')); wp_dropdown_users([ 'name' => 'user_id', + 'id' => 'lit-mcp-filter-user-id', 'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'), 'selected' => $filters['user_id'], ]); - echo ' '; - echo ''; printf('', esc_html__('Alle Ergebnisse', 'wp-lit-mcp')); + $labels = ['ok' => __('erfolgreich', 'wp-lit-mcp'), 'partial' => __('teilweise', 'wp-lit-mcp')]; foreach (self::RESULTS as $result) { printf( - '', + '', esc_attr($result), - selected($extra['result'], $result, false) + selected($extra['result'], $result, false), + esc_html($labels[$result]) ); } - echo ' '; + echo ''; + self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp')); printf( - ' ', - esc_html__('von', 'wp-lit-mcp'), + '', esc_attr($extra['date_from']) ); + self::filterLabel('date-to', __('Bis Datum', 'wp-lit-mcp')); printf( - ' ', - esc_html__('bis', 'wp-lit-mcp'), + '', esc_attr($extra['date_to']) ); - submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false); - echo '
    '; + submit_button(__('Filtern', 'wp-lit-mcp'), '', 'filter_action', false, ['id' => 'lit-mcp-filter-submit']); + } + + private static function filterLabel(string $field, string $text): void + { + printf( + '', + esc_attr($field), + esc_html($text) + ); } private function renderClearForm(): void diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index 39cf16b..c3aac12 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -11,15 +11,24 @@ final class SettingsPage { public const SLUG = 'lit-mcp'; + /** Hook suffix WordPress gives this page (add_options_page → "settings_page_"). */ + public const SCREEN_HOOK = 'settings_page_' . self::SLUG; + /** @var array */ private array $tabs = []; /** * @param list $tabs The first tab is the default. - * @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none). + * @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none). + * @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS). + * @param string $version Asset version for cache busting. */ - public function __construct(array $tabs, private string $logoUrl = '') - { + public function __construct( + array $tabs, + private string $logoUrl = '', + private string $assetsUrl = '', + private string $version = '' + ) { foreach ($tabs as $tab) { $this->tabs[$tab->slug()] = $tab; } @@ -29,6 +38,7 @@ final class SettingsPage { add_action('admin_menu', [$this, 'addMenu']); add_action('admin_init', [$this, 'registerSettings']); + add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']); foreach ($this->tabs as $tab) { $tab->registerActions(); } @@ -52,6 +62,29 @@ final class SettingsPage } } + /** + * Own CSS and a small script, only on this page. The script builds on WordPress' own + * clipboard.js and wp.a11y.speak (as the media modal's "Copy URL") and postbox.js. + */ + public function enqueueAssets(string $hook): void + { + if ($hook !== self::SCREEN_HOOK || $this->assetsUrl === '') { + return; + } + + $base = rtrim($this->assetsUrl, '/'); + wp_enqueue_style('lit-mcp-admin', $base . '/css/admin.css', [], $this->version); + $deps = ['clipboard', 'wp-a11y']; + if ($this->currentTab() === 'tools') { + // Collapsible tool groups use WordPress' own postbox toggles (remembered per user). + $deps[] = 'postbox'; + } + wp_enqueue_script('lit-mcp-admin', $base . '/js/admin.js', $deps, $this->version, true); + wp_localize_script('lit-mcp-admin', 'litMcpAdmin', [ + 'copied' => __('Die URL wurde in die Zwischenablage kopiert.', 'wp-lit-mcp'), + ]); + } + /** @return list */ public function tabSlugs(): array { @@ -81,16 +114,19 @@ final class SettingsPage } $current = $this->currentTab(); - echo '
    '; + echo '
    '; + echo '

    ' . esc_html__('LIT MCP', 'wp-lit-mcp') . '

    '; $this->renderLogo(); - echo '

    ' . esc_html__('LIT MCP', 'wp-lit-mcp') . '

    '; + // Admin notices of other plugins are moved right after this marker – above the tabs. + echo '

    '; $this->renderNotice(); - echo '
    '; } private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void @@ -177,11 +270,23 @@ final class ToolsTab implements AdminTab $own = $policy->toolOverride($name); $locked = $policy->disabledByReadonly($ability); $required = in_array($name, ExposurePolicy::REQUIRED, true); + $selectId = 'lit-mcp-tool-' . self::idPart($name); printf( - '%s
    %s', esc_html($ability->label()), esc_html($name), + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind(). + $this->kind($ability), + esc_attr($selectId), + esc_html(sprintf( + /* translators: %s: tool label */ + __('Einstellung für %s', 'wp-lit-mcp'), + $ability->label() + )), esc_attr(ExposurePolicy::OPTION), esc_attr($name), $locked ? ' disabled' : '' @@ -220,17 +325,41 @@ final class ToolsTab implements AdminTab } $note = match (true) { - $locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'), + $locked => __('Durch „Nur lesen“ aus', 'wp-lit-mcp'), $own === null && $policy->groupModeAdjusted($ability) => __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'), default => '', }; if ($note !== '') { - echo ' ' . esc_html($note) . ''; + echo '

    ' . esc_html($note) . '

    '; } echo ''; } + /** "liest" / "schreibt" / "löscht" from the ability's annotations, with a dashicon. */ + private function kind(Ability $ability): string + { + $annotations = $ability->annotations(); + [$icon, $text] = match (true) { + ($annotations['destructive'] ?? false) === true => ['trash', __('löscht', 'wp-lit-mcp')], + ($annotations['readonly'] ?? false) === true => ['visibility', __('liest', 'wp-lit-mcp')], + default => ['edit', __('schreibt', 'wp-lit-mcp')], + }; + + return sprintf( + '' + . ' %2$s', + esc_attr($icon), + esc_html($text) + ); + } + + /** Safe fragment for an HTML id from a group id or tool name ("lit/guide" → "lit-guide"). */ + private static function idPart(string $value): string + { + return trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($value)), '-'); + } + private function groupLabel(string $group): string { return match ($group) { @@ -248,7 +377,7 @@ final class ToolsTab implements AdminTab return match ($mode) { ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'), ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'), - default => __('deaktiviert', 'wp-lit-mcp'), + default => __('aus', 'wp-lit-mcp'), }; } } diff --git a/src/Plugin.php b/src/Plugin.php index 1d8a7ba..40b6b09 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,6 +11,7 @@ use LIT\MCP\Admin\AdminNotices; use LIT\MCP\Admin\ConnectionRepository; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionTab; +use LIT\MCP\Admin\ConnectionUsage; use LIT\MCP\Admin\LimitsTab; use LIT\MCP\Admin\LogTab; use LIT\MCP\Admin\SettingsPage; @@ -87,13 +88,20 @@ final class Plugin $status = new ConnectionStatus(); (new SiteHealth($status))->register(); - self::$settingsPage = new SettingsPage([ + $tabs = [ new ConnectionTab($status), new AccessTab($access, new ConnectionRepository()), new ToolsTab($registry, $policy), new LogTab($changeLog, $limits), new LimitsTab(), - ], plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile)); + ]; + self::$settingsPage = new SettingsPage( + $tabs, + plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile), + plugins_url('assets', self::$pluginFile), + self::VERSION + ); + (new ConnectionUsage())->register(); if (is_admin()) { self::$settingsPage->register(); (new AdminNotices($status))->register(); diff --git a/tests/Stubs/WpListTable.php b/tests/Stubs/WpListTable.php index 773aa13..3509108 100644 --- a/tests/Stubs/WpListTable.php +++ b/tests/Stubs/WpListTable.php @@ -83,7 +83,14 @@ if (! class_exists('WP_List_Table')) { { } + /** Like the real class: the top table nav (with extra_tablenav()) comes before the rows. */ public function display(): void + { + $this->extra_tablenav('top'); + } + + /** @param string $which */ + protected function extra_tablenav($which) { } } diff --git a/tests/Unit/Admin/AccessTabTest.php b/tests/Unit/Admin/AccessTabTest.php index e2e3abc..8a64fee 100644 --- a/tests/Unit/Admin/AccessTabTest.php +++ b/tests/Unit/Admin/AccessTabTest.php @@ -171,21 +171,36 @@ final class AccessTabTest extends TestCase (new AccessTab(new Access(), $connections))->handleRevoke(); } - public function testRenderShowsRolesAndConnections(): void + /** @param mixed $roles value of the roles option */ + private function stubRender(mixed $roles): void { - Functions\when('get_option')->justReturn(['editor']); + Functions\when('get_option')->alias(static fn (string $key, $default = false) => match ($key) { + 'lit_mcp_allowed_roles' => $roles, + 'date_format' => 'd.m.Y', + 'time_format' => 'H:i', + default => $default, + }); Functions\when('translate_user_role')->returnArg(); Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : ''); Functions\when('settings_fields')->justReturn(null); - Functions\when('submit_button')->alias(static function (): void { - echo ''; + Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = ''): void { + $attrs = is_array($other) ? implode(' ', array_map( + static fn ($k, $v): string => sprintf('%s="%s"', $k, $v), + array_keys($other), + $other + )) : ''; + echo ''; }); Functions\when('wp_nonce_field')->alias(static function (string $action): void { echo ''; }); - Functions\when('wp_date')->justReturn('25.09.2026 10:00'); - $connections = Mockery::mock(ConnectionRepository::class); - $connections->allows('all')->andReturn([[ + Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts); + } + + /** @param array $overrides */ + private function connection(array $overrides = []): array + { + return $overrides + [ 'user_id' => 3, 'user_login' => 'redaktion', 'uuid' => 'uuid-1', @@ -193,38 +208,73 @@ final class AccessTabTest extends TestCase 'app_id' => 'https://claude.ai/x', 'created' => 1790000000, 'last_used' => null, - ]]); + ]; + } + + private function renderTab(array $connections): string + { + $repository = Mockery::mock(ConnectionRepository::class); + $repository->allows('all')->andReturn($connections); ob_start(); - (new AccessTab(new Access(), $connections))->render(); - $html = (string) ob_get_clean(); + (new AccessTab(new Access(), $repository))->render(); + + return (string) ob_get_clean(); + } + + public function testRenderShowsRolesAndConnections(): void + { + $this->stubRender(['editor']); + + $html = $this->renderTab([$this->connection()]); self::assertStringContainsString('value="editor" checked="checked"', $html); self::assertStringNotContainsString('value="administrator" checked', $html); - self::assertStringContainsString('Claude <script>', $html); + self::assertStringContainsString('Claude <script>', $html); self::assertStringContainsString('redaktion<b>', $html); self::assertStringNotContainsString('", "after" => ""]]]) . ")$"; +$text = ["content" => ["innerContent" => ["desktop" => ["value" => "

    x

    "]]]]; +$head = ["title" => ["innerContent" => ["desktop" => ["value" => $v]]]]; +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash(serialize_block(["blockName" => "divi/text", "attrs" => $text, "innerBlocks" => [], + "innerHTML" => "", "innerContent" => []]) . serialize_block(["blockName" => "divi/heading", "attrs" => $head, + "innerBlocks" => [], "innerHTML" => "", "innerContent" => []]))]); +echo $id;') +CREATED_POSTS+=("$PROBE_ID") +PROBE_SELFTEST=$(probe_findings "$PROBE_ID") +for finding in "stored: script tag" "stored: event handler" "stored: data-probe attribute"; do + echo "$PROBE_SELFTEST" | grep -q "$finding" || fail "probe checker misses \"$finding\": $PROBE_SELFTEST" +done +wp post delete "$PROBE_ID" --force >/dev/null +connect "$ADMIN_LOGIN" + +# 1. Direct write tools with correct annotations +LIST=$(rpc tools/list '{}') +for t in lit-divi-insert lit-divi-update lit-divi-move lit-divi-delete; do + echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t" +done +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-delete") | .annotations.destructiveHint == true' >/dev/null \ + || fail "lit-divi-delete lacks destructiveHint" +if echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-replace-text" or .name == "lit-divi-duplicate-node")' >/dev/null; then + fail "lit-divi-replace-text and lit-divi-duplicate-node must be indirect" +fi +GUIDE=$(tool lit-guide '{"topic":"divi-shortform"}') +echo "$GUIDE" | jq -e '.content | contains("lit-divi-insert")' >/dev/null || fail "guide divi-shortform: $GUIDE" +echo "$INIT_RESPONSE" | grep -q 'tree_rev' || fail "server instructions lack the tree_rev rule" +pass "tools/list: Divi write tools direct, duplicate/replace-text indirect; guide and instructions" + +# 2. Insert a whole section in short form with placeholders +TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev') +INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1", + nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [ + [{type: "heading", text: "{{post:title}}", level: "h2"}], + [{type: "text", content: "

    Hallo Welt

    "}, + {type: "button", text: "Mehr erfahren", url: "javascript:alert(1)", label: "Neu – CTA"}]]}]}]}')") +[ "$(error_code "$INSERT")" = "validation_failed" ] || fail "javascript: URL accepted: $INSERT" +[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "refused insert wrote something" +INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1", + nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [ + [{type: "heading", text: "{{post:title}}", level: "h2"}], + [{type: "text", content: "

    Hallo Welt

    "}, + {type: "button", text: "Mehr erfahren", url: "/mehr", label: "Neu – CTA"}]]}]}]}')") +echo "$INSERT" | jq -e '.ok == true and .changed[0].id == "s1" and .revision_id != null and .log_id != null' >/dev/null \ + || fail "divi-insert: $INSERT" +TREE_REV=$(echo "$INSERT" | jq -r '.tree_rev') +[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "insert changed the bytes of an untouched section" +SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}") +echo "$SUMMARY" | jq -e '.headings[] | select(.level == 2 and .text == "LIT Divi Schreiben")' >/dev/null \ + || fail "{{post:title}} not rendered: $SUMMARY" +pass "lit-divi-insert: javascript: URL refused, section with placeholders, untouched bytes kept, rendered" + +# 3. Lock and all-or-nothing +STALE=$(tool lit-divi-update "{\"post_id\":$PAGE_ID,\"tree_rev\":\"000000000000\",\"changes\":[{\"id\":\"s0\",\"label\":\"x\"}]}") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale tree_rev accepted: $STALE" +BAD=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt"}}, {id: "s1.r0.c0.m0", attrs: {"title.decoration.fnt": {desktop: "x"}}}]}')") +[ "$(error_code "$BAD")" = "invalid_attr_path" ] || fail "invalid path accepted: $BAD" +[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "failed update wrote something" +pass "tree_rev lock and invalid_attr_path leave the page unchanged" + +# 4. Update, move, delete +UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt anfragen"}, attrs: {"button.decoration.background": {desktop: {color: "{{color:secondary}}"}}}}]}')") +echo "$UPDATE" | jq -e '.ok == true and .changed[0].action == "updated"' >/dev/null || fail "divi-update: $UPDATE" +TREE_REV=$(echo "$UPDATE" | jq -r '.tree_rev') +MOVE=$(tool lit-divi-move "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1.r0.c1.m1\",\"parent\":\"s1.r0.c0\"}") +echo "$MOVE" | jq -e '.changed[0].id == "s1.r0.c0.m1"' >/dev/null || fail "divi-move: $MOVE" +TREE_REV=$(echo "$MOVE" | jq -r '.tree_rev') +ASK=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"]}") +[ "$(error_code "$ASK")" = "confirm_required" ] || fail "delete without confirm: $ASK" +DELETE=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"],\"confirm\":true}") +echo "$DELETE" | jq -e '.changed[0].action == "deleted"' >/dev/null || fail "divi-delete: $DELETE" +TREE_REV=$(echo "$DELETE" | jq -r '.tree_rev') +[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "update/move/delete changed an untouched section" +pass "lit-divi-update, lit-divi-move, lit-divi-delete" + +# 5. Duplicate and replace text (indirect) +DUP=$(ability lit/divi-duplicate-node "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1\"}") +echo "$DUP" | jq -e '.changed[0].id == "s2" and (.changed[0].label | endswith("(Kopie)"))' >/dev/null || fail "duplicate: $DUP" +DRY=$(ability lit/divi-replace-text '{"search":"Köln","replace":"Bonn","limit":100}') +TARGET=$(echo "$DRY" | jq -c --argjson id "$PAGE_ID" '[.matches[] | select(.post_id == $id) | {post_id, tree_rev}]') +[ "$TARGET" != "[]" ] || fail "dry run found nothing: $DRY" +if content_of "$PAGE_ID" | grep -q 'Bonn'; then fail "dry run changed content"; fi +RUN=$(ability lit/divi-replace-text "$(jq -cn --argjson t "$TARGET" '{search: "Köln", replace: "Bonn", dry_run: false, confirm: true, targets: $t}')") +echo "$RUN" | jq -e '.results[0].ok == true and .results[0].count >= 1' >/dev/null || fail "replace-text: $RUN" +content_of "$PAGE_ID" | grep -q 'Bonn' || fail "replace-text wrote nothing" +pass "lit/divi-duplicate-node and lit/divi-replace-text (dry run, confirmed run)" + +# 6. New Divi page in one call; first section on an empty Divi page (ruling S9) +CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Neu","divi":[{"type":"section","label":"Start","modules":[{"type":"heading","text":"Neu hier"},{"type":"text","content":"

    Text

    "}]}]}') +NEW_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty') +[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED" +CREATED_POSTS+=("$NEW_ID") +echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED" +[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing" +[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft" +tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered" +EMPTY_ID=$(create_page "" "LIT Divi Leer") +CREATED_POSTS+=("$EMPTY_ID") +EMPTY=$(tool lit-divi-outline "{\"post_id\":$EMPTY_ID}") +echo "$EMPTY" | jq -e '.hints[0] | contains("lit-divi-insert")' >/dev/null || fail "empty-page hint: $EMPTY" +FIRST=$(tool lit-divi-insert "$(jq -cn --argjson id "$EMPTY_ID" --arg rev "$(echo "$EMPTY" | jq -r '.tree_rev')" '{post_id: $id, tree_rev: $rev, + nodes: [{type: "section", label: "Start", modules: [{type: "heading", text: "Erster Abschnitt"}]}]}')") +echo "$FIRST" | jq -e '.changed[0].id == "s0"' >/dev/null || fail "insert into empty page: $FIRST" +content_of "$EMPTY_ID" | grep -q '^, +# e.g. imported without Divi's filters): Divi normalizes untouched blocks too – the bytes +# change beyond the edit, the answer carries CHANGED_ON_SAVE, tree_rev is the stored state, +# custom fields survive (Divi 5.2.1 renders the custom attribute; the text module's wrapper +# ignores elementType), the page renders, and the next write is byte-stable again. +connect "$ADMIN_LOGIN" +FILTER_ID=$(create_page synthetic-divi-filters.html "LIT Divi Filter") +CREATED_POSTS+=("$FILTER_ID") +node_bytes() { # $1 = post ID, $2 = admin label -> the stored opener of that block + LIT_ID="$1" LIT_LABEL="$2" wp eval ' +$c = (string) get_post((int) getenv("LIT_ID"))->post_content; +$needle = "\"value\":\"" . getenv("LIT_LABEL") . "\""; +$at = strpos($c, $needle); +$start = strrpos(substr($c, 0, $at), "", $at) + 3 - $start);' +} +ARTICLE_BEFORE=$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel") +TREE_REV=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}" | jq -r '.tree_rev') +CANONICAL=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s0.r0.c0.m2", set: {text: "Schreiben"}}]}')") +echo "$CANONICAL" | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \ + || fail "8a: write on a Divi-stored page: $CANONICAL" +[ "$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")" = "$ARTICLE_BEFORE" ] || fail "8a: untouched block changed" +pass "Divi save filters, page as Divi stored it: only the edited node changes, no warning" + +LIT_ID="$FILTER_ID" LIT_FILE="$FIXTURES/synthetic-divi-filters.html" wp eval ' +global $wpdb; +$id = (int) getenv("LIT_ID"); +$wpdb->update($wpdb->posts, ["post_content" => (string) file_get_contents(getenv("LIT_FILE"))], ["ID" => $id]); +clean_post_cache($id);' +ARTICLE_BEFORE=$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel") +TREE_REV=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}" | jq -r '.tree_rev') +FILTERED=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s0.r0.c0.m1", set: {text: "Eigene Felder bleiben erhalten"}}]}')") +echo "$FILTERED" | jq -e '.ok == true' >/dev/null || fail "8b: update on a page with custom fields: $FILTERED" +echo "$FILTERED" | jq -e '.warnings | any(contains("beim Speichern verändert"))' >/dev/null \ + || fail "8b: CHANGED_ON_SAVE missing (Divi no longer normalizes – update the S7 documentation): $FILTERED" +[ "$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")" != "$ARTICLE_BEFORE" ] \ + || fail "8b: untouched block kept its bytes (Divi no longer normalizes – update the S7 documentation)" +OUTLINE=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}") +[ "$(echo "$OUTLINE" | jq -r '.tree_rev')" = "$(echo "$FILTERED" | jq -r '.tree_rev')" ] \ + || fail "8b: tree_rev of the answer is not the stored state: $FILTERED" +NODE=$(tool lit-divi-node-get "{\"post_id\":$FILTER_ID,\"id\":\"s0.r0.c0.m0\"}") +echo "$NODE" | jq -e 'tostring | contains("article") and contains("data-track")' >/dev/null \ + || fail "8b: elementType or custom attribute lost: $NODE" +RENDERED=$(LIT_ID="$FILTER_ID" wp --user="$ADMIN_LOGIN" eval \ + 'echo (new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render(get_post((int) getenv("LIT_ID")));') +echo "$RENDERED" | grep -q 'data-track="hero"' || fail "8b: custom attribute not rendered" +echo "$RENDERED" | grep -q 'Eigene Felder bleiben erhalten' || fail "8b: update not rendered" +AGAIN=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$(echo "$OUTLINE" | jq -r '.tree_rev')" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s0.r0.c0.m2", set: {text: "Nochmal"}}]}')") +echo "$AGAIN" | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \ + || fail "8b: follow-up write with the new tree_rev: $AGAIN" +pass "Divi save filters, other byte form: normalized with CHANGED_ON_SAVE, tree_rev = stored state, page valid, then stable" + +echo "ALL CHECKS PASSED" diff --git a/tests/Unit/Guides/InstructionsBuilderTest.php b/tests/Unit/Guides/InstructionsBuilderTest.php index 34d7e3c..9cac3b0 100644 --- a/tests/Unit/Guides/InstructionsBuilderTest.php +++ b/tests/Unit/Guides/InstructionsBuilderTest.php @@ -57,7 +57,9 @@ final class InstructionsBuilderTest extends TestCase $without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); + self::assertStringContainsString('tree_rev', $with); self::assertStringNotContainsString('lit-divi-outline und', $without); + self::assertStringNotContainsString('tree_rev', $without); self::assertLessThan(2000, strlen($with)); } diff --git a/tests/Unit/Guides/ToolNamingTest.php b/tests/Unit/Guides/ToolNamingTest.php index e836747..19c5f0a 100644 --- a/tests/Unit/Guides/ToolNamingTest.php +++ b/tests/Unit/Guides/ToolNamingTest.php @@ -96,6 +96,40 @@ final class ToolNamingTest extends TestCase } } + /** Every guide topic named in a description, hint or the overview exists as docs/guides/.md. */ + public function testReferencedGuideTopicsExist(): void + { + $root = dirname(__DIR__, 3); + $referenced = []; + foreach (self::texts() as $path => $text) { + if (str_ends_with($path, '.php')) { + preg_match_all('/topic \\\\?"([a-z0-9-]+)\\\\?"/', $text, $matches); + $referenced = array_merge($referenced, $matches[1]); + } + } + + self::assertContains('divi-shortform', $referenced, 'lit/divi-insert and lit/content-create name divi-shortform'); + foreach (array_unique($referenced) as $topic) { + self::assertFileExists($root . '/docs/guides/' . $topic . '.md', 'guide topic "' . $topic . '"'); + } + } + + public function testOverviewListsExactlyTheOtherGuideTopics(): void + { + $root = dirname(__DIR__, 3); + $overview = (string) file_get_contents($root . '/docs/guides/overview.md'); + preg_match('/Weitere Themen:(.*?)\n\n/s', $overview, $section); + preg_match_all('/`([a-z0-9-]+)`/', $section[1] ?? '', $listed); + $listedTopics = array_values(array_diff($listed[1], ['topic', 'lit-guide'])); + sort($listedTopics); + + $files = array_map(static fn (string $f): string => basename($f, '.md'), glob($root . '/docs/guides/*.md') ?: []); + $files = array_values(array_diff($files, ['overview'])); + sort($files); + + self::assertSame($files, $listedTopics); + } + public function testOverviewListsExactlyTheDirectTools(): void { $overview = (string) file_get_contents(dirname(__DIR__, 3) . '/docs/guides/overview.md'); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 066025a..df93599 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -84,11 +84,12 @@ final class DiviReadAbilitiesTest extends TestCase { DiviPageLoaderTest::givenPost(''); $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); - self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']); + self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]); DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html')); $hints = $ability->execute(['post_id' => 7])['hints']; self::assertStringContainsString('nur lesbar', $hints[0]); + self::assertStringContainsString('nur als Ganzes', $hints[0]); } public function testNodeGetReturnsFullFormShortformAndPresets(): void diff --git a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php index 2367d7c..31d7eb3 100644 --- a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php @@ -35,6 +35,8 @@ final class DiviCatalogAbilityTest extends TestCase )); self::assertFalse($header[0]['shortform']); self::assertSame(DiviCatalogAbility::CODE_NOTE, $result['notes'][0]); + self::assertStringContainsString('"divi-shortform"', $result['notes'][1]); + self::assertStringContainsString('"divi-attrs"', $result['notes'][1]); } public function testDetailOfOneModule(): void diff --git a/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php b/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php index 3daefcb..8c1fed3 100644 --- a/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php +++ b/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php @@ -97,6 +97,10 @@ final class PostPresenterTest extends TestCase { self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true)); self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true)); + foreach (['lit-divi-insert', 'lit-divi-update', 'lit-divi-move', 'lit-divi-delete'] as $tool) { + self::assertStringContainsString($tool, PostPresenter::diviHint(true)); + } + self::assertStringNotContainsString('vorerst im Divi-Builder', PostPresenter::diviHint(true)); self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false)); self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint()); } diff --git a/wp-lit-mcp.php b/wp-lit-mcp.php index 55a52d0..8c8ed45 100644 --- a/wp-lit-mcp.php +++ b/wp-lit-mcp.php @@ -3,7 +3,7 @@ /** * Plugin Name: WP-LIT-MCP * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. - * Version: 0.2.0 + * Version: 0.3.0 * Requires at least: 6.9 * Requires PHP: 8.1 * Tested up to: 7.0 From e0ab6f53e54283b8fcf06f1076de065a58c9d577 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:17:55 +0200 Subject: [PATCH 133/185] docs: D11 superseded by the single Divi save path (Task 9) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md index ca897c0..ba4441a 100644 --- a/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md +++ b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md @@ -15,4 +15,5 @@ Getroffen bei der Planung von Etappe 2a/2b, 2026-09-26. Kosten, falls falsch, je - D9: Platzhalter stehen allein im Wert; Überschriften der Kurzform ohne `level` werden `h2`. – Kosten: eingebettete dynamische Texte nur über die Vollform. - D10: `lit/divi-replace-text` je Inhaltstyp; Probelauf liefert `tree_rev` je Seite, Ausführung nur auf diesen `targets` mit `confirm: true`; ersetzt nur sichtbaren Text (`…innerContent`, Schlüssel text/title/label/alt/titleText), nie URLs oder Stile. – Kosten: Text mit HTML-Entitäten muss so gesucht werden, wie er gespeichert ist. - D11: `lit/content-create` mit `divi` speichert über `wp_insert_post` mit WordPress' üblicher Filterung (nur neuer, selbst erzeugter Inhalt – nichts Unberührtes, das byte-identisch bleiben müsste). – Kosten: für Benutzer ohne `unfiltered_html` kann WordPress Entitäten normalisieren; `tree_rev` in der Antwort beschreibt den gespeicherten Stand. + - **Überholt (Etappe 2b, Task 9):** Titel, Auszug und übrige Felder laufen weiter durch `wp_insert_post` mit üblicher Filterung; das Divi-Layout wird danach über `DiviSaver::saveNew` im selben kses-Fenster wie alle Divi-Schreibvorgänge gespeichert (D7, ein einziger Speicherweg). Grund: WordPress' kses würde die `$variable`-Verweise und Blockattribute sonst für Benutzer ohne `unfiltered_html` verändern; bereinigt wird vorher je Feldart (AttrWriter/VariableRef). - D12: Fixtures: handgeschriebene Grenzfälle (`synthetic-*.html`), mit Divis eigener Konvertierung erzeugte Seiten (`converted-*.html`), echte Modul-Metadaten (`metadata-sample.json`); im Visual Builder gebaute Seiten exportiert Jan-Niclas später mit `tests/Integration/divi-export-page.php` als `builder-*.html` – sie laufen automatisch im Roundtrip-Test mit. From e005d83a3d1987b37a3558fa9acb7e66e213e196 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:26:04 +0200 Subject: [PATCH 134/185] docs: S7 exception applies only to pages not yet in Divi's saved form Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-attrs.md | 2 +- docs/guides/divi-placeholders.md | 2 +- docs/guides/divi-shortform.md | 2 +- docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md | 1 + docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md | 4 ++-- readme.txt | 4 +++- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md index 5d9d919..9de9be7 100644 --- a/docs/guides/divi-attrs.md +++ b/docs/guides/divi-attrs.md @@ -41,7 +41,7 @@ Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS und HTML-Attribute (`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten -mit eigenen HTML-/Attribut-Feldern beim Speichern (Warnung in der Antwort, neue `tree_rev` gilt). +mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt). ## Pfade finden diff --git a/docs/guides/divi-placeholders.md b/docs/guides/divi-placeholders.md index 16fb767..571e0e9 100644 --- a/docs/guides/divi-placeholders.md +++ b/docs/guides/divi-placeholders.md @@ -43,5 +43,5 @@ dynamischen Quellen; Zusatztexte davor/danach (`before`/`after`) werden bereinig Verweis mitten im Text, eigenes HTML-Ausgeben von Feldern) lehnt das Werkzeug mit `validation_failed` ab. Unberührte Teile der Seite bleiben dabei byte-identisch. Ausnahme: Divi selbst normalisiert Seiten mit -eigenen HTML-/Attribut-Feldern beim Speichern; die Antwort warnt dann, dass der Inhalt beim Speichern +eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie; die Antwort warnt dann, dass der Inhalt beim Speichern verändert wurde, und die neue `tree_rev` gilt. diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index 45333ad..8ca822a 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -102,7 +102,7 @@ HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`. `$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS und HTML-Attribute eines Moduls (`…decoration.attributes`, `…advanced.html`, `…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern -beim Speichern (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` +beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` weiterarbeiten). ## Warnungen diff --git a/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md index ba4441a..0bca87c 100644 --- a/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md +++ b/docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md @@ -11,6 +11,7 @@ Getroffen bei der Planung von Etappe 2a/2b, 2026-09-26. Kosten, falls falsch, je - D7 (Ruling R1): Divi-Speichern ohne kses für Benutzer ohne `unfiltered_html` (sonst re-serialisiert WordPress jeden Block) ist nur vertretbar, weil keine client-gelieferte Zeichenkette ungeprüft in `post_content` gelangt. Klartext-Werte werden je Feldtyp bereinigt (Text: `sanitize_text_field`; Rich-Text: `wp_kses_post`, abhängig von `unfiltered_html`; URLs: `esc_url_raw`, geroutet über die Attribut-Metadaten des Katalogs, ersatzweise über den Schlüsselnamen). Werte mit `$variable(...)$` – ob aus Platzhaltern erzeugt oder unverändert aus `lit/divi-node-get` zurückgegeben – werden geparst, streng geprüft (zulässiger Typ, existierende gcid-/gvid-/Dynamic-Content-Quellennamen, whitelisted Settings-Schlüssel, String-Settings bereinigt) und vom Plugin neu serialisiert; alles andere wird mit `validation_failed` abgelehnt. Code-Modul ungefiltert nur mit `unfiltered_html`. – Kosten: jeder neue Schreibpfad muss bereinigen, streng validieren und neu serialisieren (Review-Pflicht). - R2 (Ruling): Ob Divi das Dynamic-Content-`before`/`after` bei der Ausgabe selbst escaped, wird in Etappe 2b im Divi-Quellcode verifiziert; die Bereinigung aus D7 gilt unabhängig vom Ergebnis (Verteidigung in der Tiefe). – Kosten: keine (Verifikation, keine Abhängigkeit der Sicherheit vom Ergebnis). - R2 – Befund (Etappe 2b, Divi 5.2.1, Ruling S5): (1) Dynamic-Content-`before`/`after` wird bei der Ausgabe nur dann mit `wp_kses` gefiltert, wenn der **Autor des Beitrags** kein `unfiltered_html` hat (`DynamicContentElements::get_wrapper_element`); auf Seiten eines Administrators gelangt es ungefiltert ins HTML – ein Redakteur könnte dort Skript platzieren. (2) `enable_html: on` gibt Meta-Werte mit `wp_kses_post` statt `esc_html` aus; Divi entfernt `enable_html` beim Speichern nur für Benutzer ohne `unfiltered_html` (`Security::sanitize_dynamic_content_fields`, `wp_insert_post_data` Priorität 10). (3) `custom_meta_` wird ohne `is_protected_meta`-Prüfung ausgegeben (`DynamicContentOptionPostMetaKey`). (4) Farbverweis-Namen gehen unescaped in `var(--)` (`StyleLibrary\Utils::resolve_dynamic_variable`). (5) Divis eigene `wp_insert_post_data`-Filter sind kein kses und laufen auch bei unserem Speichern ohne kses: `AttributeSecurity` (Priorität 5, immer, sobald `"decoration":{` und `"attributes":{` vorkommen) und `HtmlSecurity` (Priorität 6, bei `htmlBefore`/`htmlAfter`/`elementType`) parsen und re-serialisieren die **ganze Seite**; `DynamicContentFixes` (Priorität 10). Folgen: die Plugin-Prüfung aus D7 ist die eigentliche Garantie (VariableRef: nur ganze, geprüfte, neu serialisierte Verweise; `before`/`after` ohne unzulässiges HTML, kein `enable_html`, keine Meta-Schlüssel mit `_`); Divis Filter bleiben aktiv (Verteidigung in der Tiefe) und werden nie entfernt; Schreiben auf `*.decoration.attributes` und `*.advanced.html` nur mit `unfiltered_html` („nur im Divi-Builder“, Ruling S7), und auf Seiten mit diesen Feldern gilt die Byte-Invarianz nicht (Divi normalisiert beim Speichern). – Kosten: keine (Dokumentation; die Sicherheit hängt nicht von Divis Ausgabe ab). + - Nachtrag (Etappe 2b, Task 10, live): Divis `AttributeSecurity`/`HtmlSecurity` serialisieren mit `serialize_blocks()`; Bytes ändern sich nur, wenn die Seite noch nicht in dieser Form gespeichert war (z. B. Import, direkter DB-Schreibzugriff). Über Builder oder LIT MCP gespeicherte Seiten bleiben außerhalb der Änderung byte-identisch (E2E `divi-write-smoke.sh` 8a/8b). - D8: Spalten neuer Zeilen über `module.decoration.sizing.desktop.value.flexType` (`"1_3,2_3"` → `8_24`, `16_24`; Fünftel und 1/7, 1/9, 1/10, 1/11 nativ), wie Divi 5.2 (Flex-Layout ist Standard). – Kosten: falls der Builder anders speichert, nur Composer anpassen. - D9: Platzhalter stehen allein im Wert; Überschriften der Kurzform ohne `level` werden `h2`. – Kosten: eingebettete dynamische Texte nur über die Vollform. - D10: `lit/divi-replace-text` je Inhaltstyp; Probelauf liefert `tree_rev` je Seite, Ausführung nur auf diesen `targets` mit `confirm: true`; ersetzt nur sichtbaren Text (`…innerContent`, Schlüssel text/title/label/alt/titleText), nie URLs oder Stile. – Kosten: Text mit HTML-Entitäten muss so gesucht werden, wie er gespeichert ist. diff --git a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md index fc03b61..fe3b4a8 100644 --- a/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md +++ b/docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md @@ -18,7 +18,7 @@ Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, m 1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen. 2. Von Claude erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe). -3. Unangetastete Inhalte bleiben byte-identisch erhalten. Dokumentierte Ausnahme (Ruling S7): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`AttributeSecurity`/`HtmlSecurity` auf `wp_insert_post_data`, laufen für jeden Benutzer und werden nie entfernt); die Antwort meldet das als Warnung „Inhalt beim Speichern verändert“ (`CHANGED_ON_SAVE`), die Seite bleibt gültig (E2E `divi-write-smoke.sh` Abschnitt 8). +3. Unangetastete Inhalte bleiben byte-identisch erhalten. Dokumentierte Ausnahme (Ruling S7): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`AttributeSecurity`/`HtmlSecurity` auf `wp_insert_post_data`, laufen für jeden Benutzer und werden nie entfernt) – aber nur, wenn die Seite noch nicht in Divis `serialize_blocks()`-Form gespeichert war (z. B. Import, direkter DB-Schreibzugriff); über Builder oder LIT MCP gespeicherte Seiten bleiben unverändert (E2E Abschnitt 8a); die Antwort meldet das als Warnung „Inhalt beim Speichern verändert“ (`CHANGED_ON_SAVE`), die Seite bleibt gültig (E2E `divi-write-smoke.sh` Abschnitt 8). 4. Eine neue Website (Seitenstruktur, Menü, Einstellungen, Designsystem aus einem CD) lässt sich im Dialog aufsetzen. 5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot). @@ -171,7 +171,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_ - Parst `post_content` in einen Baum (auf Basis von `parse_blocks`, mit Sonderbehandlung für `divi/global-layout` und `globalModule`, die `parse_blocks` laut Divi-Code nicht korrekt verarbeitet). - Serialisiert den Baum zurück; unveränderte Knoten werden aus ihrem Original-Markup übernommen. -- **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt. Ausnahme beim Speichern (nicht im BlockTree): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`module.decoration.attributes`, `module.advanced.html` mit `htmlBefore`/`htmlAfter`/`elementType`) – Divi parst und serialisiert dann die ganze Seite neu; gemeldet als `CHANGED_ON_SAVE` (Ruling S7). +- **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt. Ausnahme beim Speichern (nicht im BlockTree): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`module.decoration.attributes`, `module.advanced.html` mit `htmlBefore`/`htmlAfter`/`elementType`) – Divi parst und serialisiert dann die ganze Seite neu, was nur Bytes ändert, die noch nicht in Divis `serialize_blocks()`-Form vorlagen; gemeldet als `CHANGED_ON_SAVE` (Ruling S7). - Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar). - Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`). - Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert. diff --git a/readme.txt b/readme.txt index e67342e..9ce5b29 100644 --- a/readme.txt +++ b/readme.txt @@ -47,7 +47,9 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben aktiv. * Unberührte Teile einer Divi-Seite bleiben beim Speichern byte-identisch. Ausnahme: Divi selbst normalisiert - Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (die Antwort warnt dann, dass der Inhalt beim + Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern, sofern sie noch nicht in Divis eigener + gespeicherter Form vorliegen (z. B. importierter Inhalt); über den Divi-Builder oder LIT MCP gespeicherte + Seiten betrifft das praktisch nie (die Antwort warnt dann, dass der Inhalt beim Speichern verändert wurde). * Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert. * Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar From b6984d3f9ad1f24fb92b75427982672c0e5b223e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:36:01 +0200 Subject: [PATCH 135/185] refactor: one logging helper LIT\MCP\Log (M6) Log::failure(context, detail) keeps the '[wp-lit-mcp] failed: ' format of the nine former error_log() sites; Log::line() covers the change log line. One phpcs ignore instead of eleven. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Abilities/AbilityRegistrar.php | 10 ++-- src/Abilities/FollowUp.php | 4 +- src/ChangeLog/ChangeLog.php | 4 +- .../Divi/Pages/DiviPageLoader.php | 4 +- .../Divi/Pages/DiviReplaceTextAbility.php | 4 +- .../Divi/Pages/DynamicContentMap.php | 8 +-- src/Integrations/Media/MediaImporter.php | 4 +- src/Integrations/Media/MediaUsageFinder.php | 4 +- src/Log.php | 29 ++++++++++ src/Server/ServerRegistrar.php | 4 +- tests/Unit/LogTest.php | 53 +++++++++++++++++++ 11 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 src/Log.php create mode 100644 tests/Unit/LogTest.php diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php index 439044a..0e84f0e 100644 --- a/src/Abilities/AbilityRegistrar.php +++ b/src/Abilities/AbilityRegistrar.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Abilities; use LIT\MCP\Access; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Log; use LIT\MCP\Server\RequestContext; use Throwable; use WP_Error; @@ -64,8 +65,7 @@ final class AbilityRegistrar try { return $ability->permission(is_array($input) ? $input : []); } catch (Throwable $e) { - $message = sprintf('[wp-lit-mcp] %s failed: %s', $ability->name(), $e); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure($ability->name(), $e); return Errors::internal(); } }, @@ -73,12 +73,10 @@ final class AbilityRegistrar try { return $ability->execute(is_array($input) ? $input : []); } catch (WriteFailed $e) { - $message = sprintf('[wp-lit-mcp] %s failed: %s', $ability->name(), $e); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure($ability->name(), $e); return $e->toError(); } catch (Throwable $e) { - $message = sprintf('[wp-lit-mcp] %s failed: %s', $ability->name(), $e); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure($ability->name(), $e); return Errors::internal(); } }, diff --git a/src/Abilities/FollowUp.php b/src/Abilities/FollowUp.php index 4c641bb..039f79b 100644 --- a/src/Abilities/FollowUp.php +++ b/src/Abilities/FollowUp.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Abilities; +use LIT\MCP\Log; use Throwable; /** @@ -20,8 +21,7 @@ final class FollowUp try { return $step(); } catch (Throwable $e) { - $message = sprintf('[wp-lit-mcp] %s failed: %s', $context, $e); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure($context, $e); $warnings[] = $warning; return null; diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php index ae0e712..574c2db 100644 --- a/src/ChangeLog/ChangeLog.php +++ b/src/ChangeLog/ChangeLog.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\ChangeLog; use LIT\MCP\Abilities\FollowUp; +use LIT\MCP\Log; use LIT\MCP\Server\RequestContext; /** @@ -57,8 +58,7 @@ class ChangeLog ); if ($inserted === false) { - $message = sprintf('[wp-lit-mcp] change log insert failed for %s %s:%s', $ability, $objectType, $objectId); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId)); return null; } diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 1306dc6..08d06f1 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Log; use WP_Error; use WP_Post; @@ -51,8 +52,7 @@ class DiviPageLoader try { $document = $this->parser->parse($content); } catch (MalformedMarkup $e) { - $message = sprintf('[wp-lit-mcp] divi parse of post %d failed: %s', $post->ID, $e->getMessage()); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure(sprintf('divi parse of post %d', $post->ID), $e->getMessage()); return Errors::unsupported( sprintf( diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index fdfd2b9..e37b66f 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -15,6 +15,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Compose\TextReplacer; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Log; use Throwable; use WP_Error; @@ -484,8 +485,7 @@ final class DiviReplaceTextAbility implements Ability private static function log(int $postId, Throwable $e): void { - $message = sprintf('[wp-lit-mcp] lit/divi-replace-text on post %d failed: %s', $postId, $e); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e); } private function db(): \wpdb diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index aaea737..369aa72 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use Closure; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\VariableRef; +use LIT\MCP\Log; use Throwable; /** @@ -43,12 +44,7 @@ class DynamicContentMap try { $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); } catch (Throwable $e) { - $message = sprintf( - '[wp-lit-mcp] divi dynamic content options of post %d failed: %s', - $postId, - $e->getMessage() - ); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure(sprintf('divi dynamic content options of post %d', $postId), $e->getMessage()); return null; } diff --git a/src/Integrations/Media/MediaImporter.php b/src/Integrations/Media/MediaImporter.php index 08b49a3..fc0838a 100644 --- a/src/Integrations/Media/MediaImporter.php +++ b/src/Integrations/Media/MediaImporter.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Media; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Limits; +use LIT\MCP\Log; use Throwable; use WP_Error; @@ -197,8 +198,7 @@ class MediaImporter ); if ($attachmentId instanceof WP_Error) { // WordPress' message can contain server paths: only for the log, not for the client. - $message = sprintf('[wp-lit-mcp] media sideload failed: %s', $attachmentId->get_error_message()); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure('media sideload', $attachmentId->get_error_message()); return Errors::writeFailed( 'WordPress hat die Datei nicht übernommen (z. B. Upload-Verzeichnis nicht beschreibbar).' diff --git a/src/Integrations/Media/MediaUsageFinder.php b/src/Integrations/Media/MediaUsageFinder.php index ec10818..9340092 100644 --- a/src/Integrations/Media/MediaUsageFinder.php +++ b/src/Integrations/Media/MediaUsageFinder.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Media; use Closure; +use LIT\MCP\Log; /** * Finds where an attachment is used (spec §6.3). Deliberately errs on the side of "used": @@ -165,8 +166,7 @@ class MediaUsageFinder } $this->queryFailed = true; - $message = sprintf('[wp-lit-mcp] media usage query failed: %s', (string) $wpdb->last_error); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure('media usage query', (string) $wpdb->last_error); return []; } diff --git a/src/Log.php b/src/Log.php new file mode 100644 index 0000000..9a79ed3 --- /dev/null +++ b/src/Log.php @@ -0,0 +1,29 @@ + failed: "; a Throwable is logged with its trace. */ + public static function failure(string $context, Throwable|string $detail): void + { + self::line(sprintf('%s failed: %s', $context, (string) $detail)); + } + + /** Logs one free-form line with the plugin prefix. */ + public static function line(string $message): void + { + error_log(self::PREFIX . $message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } +} diff --git a/src/Server/ServerRegistrar.php b/src/Server/ServerRegistrar.php index 7516852..5df84a2 100644 --- a/src/Server/ServerRegistrar.php +++ b/src/Server/ServerRegistrar.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Server; use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Log; use LIT\MCP\Plugin; use WP\MCP\Core\McpAdapter; use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler; @@ -131,8 +132,7 @@ final class ServerRegistrar $result = ($this->createAdapterServer)($args); if (is_wp_error($result)) { - $message = '[wp-lit-mcp] MCP server registration failed: ' . $result->get_error_message(); - error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + Log::failure('MCP server registration', $result->get_error_message()); } } diff --git a/tests/Unit/LogTest.php b/tests/Unit/LogTest.php new file mode 100644 index 0000000..d4bf86e --- /dev/null +++ b/tests/Unit/LogTest.php @@ -0,0 +1,53 @@ + Log::failure('media usage query', 'Table gone')); + + self::assertStringContainsString('[wp-lit-mcp] media usage query failed: Table gone', $content); + } + + public function testFailureLogsAThrowableWithItsTrace(): void + { + $e = new RuntimeException('kaputt'); + $content = self::captured(static fn () => Log::failure('lit/x', $e)); + + self::assertStringContainsString('[wp-lit-mcp] lit/x failed: RuntimeException: kaputt', $content); + self::assertStringContainsString('Stack trace', $content); + } + + public function testLineOnlyAddsThePrefix(): void + { + $content = self::captured(static fn () => Log::line('change log insert failed for a b:1')); + + self::assertStringContainsString('[wp-lit-mcp] change log insert failed for a b:1', $content); + } +} From fb6eabf80c553c552c0884b483a1678c3c20c037 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:36:23 +0200 Subject: [PATCH 136/185] refactor: one wpdb accessor style (L4) The three lazy db() methods use the $this->db ?? $GLOBALS['wpdb'] one-liner that SampledMetaSource, MediaUsageFinder and ConnectionRepository already use. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ChangeLog/ChangeLog.php | 7 +------ src/Integrations/Divi/Pages/DiviReplaceTextAbility.php | 7 +------ src/Integrations/WordPress/Status/PostCloner.php | 7 +------ 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php index 574c2db..0ef0c0a 100644 --- a/src/ChangeLog/ChangeLog.php +++ b/src/ChangeLog/ChangeLog.php @@ -181,11 +181,6 @@ class ChangeLog protected function db(): \wpdb { - if ($this->db === null) { - global $wpdb; - $this->db = $wpdb; - } - - return $this->db; + return $this->db ?? $GLOBALS['wpdb']; } } diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index e37b66f..f7189da 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -490,11 +490,6 @@ final class DiviReplaceTextAbility implements Ability private function db(): \wpdb { - if ($this->db === null) { - global $wpdb; - $this->db = $wpdb; - } - - return $this->db; + return $this->db ?? $GLOBALS['wpdb']; } } diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php index fdfa993..ed4a991 100644 --- a/src/Integrations/WordPress/Status/PostCloner.php +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -362,11 +362,6 @@ class PostCloner private function db(): \wpdb { - if ($this->db === null) { - global $wpdb; - $this->db = $wpdb; - } - - return $this->db; + return $this->db ?? $GLOBALS['wpdb']; } } From 8e5ba9eac435b7e427100ca612c46ea13508259d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:40:32 +0200 Subject: [PATCH 137/185] refactor: WriteFailed named constructors, Rollback runner, Errors read-back helpers (H6) - WriteFailed::rolledBack()/partial() replace the 11-parameter constructor (now private); the hint/state of either error are plain $hint/$state. - Rollback::attempt(...steps) runs every undo step and collects failure messages; used by ContentCreate::discard, ContentUpdate::rollBack, MediaUpdate::rollBack and DiviSaver::restore. - Errors::clientMessage/codeOf/fieldsOf replace DiviReplaceTextAbility::reason, the JSON digging in MediaUpdateAbility::failure and ContentCreateAbility::fields. - Errors docblock documents the exception/WP_Error split. - ContentCreateAbility::discard takes the client reason as a parameter (both callers still pass REASON_EXTRAS; changing the Divi text needs a ruling). Error payloads unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Abilities/Errors.php | 34 +++++++++ src/Abilities/Rollback.php | 33 +++++++++ src/Abilities/WriteFailed.php | 70 ++++++++++++++---- .../Divi/Pages/DiviReplaceTextAbility.php | 14 +--- src/Integrations/Divi/Pages/DiviSaver.php | 31 ++++---- src/Integrations/Media/MediaImporter.php | 19 ++--- src/Integrations/Media/MediaUpdateAbility.php | 33 ++++----- .../Content/ContentCreateAbility.php | 73 ++++++++----------- .../Content/ContentUpdateAbility.php | 35 +++------ .../WordPress/Menus/MenuWriter.php | 25 ++++--- .../Settings/WpSettingsUpdateAbility.php | 15 ++-- .../WordPress/Status/PostCloner.php | 12 +-- .../Status/PostReplaceWithCopyAbility.php | 22 +++--- .../WordPress/Status/StatusWrite.php | 13 ++-- tests/Unit/Abilities/AbilityRegistrarTest.php | 7 +- tests/Unit/Abilities/ErrorsTest.php | 20 +++++ tests/Unit/Abilities/RollbackTest.php | 54 ++++++++++++++ tests/Unit/Abilities/WriteFailedTest.php | 41 ++++++++--- .../Divi/Pages/DiviReplaceTextAbilityTest.php | 2 +- .../Divi/Pages/DiviWriterTest.php | 4 +- .../Media/MediaUploadAbilitiesTest.php | 2 +- .../Menus/MenuSetItemsAbilityTest.php | 4 +- .../Status/PostReplaceWithCopyAbilityTest.php | 2 +- 23 files changed, 358 insertions(+), 207 deletions(-) create mode 100644 src/Abilities/Rollback.php create mode 100644 tests/Unit/Abilities/RollbackTest.php diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index a5d1da7..3b6f6c2 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -12,6 +12,14 @@ use WP_Error; * mcp-adapter 0.5 turns a WP_Error into a tool result with isError: true but * forwards only the message text. The message therefore carries the whole * structured error as JSON: {code, message, hint, fields?, usages?, …} (spec §8.2). + * clientMessage(), codeOf() and fieldsOf() read such an error back (batch results that list + * per-item errors, merged validation errors). + * + * How failures travel: pure helpers (e.g. the Divi Compose classes) throw + * InvalidArgumentException, which the caller collects into `fields`; abilities return a + * WP_Error from this factory; writes that may have left something behind throw WriteFailed + * (rolledBack()/partial()), which the AbilityRegistrar maps via toError(); internal steps + * throw RuntimeException, which is logged and becomes internal_error. */ final class Errors { @@ -190,6 +198,32 @@ final class Errors ); } + /** The client message of an error (its JSON "message"), without the JSON around it. */ + public static function clientMessage(WP_Error $error): string + { + $data = json_decode($error->get_error_message(), true); + + return is_array($data) && is_string($data['message'] ?? null) ? $data['message'] : $error->get_error_message(); + } + + /** The error code as the client sees it (without the "lit_" prefix of the WP_Error code). */ + public static function codeOf(WP_Error $error): string + { + return (string) preg_replace('/^lit_/', '', (string) $error->get_error_code()); + } + + /** + * The `fields` of a validation error (input path => problem); [] when it has none. + * + * @return array + */ + public static function fieldsOf(WP_Error $error): array + { + $data = $error->get_error_data(); + + return is_array($data) && is_array($data['fields'] ?? null) ? $data['fields'] : []; + } + public static function internal(): WP_Error { return self::make( diff --git a/src/Abilities/Rollback.php b/src/Abilities/Rollback.php new file mode 100644 index 0000000..2a16166 --- /dev/null +++ b/src/Abilities/Rollback.php @@ -0,0 +1,33 @@ + The messages of the steps that threw, in step order. Never throws. + */ + public static function attempt(callable ...$steps): array + { + $failures = []; + foreach ($steps as $step) { + try { + $step(); + } catch (Throwable $e) { + $failures[] = $e->getMessage(); + } + } + + return $failures; + } +} diff --git a/src/Abilities/WriteFailed.php b/src/Abilities/WriteFailed.php index 4fde2cf..472acdc 100644 --- a/src/Abilities/WriteFailed.php +++ b/src/Abilities/WriteFailed.php @@ -12,27 +12,28 @@ use WP_Error; * Thrown by write paths when saving failed after something may already have been * written. getMessage() is the internal detail for the PHP error log; $reason is the * client-safe German text. The AbilityRegistrar logs the detail and returns toError(): - * lit_write_failed when everything was rolled back, lit_partial_write otherwise. - * $state/$hint customise the partial-write error for writes that are not about a post (see - * Errors::partialWrite()); $details are added to either error. $rolledBackHint replaces the - * default hint of the write_failed error, $rolledBackState its "Nichts wurde gespeichert." - * (for rollbacks that deliberately keep a backup or revision). + * lit_write_failed when everything was rolled back (rolledBack()), lit_partial_write + * otherwise (partial()). + * + * $detail is the log text (default: the cause's message, else the reason); $details are + * added to either error. For rolledBack(), $hint replaces the default hint and $state the + * "Nichts wurde gespeichert." (for rollbacks that deliberately keep a backup or revision, + * see Errors::notSavedKeeping()). For partial(), $state and $hint replace the post-oriented + * defaults for writes that are not about a post (see Errors::partialWrite()). */ final class WriteFailed extends RuntimeException { /** @param array $details Extra error details for the client. */ - public function __construct( + private function __construct( public readonly string $reason, public readonly bool $rolledBack, - public readonly ?int $postId = null, - public readonly ?int $revisionId = null, - ?Throwable $previous = null, - string $detail = '', - public readonly ?string $state = null, - public readonly ?string $hint = null, - public readonly array $details = [], - public readonly ?string $rolledBackHint = null, - public readonly ?string $rolledBackState = null + public readonly ?int $postId, + public readonly ?int $revisionId, + ?Throwable $previous, + string $detail, + public readonly ?string $state, + public readonly ?string $hint, + public readonly array $details ) { $internal = $detail !== '' ? $detail : ($previous !== null ? $previous->getMessage() : $reason); parent::__construct( @@ -42,10 +43,47 @@ final class WriteFailed extends RuntimeException ); } + /** + * Everything was restored (lit_write_failed). $postId/$revisionId only name the entry for + * callers that log it; the client error does not carry them. + * + * @param array $details + */ + public static function rolledBack( + string $reason, + ?Throwable $cause = null, + string $detail = '', + ?string $hint = null, + ?string $state = null, + array $details = [], + ?int $postId = null, + ?int $revisionId = null + ): self { + return new self($reason, true, $postId, $revisionId, $cause, $detail, $state, $hint, $details); + } + + /** + * The rollback was incomplete – something may have been saved (lit_partial_write). + * + * @param array $details + */ + public static function partial( + string $reason, + ?Throwable $cause = null, + string $detail = '', + ?int $postId = null, + ?int $revisionId = null, + ?string $state = null, + ?string $hint = null, + array $details = [] + ): self { + return new self($reason, false, $postId, $revisionId, $cause, $detail, $state, $hint, $details); + } + public function toError(): WP_Error { return $this->rolledBack - ? Errors::writeFailed($this->reason, $this->details, $this->rolledBackHint, $this->rolledBackState) + ? Errors::writeFailed($this->reason, $this->details, $this->hint, $this->state) : Errors::partialWrite( $this->reason, $this->postId, diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index f7189da..01552fa 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -186,7 +186,7 @@ final class DiviReplaceTextAbility implements Ability $divi = $this->loader->load($postId, true); $problem = $divi instanceof WP_Error ? $divi : $this->loader->writable($divi); if ($problem instanceof WP_Error) { - $skipped[] = ['post_id' => $postId, 'reason' => self::reason($problem)]; + $skipped[] = ['post_id' => $postId, 'reason' => Errors::clientMessage($problem)]; continue; } @@ -320,7 +320,7 @@ final class DiviReplaceTextAbility implements Ability } catch (WriteFailed $e) { self::log($postId, $e); - return ['post_id' => $postId, 'ok' => false, 'error' => self::reason($e->toError())]; + return ['post_id' => $postId, 'ok' => false, 'error' => Errors::clientMessage($e->toError())]; } catch (Throwable $e) { self::log($postId, $e); @@ -343,7 +343,7 @@ final class DiviReplaceTextAbility implements Ability $postId = $input['post_id']; $page = $this->writer->page($input); if ($page instanceof WP_Error) { - return ['post_id' => $postId, 'ok' => false, 'error' => self::reason($page)]; + return ['post_id' => $postId, 'ok' => false, 'error' => Errors::clientMessage($page)]; } $post = $page->post; @@ -475,14 +475,6 @@ final class DiviReplaceTextAbility implements Ability : 'Knoten nicht lesbar (Attribute kein JSON-Objekt) – im Divi-Builder bearbeiten'; } - /** The client message of an error (its JSON "message"), without the JSON around it. */ - private static function reason(WP_Error $error): string - { - $data = json_decode($error->get_error_message(), true); - - return is_array($data) && is_string($data['message'] ?? null) ? $data['message'] : $error->get_error_message(); - } - private static function log(int $postId, Throwable $e): void { Log::failure(sprintf('lit/divi-replace-text on post %d', $postId), $e); diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 0ee0c78..f20b3a2 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\FollowUp; +use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\RevisionPoint; @@ -78,14 +79,12 @@ class DiviSaver if ($result instanceof WP_Error || $result === 0) { $message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0'; - throw new WriteFailed( + throw WriteFailed::rolledBack( self::REASON, - true, - $postId, - $revisionId, - null, - sprintf('wp_update_post(%d) refused: %s', $postId, $message), - rolledBackState: self::keeping($revisionId) + detail: sprintf('wp_update_post(%d) refused: %s', $postId, $message), + state: self::keeping($revisionId), + postId: $postId, + revisionId: $revisionId ); } @@ -172,7 +171,7 @@ class DiviSaver private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never { $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); - try { + $failures = Rollback::attempt(function () use ($postId, $before): void { $current = get_post($postId); if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { throw new RuntimeException('restoring would publish a missed schedule'); @@ -185,19 +184,19 @@ class DiviSaver if ($restored instanceof WP_Error || $restored === 0) { throw new RuntimeException('previous content could not be written back'); } - } catch (Throwable $e) { - $detail .= '; rollback: ' . $e->getMessage(); - throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail); + }); + if ($failures !== []) { + $detail .= '; rollback: ' . $failures[0]; + throw WriteFailed::partial(self::REASON, $cause, $detail, $postId, $revisionId); } - throw new WriteFailed( + throw WriteFailed::rolledBack( self::REASON, - true, - $postId, - $revisionId, $cause, $detail, - rolledBackState: self::keeping($revisionId) + state: self::keeping($revisionId), + postId: $postId, + revisionId: $revisionId ); } diff --git a/src/Integrations/Media/MediaImporter.php b/src/Integrations/Media/MediaImporter.php index fc0838a..3cab21e 100644 --- a/src/Integrations/Media/MediaImporter.php +++ b/src/Integrations/Media/MediaImporter.php @@ -261,20 +261,17 @@ class MediaImporter } if ($deleted) { - throw new WriteFailed($reason, true, null, null, null, $detail); + throw WriteFailed::rolledBack($reason, detail: $detail); } - throw new WriteFailed( + throw WriteFailed::partial( $reason, - false, - $attachmentId, - null, - null, - $detail . '; attachment could not be deleted', - sprintf('Medium %d wurde trotzdem angelegt, aber ohne Alt-Text.', $attachmentId), - 'Den Aufruf nicht wiederholen (sonst liegt die Datei doppelt vor). Medium mit lit/media-get prüfen, den ' - . 'Alt-Text in der Mediathek nachtragen und dem Nutzer Bescheid geben.', - ['attachment_id' => $attachmentId] + detail: $detail . '; attachment could not be deleted', + postId: $attachmentId, + state: sprintf('Medium %d wurde trotzdem angelegt, aber ohne Alt-Text.', $attachmentId), + hint: 'Den Aufruf nicht wiederholen (sonst liegt die Datei doppelt vor). Medium mit lit/media-get prüfen, ' + . 'den Alt-Text in der Mediathek nachtragen und dem Nutzer Bescheid geben.', + details: ['attachment_id' => $attachmentId] ); } diff --git a/src/Integrations/Media/MediaUpdateAbility.php b/src/Integrations/Media/MediaUpdateAbility.php index 7b677cd..d784f40 100644 --- a/src/Integrations/Media/MediaUpdateAbility.php +++ b/src/Integrations/Media/MediaUpdateAbility.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Media; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\FollowUp; +use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; @@ -266,24 +267,20 @@ final class MediaUpdateAbility implements Ability $unrestored = []; $failures = []; foreach (array_reverse($written) as $entry) { - try { - $this->writer->restore($entry['snapshot']); - } catch (Throwable $e) { + $failed = Rollback::attempt(fn () => $this->writer->restore($entry['snapshot'])); + if ($failed !== []) { $unrestored[] = $entry['attachment']; - $failures[] = $e->getMessage(); + $failures = array_merge($failures, $failed); } } $detail = 'Media update failed: ' . $cause->getMessage(); if ($unrestored === []) { - throw new WriteFailed( + throw WriteFailed::rolledBack( self::REASON, - true, - null, - null, $cause, $detail, - rolledBackHint: 'Das Zurücksetzen hat modified der Medien geändert: vor einem neuen Versuch die Medien ' + hint: 'Das Zurücksetzen hat modified der Medien geändert: vor einem neuen Versuch die Medien ' . 'neu lesen (lit/media-get oder lit/media-search, direkt: lit-media-search) und den Stapel mit ' . 'dem aktuellen modified senden; tritt der Fehler wieder auf, den Administrator informieren ' . '(Details im PHP-Error-Log).' @@ -301,17 +298,14 @@ final class MediaUpdateAbility implements Ability ); } - throw new WriteFailed( + throw WriteFailed::partial( self::REASON, - false, - null, - null, $cause, $detail . '; rollback incomplete: ' . implode('; ', $failures), - sprintf('Möglicherweise geändert: Medien %s (siehe changed_ids).', implode(', ', $ids)), - 'Den Aufruf nicht einfach wiederholen. Die unter changed_ids genannten Medien mit lit/media-get prüfen, ' - . 'die Metadaten ggf. korrigieren und dem Nutzer Bescheid geben.', - ['changed_ids' => $ids] + state: sprintf('Möglicherweise geändert: Medien %s (siehe changed_ids).', implode(', ', $ids)), + hint: 'Den Aufruf nicht einfach wiederholen. Die unter changed_ids genannten Medien mit lit/media-get ' + . 'prüfen, die Metadaten ggf. korrigieren und dem Nutzer Bescheid geben.', + details: ['changed_ids' => $ids] ); } @@ -374,12 +368,11 @@ final class MediaUpdateAbility implements Ability */ private static function failure(int $index, array $item, WP_Error $error): array { - $payload = json_decode($error->get_error_message(), true); return [ 'index' => $index, 'id' => PostGuard::id($item['id'] ?? null), - 'code' => (string) preg_replace('/^lit_/', '', (string) $error->get_error_code()), - 'error' => is_array($payload) ? (string) ($payload['message'] ?? '') : $error->get_error_message(), + 'code' => Errors::codeOf($error), + 'error' => Errors::clientMessage($error), ]; } diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index 3211cac..d9735f1 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -7,12 +7,14 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Rev; +use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use RuntimeException; use Throwable; use WP_Error; use WP_Post; @@ -137,7 +139,7 @@ final class ContentCreateAbility implements Ability // A hook threw – the row may or may not exist and its ID is unknown; nothing is // searched or deleted, the client is told to check (lit_partial_write without id). $this->logPartial($postType, '', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden'); - throw new WriteFailed(self::REASON_INSERT, false, null, null, $e); + throw WriteFailed::partial(self::REASON_INSERT, $e); } if ($postId instanceof WP_Error) { return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message()); @@ -153,7 +155,7 @@ final class ContentCreateAbility implements Ability try { $layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null; } catch (Throwable $e) { - $this->discard($postId, $postType, $e); + $this->discard($postId, $postType, $e, self::REASON_EXTRAS); } if ($layout instanceof WP_Error) { return $this->refuse($postId, $postType, $layout); @@ -162,7 +164,7 @@ final class ContentCreateAbility implements Ability try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { - $this->discard($postId, $postType, $e); + $this->discard($postId, $postType, $e, self::REASON_EXTRAS); } $warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []); @@ -207,7 +209,7 @@ final class ContentCreateAbility implements Ability return $prepared instanceof WP_Error ? $prepared : $divi; } - $fields = array_merge(self::fields($prepared), self::fields($divi)); + $fields = array_merge(Errors::fieldsOf($prepared), Errors::fieldsOf($divi)); if ($fields === []) { return $prepared; } @@ -217,14 +219,6 @@ final class ContentCreateAbility implements Ability : Errors::invalidInput('Eingaben sind ungültig; nichts wurde angelegt.', $fields); } - /** @return array */ - private static function fields(WP_Error $error): array - { - $data = $error->get_error_data(); - - return is_array($data) && is_array($data['fields'] ?? null) ? $data['fields'] : []; - } - /** * @param array $input * @param list $warnings @@ -254,42 +248,38 @@ final class ContentCreateAbility implements Ability /** * Rolls the brand-new draft back: terms it created, then the draft itself (force – - * it cannot have been used anywhere yet). + * it cannot have been used anywhere yet). $reason is the client text of the error. * * @throws WriteFailed Always. */ - private function discard(int $postId, string $postType, Throwable $cause): never + private function discard(int $postId, string $postType, Throwable $cause, string $reason): never { - $problems = []; - try { - $this->writer->deleteCreatedTerms(); - } catch (Throwable $e) { - $problems[] = $e->getMessage(); - } - - try { - if (! wp_delete_post($postId, true)) { - $problems[] = sprintf('draft %d could not be deleted', $postId); + $problems = Rollback::attempt( + fn () => $this->writer->deleteCreatedTerms(), + static function () use ($postId): void { + try { + $deleted = wp_delete_post($postId, true); + } catch (Throwable $e) { + throw new RuntimeException( + sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()), + 0, + $e + ); + } + if (! $deleted) { + throw new RuntimeException(sprintf('draft %d could not be deleted', $postId)); + } } - } catch (Throwable $e) { - $problems[] = sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()); - } + ); $detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage()); if ($problems === []) { - throw new WriteFailed(self::REASON_EXTRAS, true, null, null, $cause, $detail); + throw WriteFailed::rolledBack($reason, $cause, $detail); } $summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId); $this->logPartial($postType, (string) $postId, $summary); - throw new WriteFailed( - self::REASON_EXTRAS, - false, - $postId, - null, - $cause, - $detail . '; rollback: ' . implode('; ', $problems) - ); + throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId); } /** @@ -311,15 +301,12 @@ final class ContentCreateAbility implements Ability if (! $deleted) { $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); $this->logPartial($postType, (string) $postId, $summary); - $fields = $denied instanceof WP_Error ? self::fields($denied) : $denied; - throw new WriteFailed( + $fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied; + throw WriteFailed::partial( ($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ') . implode(', ', array_keys($fields)) . '.', - false, - $postId, - null, - null, - sprintf('New post %d refused after the insert; draft could not be deleted', $postId) + detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId), + postId: $postId ); } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 2c6dc7f..1178a9a 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Rev; +use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; @@ -218,32 +219,21 @@ final class ContentUpdateAbility implements Ability private function rollBack(WP_Post $post, array $before, ?int $revisionId, array $snapshot, Throwable $cause): never { $postId = (int) $post->ID; - $steps = [ + $failures = Rollback::attempt( fn () => $this->writer->deleteCreatedTerms(), fn () => $this->writer->restore($postId, (string) $post->post_type, $snapshot), - fn () => $this->restoreRow($post, $before, $revisionId), - ]; - $failures = []; - foreach ($steps as $step) { - try { - $step(); - } catch (Throwable $e) { - $failures[] = $e->getMessage(); - } - } + fn () => $this->restoreRow($post, $before, $revisionId) + ); $detail = sprintf('Update of post %d failed: %s', $postId, $cause->getMessage()); if ($failures === []) { - throw new WriteFailed( + throw WriteFailed::rolledBack( self::REASON, - true, - $postId, - $revisionId, $cause, $detail, - rolledBackState: $revisionId === null - ? null - : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId)) + state: $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId)), + postId: $postId, + revisionId: $revisionId ); } @@ -254,13 +244,12 @@ final class ContentUpdateAbility implements Ability sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title), $revisionId ); - throw new WriteFailed( + throw WriteFailed::partial( self::REASON, - false, - $postId, - $revisionId, $cause, - $detail . '; rollback incomplete: ' . implode('; ', $failures) + $detail . '; rollback incomplete: ' . implode('; ', $failures), + $postId, + $revisionId ); } diff --git a/src/Integrations/WordPress/Menus/MenuWriter.php b/src/Integrations/WordPress/Menus/MenuWriter.php index c0806b9..81a842d 100644 --- a/src/Integrations/WordPress/Menus/MenuWriter.php +++ b/src/Integrations/WordPress/Menus/MenuWriter.php @@ -454,19 +454,22 @@ class MenuWriter . ($this->deleted !== [] ? '; already deleted: ' . implode(', ', $this->deleted) : '') . ($problems !== [] ? '; rollback: ' . implode('; ', $problems) : ''); - throw new WriteFailed( - 'Das Menü konnte nicht gespeichert werden.', - $rolledBack, - null, - null, + $reason = 'Das Menü konnte nicht gespeichert werden.'; + $details = ['menu_id' => $menuId, 'previous_items' => MenuTree::build(array_values($current))]; + if ($rolledBack) { + throw WriteFailed::rolledBack($reason, $cause, $detail, details: $details); + } + + throw WriteFailed::partial( + $reason, $cause, $detail, - sprintf('Menü %d ist möglicherweise teilweise geändert worden.', $menuId), - 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit/menu-get prüfen und ihn ggf. ' - . 'mit lit/menu-set-items (beide über mcp-adapter-execute-ability) anhand von previous_items (Stand ' - . 'vorher) wiederherstellen; entfernte Einträge müssen dabei neu angelegt werden. Dem Nutzer Bescheid ' - . 'geben.', - ['menu_id' => $menuId, 'previous_items' => MenuTree::build(array_values($current))] + state: sprintf('Menü %d ist möglicherweise teilweise geändert worden.', $menuId), + hint: 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand mit lit/menu-get prüfen und ihn ' + . 'ggf. mit lit/menu-set-items (beide über mcp-adapter-execute-ability) anhand von previous_items ' + . '(Stand vorher) wiederherstellen; entfernte Einträge müssen dabei neu angelegt werden. Dem Nutzer ' + . 'Bescheid geben.', + details: $details ); } diff --git a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php index 6ae6c92..761f674 100644 --- a/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php +++ b/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php @@ -238,7 +238,7 @@ final class WpSettingsUpdateAbility implements Ability $reason = 'Eine Einstellung konnte nicht gespeichert werden.'; if ($stuck === []) { - throw new WriteFailed($reason, true, null, null, $cause); + throw WriteFailed::rolledBack($reason, $cause); } $this->log->recordPartial( @@ -247,17 +247,14 @@ final class WpSettingsUpdateAbility implements Ability implode(',', $stuck), 'Einstellungen nur teilweise zurückgenommen: ' . implode(', ', $stuck) ); - throw new WriteFailed( + throw WriteFailed::partial( $reason, - false, - null, - null, $cause, $cause->getMessage() . '; not restored: ' . implode(', ', $stuck), - sprintf('Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', implode(', ', $stuck)), - 'Aktuelle Werte über lit/wp-settings-get (mcp-adapter-execute-ability) prüfen und ggf. erneut auf die ' - . 'gewünschten Werte setzen.', - ['keys' => $stuck] + state: sprintf('Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', implode(', ', $stuck)), + hint: 'Aktuelle Werte über lit/wp-settings-get (mcp-adapter-execute-ability) prüfen und ggf. erneut auf ' + . 'die gewünschten Werte setzen.', + details: ['keys' => $stuck] ); } diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php index ed4a991..8d6b5cd 100644 --- a/src/Integrations/WordPress/Status/PostCloner.php +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -182,13 +182,9 @@ class PostCloner if ($cloneId instanceof WP_Error || (int) $cloneId === 0) { $reason = $cloneId instanceof WP_Error ? $cloneId->get_error_message() : 'unknown error'; - throw new WriteFailed( + throw WriteFailed::rolledBack( $label . ' konnte nicht angelegt werden.', - true, - null, - null, - null, - sprintf('Clone (%s) of post %d could not be created: %s', $markerKey, $originalId, $reason) + detail: sprintf('Clone (%s) of post %d could not be created: %s', $markerKey, $originalId, $reason) ); } @@ -216,10 +212,10 @@ class PostCloner $deleted = wp_delete_post($cloneId, true); $detail = sprintf('Clone %d of post %d failed: %s', $cloneId, (int) $original->ID, $cause->getMessage()); if ($deleted) { - throw new WriteFailed($reason, true, null, null, $cause, $detail . '; clone deleted'); + throw WriteFailed::rolledBack($reason, $cause, $detail . '; clone deleted'); } - throw new WriteFailed($reason, false, $cloneId, null, $cause, $detail . '; clone could not be deleted'); + throw WriteFailed::partial($reason, $cause, $detail . '; clone could not be deleted', $cloneId); } /** diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 69a3646..57cd164 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -126,7 +126,7 @@ final class PostReplaceWithCopyAbility implements Ability try { $snapshot = $this->cloner->snapshot($original); } catch (Throwable $e) { - throw new WriteFailed(self::REASON, true, $originalId, null, $e); + throw WriteFailed::rolledBack(self::REASON, $e, postId: $originalId); } // Throws WriteFailed (backup deleted again) before anything touches the original. @@ -275,13 +275,12 @@ final class PostReplaceWithCopyAbility implements Ability sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title), $revisionId ); - throw new WriteFailed( + throw WriteFailed::partial( self::REASON, - false, - $originalId, - $revisionId, $cause, $detail . '; rollback incomplete: ' . $e->getMessage(), + $originalId, + $revisionId, sprintf( 'Eintrag %d ist möglicherweise teilweise geändert worden (Stand vorher: Revision %s); die ' . 'vollständige Sicherung des vorherigen Stands liegt als Eintrag %d im Papierkorb.', @@ -297,17 +296,14 @@ final class PostReplaceWithCopyAbility implements Ability ); } - throw new WriteFailed( + throw WriteFailed::rolledBack( self::REASON, - true, - $originalId, - $revisionId, $cause, $detail, - null, - null, - $details, - rolledBackState: Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId)) + state: Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId)), + details: $details, + postId: $originalId, + revisionId: $revisionId ); } diff --git a/src/Integrations/WordPress/Status/StatusWrite.php b/src/Integrations/WordPress/Status/StatusWrite.php index c7afc40..94f47a0 100644 --- a/src/Integrations/WordPress/Status/StatusWrite.php +++ b/src/Integrations/WordPress/Status/StatusWrite.php @@ -33,16 +33,13 @@ final class StatusWrite sprintf('"%s": Statusänderung abgebrochen, Stand unklar', $post->post_title) ); - throw new WriteFailed( + throw WriteFailed::partial( $reason, - false, - $postId, - null, $e, - '', - sprintf('Status von Eintrag %d ist möglicherweise schon geändert worden.', $postId), - 'Den Aufruf nicht einfach wiederholen. Zuerst mit lit/content-get (direkt: lit-content-get) Status ' - . 'und Inhalt prüfen und dem Nutzer Bescheid geben.' + postId: $postId, + state: sprintf('Status von Eintrag %d ist möglicherweise schon geändert worden.', $postId), + hint: 'Den Aufruf nicht einfach wiederholen. Zuerst mit lit/content-get (direkt: lit-content-get) ' + . 'Status und Inhalt prüfen und dem Nutzer Bescheid geben.' ); } } diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index 4837b58..98a43df 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -182,7 +182,12 @@ final class AbilityRegistrarTest extends TestCase $previous = ini_set('error_log', $log); $ability = $this->ability(); $ability->expects('execute')->andThrow( - new WriteFailed('Felder konnten nicht gespeichert werden.', false, 5, 811, new RuntimeException('meta /var/www')) + WriteFailed::partial( + 'Felder konnten nicht gespeichert werden.', + new RuntimeException('meta /var/www'), + postId: 5, + revisionId: 811 + ) ); try { diff --git a/tests/Unit/Abilities/ErrorsTest.php b/tests/Unit/Abilities/ErrorsTest.php index 9eda029..b41b5a1 100644 --- a/tests/Unit/Abilities/ErrorsTest.php +++ b/tests/Unit/Abilities/ErrorsTest.php @@ -114,4 +114,24 @@ final class ErrorsTest extends TestCase self::assertStringContainsString('Entwurf', $payload['message']); self::assertArrayNotHasKey('post_id', $payload); } + + public function testClientMessageReadsTheJsonMessageBack(): void + { + self::assertSame('Titel fehlt.', Errors::clientMessage(Errors::invalidInput('Titel fehlt.'))); + self::assertSame('WordPress sagt nein', Errors::clientMessage(new \WP_Error('db', 'WordPress sagt nein'))); + self::assertSame('{"code":"x"}', Errors::clientMessage(new \WP_Error('db', '{"code":"x"}'))); + } + + public function testCodeOfDropsTheLitPrefixOnly(): void + { + self::assertSame('validation_failed', Errors::codeOf(Errors::invalidInput('x'))); + self::assertSame('db_error', Errors::codeOf(new \WP_Error('db_error', 'x'))); + } + + public function testFieldsOfReturnsTheFieldsOrNothing(): void + { + self::assertSame(['title' => 'Pflichtfeld'], Errors::fieldsOf(Errors::invalidInput('x', ['title' => 'Pflichtfeld']))); + self::assertSame([], Errors::fieldsOf(Errors::invalidInput('x'))); + self::assertSame([], Errors::fieldsOf(new \WP_Error('db', 'x', 'kein Array'))); + } } diff --git a/tests/Unit/Abilities/RollbackTest.php b/tests/Unit/Abilities/RollbackTest.php new file mode 100644 index 0000000..fed8579 --- /dev/null +++ b/tests/Unit/Abilities/RollbackTest.php @@ -0,0 +1,54 @@ + 'egal', static fn (): bool => false)); + } +} diff --git a/tests/Unit/Abilities/WriteFailedTest.php b/tests/Unit/Abilities/WriteFailedTest.php index e062e62..b4fd958 100644 --- a/tests/Unit/Abilities/WriteFailedTest.php +++ b/tests/Unit/Abilities/WriteFailedTest.php @@ -13,7 +13,7 @@ final class WriteFailedTest extends TestCase public function testCleanRollbackMapsToWriteFailed(): void { $cause = new RuntimeException('meta kaputt'); - $failure = new WriteFailed('Felder konnten nicht gespeichert werden.', true, 5, null, $cause); + $failure = WriteFailed::rolledBack('Felder konnten nicht gespeichert werden.', $cause, postId: 5); self::assertTrue($failure->rolledBack); self::assertSame($cause, $failure->getPrevious()); @@ -23,16 +23,17 @@ final class WriteFailedTest extends TestCase public function testRolledBackHintReplacesTheDefaultHintOfWriteFailedOnly(): void { - $clean = new WriteFailed('x', true, rolledBackHint: 'Erst neu lesen.'); - $partial = new WriteFailed('x', false, rolledBackHint: 'Erst neu lesen.'); + $clean = WriteFailed::rolledBack('x', hint: 'Erst neu lesen.'); + $partial = WriteFailed::partial('x'); self::assertSame('Erst neu lesen.', $clean->toError()->get_error_data()['hint']); self::assertNotSame('Erst neu lesen.', $partial->toError()->get_error_data()['hint']); + self::assertStringStartsWith('Den Aufruf nicht einfach wiederholen.', $partial->toError()->get_error_data()['hint']); } public function testIncompleteRollbackMapsToPartialWriteWithTheIds(): void { - $error = (new WriteFailed('Felder konnten nicht gespeichert werden.', false, 5, 811))->toError(); + $error = WriteFailed::partial('Felder konnten nicht gespeichert werden.', postId: 5, revisionId: 811)->toError(); self::assertSame('lit_partial_write', $error->get_error_code()); self::assertSame(5, $error->get_error_data()['post_id']); @@ -41,13 +42,12 @@ final class WriteFailedTest extends TestCase public function testIncompleteRollbackCanDescribeItsOwnStateHintAndDetails(): void { - $error = (new WriteFailed( + $error = WriteFailed::partial( 'Menü nicht gespeichert.', - false, state: 'Das Menü ist teilweise geändert.', hint: 'Mit lit/menu-get prüfen.', details: ['menu_id' => 3] - ))->toError(); + )->toError(); self::assertSame('lit_partial_write', $error->get_error_code()); self::assertStringContainsString('Das Menü ist teilweise geändert.', $error->get_error_message()); @@ -59,8 +59,8 @@ final class WriteFailedTest extends TestCase public function testRolledBackStateReplacesNothingWasSavedOfWriteFailedOnly(): void { $state = 'Die Änderung wurde nicht gespeichert (Sicherung 88 bleibt erhalten).'; - $clean = (new WriteFailed('x', true, rolledBackState: $state))->toError(); - $partial = (new WriteFailed('x', false, rolledBackState: $state))->toError(); + $clean = WriteFailed::rolledBack('x', state: $state)->toError(); + $partial = WriteFailed::partial('x')->toError(); self::assertStringContainsString($state, $clean->get_error_message()); self::assertStringNotContainsString('Nichts wurde gespeichert', $clean->get_error_message()); @@ -69,10 +69,31 @@ final class WriteFailedTest extends TestCase public function testCleanRollbackKeepsItsDetails(): void { - $error = (new WriteFailed('Nicht übernommen.', true, details: ['backup_id' => 88]))->toError(); + $error = WriteFailed::rolledBack('Nicht übernommen.', details: ['backup_id' => 88])->toError(); self::assertSame('lit_write_failed', $error->get_error_code()); self::assertSame(88, $error->get_error_data()['backup_id']); self::assertStringContainsString('"backup_id":88', $error->get_error_message()); } + + public function testTheLogMessageIsTheDetailOrTheCauseOrTheReasonWithTheRollbackOutcome(): void + { + $cause = new RuntimeException('meta kaputt'); + + self::assertSame('Detail [rolled back]', WriteFailed::rolledBack('x', $cause, 'Detail')->getMessage()); + self::assertSame('meta kaputt [rollback incomplete]', WriteFailed::partial('x', $cause)->getMessage()); + self::assertSame('Grund [rolled back]', WriteFailed::rolledBack('Grund')->getMessage()); + } + + public function testTheNamedConstructorsKeepTheIdsForCallersThatLogThem(): void + { + $clean = WriteFailed::rolledBack('x', postId: 7, revisionId: 811); + $partial = WriteFailed::partial('x', postId: 7, revisionId: 811); + + self::assertTrue($clean->rolledBack); + self::assertSame([7, 811], [$clean->postId, $clean->revisionId]); + self::assertFalse($partial->rolledBack); + self::assertSame([7, 811], [$partial->postId, $partial->revisionId]); + self::assertArrayNotHasKey('post_id', $clean->toError()->get_error_data()); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php index 5d7247f..fd960cd 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php @@ -561,7 +561,7 @@ final class DiviReplaceTextAbilityTest extends TestCase public function testWriteFailedKeepsItsOwnMessage(): void { $ability = $this->ability(); - $this->failures = [new WriteFailed('Die Datenbank hat das Speichern abgelehnt.', true, 7)]; + $this->failures = [WriteFailed::rolledBack('Die Datenbank hat das Speichern abgelehnt.', postId: 7)]; $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); $previous = ini_set('error_log', $log); try { diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php index 4bcc6a9..2eec8b9 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -171,7 +171,7 @@ final class DiviWriterTest extends TestCase $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); DiviPageLoaderTest::givenPost($content); $saver = Mockery::mock(DiviSaver::class); - $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811)); + $saver->allows('save')->andThrow(WriteFailed::partial(DiviSaver::REASON, postId: 7, revisionId: 811)); $log = Mockery::mock(ChangeLog::class); $log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811); $writer = $this->writer($saver, $log); @@ -187,7 +187,7 @@ final class DiviWriterTest extends TestCase $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); DiviPageLoaderTest::givenPost($content); $saver = Mockery::mock(DiviSaver::class); - $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, true, 7, 811)); + $saver->allows('save')->andThrow(WriteFailed::rolledBack(DiviSaver::REASON, postId: 7, revisionId: 811)); $log = Mockery::mock(ChangeLog::class); $log->expects('recordPartial')->never(); $log->expects('record')->never(); diff --git a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php index c40508f..427b899 100644 --- a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php +++ b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php @@ -108,7 +108,7 @@ final class MediaUploadAbilitiesTest extends TestCase public function testAPartialUploadIsLoggedAsPartialAndRethrown(): void { - $failure = new WriteFailed('Alt-Text fehlt.', false, 55); + $failure = WriteFailed::partial('Alt-Text fehlt.', postId: 55); $importer = Mockery::mock(MediaImporter::class); $importer->allows('fromUrl')->andThrow($failure); $log = Mockery::mock(ChangeLog::class); diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php index ea29135..db9d870 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php @@ -242,7 +242,7 @@ final class MenuSetItemsAbilityTest extends TestCase { $writer = Mockery::mock(MenuWriter::class); $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); - $writer->expects('apply')->andThrow(new WriteFailed('Menü nicht gespeichert.', true)); + $writer->expects('apply')->andThrow(WriteFailed::rolledBack('Menü nicht gespeichert.')); $log = Mockery::mock(ChangeLog::class); $log->shouldNotReceive('recordPartial'); $ability = new MenuSetItemsAbility($writer, new MenuOperations(), $log); @@ -255,7 +255,7 @@ final class MenuSetItemsAbilityTest extends TestCase { $writer = Mockery::mock(MenuWriter::class); $writer->allows('plan')->andReturn(['errors' => [], 'removals' => [], 'warnings' => []]); - $writer->expects('apply')->andThrow(new WriteFailed('Menü nicht gespeichert.', false)); + $writer->expects('apply')->andThrow(WriteFailed::partial('Menü nicht gespeichert.')); $log = Mockery::mock(ChangeLog::class); $log->expects('recordPartial')->with('lit/menu-set-items', 'nav_menu', '3', Mockery::type('string')); $ability = new MenuSetItemsAbility($writer, new MenuOperations(), $log); diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php index e330e34..e7d9310 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -282,7 +282,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase { $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); - $cloner->expects('backup')->andThrow(new WriteFailed('Die Sicherung konnte nicht angelegt werden.', true)); + $cloner->expects('backup')->andThrow(WriteFailed::rolledBack('Die Sicherung konnte nicht angelegt werden.')); $cloner->expects('replace')->never(); $cloner->expects('restore')->never(); Functions\expect('wp_save_post_revision')->never(); From 4114bdb805dd343ad7a5cb3055a187f9b08446c1 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:42:33 +0200 Subject: [PATCH 138/185] refactor: PostRow::update and PostGuard::assertNoMissedSchedule (M5) - PostRow::update(id, columns, failure) / ::save(): the slashed wp_update_post of internal row writes, refusals as RuntimeException ': '. Used by ContentUpdate::restoreRow, PostCloner::updateRow, MediaMetaWriter apply/restore. - PostGuard::assertNoMissedSchedule(postId, message) replaces the re-read + check in ContentUpdate::restoreRow, PostCloner and DiviSaver::restore (DiviSaver keeps its own kses window; M4). - PostCloner::restore uses Rollback::attempt. Log messages unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Divi/Pages/DiviSaver.php | 5 +- src/Integrations/Media/MediaMetaWriter.php | 21 ++------- .../Content/ContentUpdateAbility.php | 15 +++--- src/Integrations/WordPress/PostGuard.php | 16 +++++++ src/Integrations/WordPress/PostRow.php | 46 +++++++++++++++++++ .../WordPress/Status/PostCloner.php | 28 ++++------- .../Integrations/WordPress/PostGuardTest.php | 16 +++++++ .../Integrations/WordPress/PostRowTest.php | 42 +++++++++++++++++ 8 files changed, 140 insertions(+), 49 deletions(-) create mode 100644 src/Integrations/WordPress/PostRow.php create mode 100644 tests/Unit/Integrations/WordPress/PostRowTest.php diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index f20b3a2..9e643cf 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -172,10 +172,7 @@ class DiviSaver { $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); $failures = Rollback::attempt(function () use ($postId, $before): void { - $current = get_post($postId); - if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { - throw new RuntimeException('restoring would publish a missed schedule'); - } + PostGuard::assertNoMissedSchedule($postId, 'restoring would publish a missed schedule'); $restored = $this->withoutKses( static fn (): int|WP_Error diff --git a/src/Integrations/Media/MediaMetaWriter.php b/src/Integrations/Media/MediaMetaWriter.php index 6eaf608..562cb0a 100644 --- a/src/Integrations/Media/MediaMetaWriter.php +++ b/src/Integrations/Media/MediaMetaWriter.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Media; +use LIT\MCP\Integrations\WordPress\PostRow; use RuntimeException; -use WP_Error; use WP_Post; /** @@ -58,15 +58,11 @@ final class MediaMetaWriter if ($columns !== []) { // Set before the call: a throwing save_post hook runs after the row was written. $touched = true; - $result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true); - if ($result instanceof WP_Error || ! $result) { + $refusal = PostRow::save($id, $columns); + if ($refusal !== null) { // wp_update_post() reports errors before it writes anything. $touched = false; - throw new RuntimeException(sprintf( - 'Attachment %d not updated: %s', - $id, - $result instanceof WP_Error ? $result->get_error_message() : 'unknown error' - )); + throw new RuntimeException(sprintf('Attachment %d not updated: %s', $id, $refusal)); } } @@ -84,14 +80,7 @@ final class MediaMetaWriter { $id = $snapshot['id']; if ($snapshot['columns'] !== []) { - $result = wp_update_post(wp_slash(array_merge($snapshot['columns'], ['ID' => $id])), true); - if ($result instanceof WP_Error || ! $result) { - throw new RuntimeException(sprintf( - 'Attachment %d not restored: %s', - $id, - $result instanceof WP_Error ? $result->get_error_message() : 'unknown error' - )); - } + PostRow::update($id, $snapshot['columns'], sprintf('Attachment %d not restored', $id)); } if (! $snapshot['alt_changed']) { diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index 1178a9a..8201665 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -13,6 +13,7 @@ use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; +use LIT\MCP\Integrations\WordPress\PostRow; use LIT\MCP\Integrations\WordPress\RevisionPoint; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use RuntimeException; @@ -269,10 +270,10 @@ final class ContentUpdateAbility implements Ability } // Both restore paths save through wp_update_post(), which would publish a missed schedule. - $current = get_post((int) $post->ID); - if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { - throw new RuntimeException('Post row not restored: saving it now would publish a missed schedule.'); - } + PostGuard::assertNoMissedSchedule( + (int) $post->ID, + 'Post row not restored: saving it now would publish a missed schedule.' + ); $fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED)); if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) { @@ -286,10 +287,6 @@ final class ContentUpdateAbility implements Ability return; } - $result = wp_update_post(wp_slash(array_merge($before, ['ID' => (int) $post->ID])), true); - if ($result instanceof WP_Error || ! $result) { - $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; - throw new RuntimeException('Post row could not be restored: ' . $message); - } + PostRow::update((int) $post->ID, $before, 'Post row could not be restored'); } } diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index b8d3f71..ace98c1 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use RuntimeException; use WP_Error; use WP_Post; @@ -83,6 +84,21 @@ final class PostGuard return $time !== false && $time <= time() + self::PUBLISH_WINDOW; } + /** + * For internal writes (rollbacks, clone rows): wp_update_post() would publish a missed + * schedule (see missedSchedule()), so the entry is re-read and such a save is refused. + * $message is the internal log text of the RuntimeException. + * + * @throws RuntimeException + */ + public static function assertNoMissedSchedule(int $postId, string $message): void + { + $post = get_post($postId); + if ($post instanceof WP_Post && self::missedSchedule($post)) { + throw new RuntimeException($message); + } + } + /** * Refuses a write that would change the status as a side effect (spec §2: status is only * changed on explicit request) – currently a missed schedule, which any save would publish. diff --git a/src/Integrations/WordPress/PostRow.php b/src/Integrations/WordPress/PostRow.php new file mode 100644 index 0000000..32808c7 --- /dev/null +++ b/src/Integrations/WordPress/PostRow.php @@ -0,0 +1,46 @@ + $columns Column => value (unslashed). + * @param string $failure Log text; the exception reads ": ". + * @throws RuntimeException When WordPress refuses the save. + */ + public static function update(int $id, array $columns, string $failure): void + { + $refusal = self::save($id, $columns); + if ($refusal !== null) { + throw new RuntimeException($failure . ': ' . $refusal); + } + } + + /** + * The save itself, for callers that must react before throwing. + * + * @param array $columns + * @return string|null Why WordPress refused (WP_Error message or "unknown error"), null when saved. + */ + public static function save(int $id, array $columns): ?string + { + $result = wp_update_post(wp_slash(array_merge($columns, ['ID' => $id])), true); + if ($result instanceof WP_Error) { + return $result->get_error_message(); + } + + return $result ? null : 'unknown error'; + } +} diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php index 8d6b5cd..c10fc08 100644 --- a/src/Integrations/WordPress/Status/PostCloner.php +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; +use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\WordPress\PostGuard; +use LIT\MCP\Integrations\WordPress\PostRow; use RuntimeException; use Throwable; use WP_Error; @@ -143,14 +145,7 @@ class PostCloner 'meta' => fn () => $this->replaceMeta($postId, $snapshot['meta']), 'terms' => fn () => $this->setTerms($postId, $snapshot['terms']), ]; - $failures = []; - foreach (array_intersect_key($steps, array_flip($parts)) as $step) { - try { - $step(); - } catch (Throwable $e) { - $failures[] = $e->getMessage(); - } - } + $failures = Rollback::attempt(...array_values(array_intersect_key($steps, array_flip($parts)))); if ($failures !== []) { throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures)); @@ -225,11 +220,7 @@ class PostCloner private function updateRow(int $postId, array $columns): void { $this->assertNoMissedSchedule($postId); - $result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true); - if ($result instanceof WP_Error || ! $result) { - $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; - throw new RuntimeException(sprintf('Post %d could not be updated: %s', $postId, $message)); - } + PostRow::update($postId, $columns, sprintf('Post %d could not be updated', $postId)); } /** @@ -239,13 +230,10 @@ class PostCloner */ private function assertNoMissedSchedule(int $postId): void { - $post = get_post($postId); - if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) { - throw new RuntimeException(sprintf( - 'Post %d not written: saving it now would publish a missed schedule.', - $postId - )); - } + PostGuard::assertNoMissedSchedule( + $postId, + sprintf('Post %d not written: saving it now would publish a missed schedule.', $postId) + ); } /** diff --git a/tests/Unit/Integrations/WordPress/PostGuardTest.php b/tests/Unit/Integrations/WordPress/PostGuardTest.php index ed06161..b88bbe1 100644 --- a/tests/Unit/Integrations/WordPress/PostGuardTest.php +++ b/tests/Unit/Integrations/WordPress/PostGuardTest.php @@ -148,4 +148,20 @@ final class PostGuardTest extends TestCase Functions\when('current_user_can')->justReturn(false); self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code()); } + + public function testAssertNoMissedScheduleRefusesAMissedScheduleWithTheCallersMessage(): void + { + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) { + 5 => new WP_Post(['ID' => 5, 'post_status' => 'future', 'post_date_gmt' => '2020-01-01 09:00:00']), + 6 => new WP_Post(['ID' => 6, 'post_status' => 'publish']), + default => null, + }); + + PostGuard::assertNoMissedSchedule(6, 'nie'); + PostGuard::assertNoMissedSchedule(7, 'nie'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Post 5 not written'); + PostGuard::assertNoMissedSchedule(5, 'Post 5 not written'); + } } diff --git a/tests/Unit/Integrations/WordPress/PostRowTest.php b/tests/Unit/Integrations/WordPress/PostRowTest.php new file mode 100644 index 0000000..580ffbe --- /dev/null +++ b/tests/Unit/Integrations/WordPress/PostRowTest.php @@ -0,0 +1,42 @@ +alias(static fn (array $data): array => $data + ['slashed' => 1]); + Functions\expect('wp_update_post')->once() + ->with(['post_title' => 'A', 'ID' => 5, 'slashed' => 1], true) + ->andReturn(5); + + PostRow::update(5, ['post_title' => 'A'], 'nie'); + } + + public function testUpdateThrowsTheFailureWithWordPressReason(): void + { + Functions\when('wp_update_post')->justReturn(new WP_Error('db', 'Datenbank weg')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Post 5 could not be updated: Datenbank weg'); + PostRow::update(5, ['post_title' => 'A'], 'Post 5 could not be updated'); + } + + public function testSaveReportsAZeroResultAsUnknownError(): void + { + Functions\when('wp_update_post')->justReturn(0); + self::assertSame('unknown error', PostRow::save(5, ['post_title' => 'A'])); + + Functions\when('wp_update_post')->justReturn(5); + self::assertNull(PostRow::save(5, ['post_title' => 'A'])); + } +} From d2cc8497c9d3fa33e05344df75b5f6fb53d51e96 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:43:50 +0200 Subject: [PATCH 139/185] refactor: extract the lit/content-create draft transaction into NewDraft (M7) NewDraft owns insert (partial write on a throwing hook), refuse and discard (via Rollback::attempt and WriteFailed::*) and the partial logging; ContentCreateAbility keeps checkDivi/prepare/combined (Errors::fieldsOf) and the result. Both discard callers still pass REASON_EXTRAS. Behaviour unchanged; NewDraftTest adds the throwing-delete branch. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Content/ContentCreateAbility.php | 102 ++------------ .../WordPress/Content/NewDraft.php | 133 ++++++++++++++++++ .../WordPress/Content/NewDraftTest.php | 62 ++++++++ 3 files changed, 203 insertions(+), 94 deletions(-) create mode 100644 src/Integrations/WordPress/Content/NewDraft.php create mode 100644 tests/Unit/Integrations/WordPress/Content/NewDraftTest.php diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index d9735f1..58e3c3d 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -7,14 +7,11 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Rev; -use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\ToolResult; -use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; -use RuntimeException; use Throwable; use WP_Error; use WP_Post; @@ -25,6 +22,7 @@ use WP_Post; * afterwards, created terms and the brand-new draft are deleted again and a WriteFailed * is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup * failed or the insert itself threw; partial writes are logged with result "partial"). + * The insert and its rollbacks live in NewDraft; this class checks, orders and reports. * * Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole * description before the insert; the draft is inserted with its normal WordPress fields only @@ -33,9 +31,6 @@ use WP_Post; */ final class ContentCreateAbility implements Ability { - private const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.'; - private const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.'; - public function __construct( private ContentSchema $schema, private PostWriter $writer, @@ -133,38 +128,31 @@ final class ContentCreateAbility implements Ability 'post_status' => 'draft', 'post_author' => get_current_user_id(), ]); - try { - $postId = wp_insert_post(wp_slash($data), true); - } catch (Throwable $e) { - // A hook threw – the row may or may not exist and its ID is unknown; nothing is - // searched or deleted, the client is told to check (lit_partial_write without id). - $this->logPartial($postType, '', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden'); - throw WriteFailed::partial(self::REASON_INSERT, $e); - } + $draft = new NewDraft($this->writer, $this->log, $this->name(), $postType); + $postId = $draft->insert($data); if ($postId instanceof WP_Error) { - return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message()); + return $postId; } - $postId = (int) $postId; $denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta'); if ($denied !== []) { - return $this->refuse($postId, $postType, $denied); + return $draft->refuse($postId, $denied); } $layout = null; try { $layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null; } catch (Throwable $e) { - $this->discard($postId, $postType, $e, self::REASON_EXTRAS); + $draft->discard($postId, $e, NewDraft::REASON_EXTRAS); } if ($layout instanceof WP_Error) { - return $this->refuse($postId, $postType, $layout); + return $draft->refuse($postId, $layout); } try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { - $this->discard($postId, $postType, $e, self::REASON_EXTRAS); + $draft->discard($postId, $e, NewDraft::REASON_EXTRAS); } $warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []); @@ -245,78 +233,4 @@ final class ContentCreateAbility implements Ability return $result->toArray(); } - - /** - * Rolls the brand-new draft back: terms it created, then the draft itself (force – - * it cannot have been used anywhere yet). $reason is the client text of the error. - * - * @throws WriteFailed Always. - */ - private function discard(int $postId, string $postType, Throwable $cause, string $reason): never - { - $problems = Rollback::attempt( - fn () => $this->writer->deleteCreatedTerms(), - static function () use ($postId): void { - try { - $deleted = wp_delete_post($postId, true); - } catch (Throwable $e) { - throw new RuntimeException( - sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()), - 0, - $e - ); - } - if (! $deleted) { - throw new RuntimeException(sprintf('draft %d could not be deleted', $postId)); - } - } - ); - - $detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage()); - if ($problems === []) { - throw WriteFailed::rolledBack($reason, $cause, $detail); - } - - $summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId); - $this->logPartial($postType, (string) $postId, $summary); - throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId); - } - - /** - * Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout - * against the new entry's own dynamic content options. A refusal removes the brand-new draft - * again and is reported like any invalid input. - * - * @param array|WP_Error $denied fields. => error, or the error itself. - * @throws WriteFailed When the draft cannot be deleted. - */ - private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error - { - try { - $deleted = (bool) wp_delete_post($postId, true); - } catch (Throwable $e) { - $deleted = false; - } - - if (! $deleted) { - $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); - $this->logPartial($postType, (string) $postId, $summary); - $fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied; - throw WriteFailed::partial( - ($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ') - . implode(', ', array_keys($fields)) . '.', - detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId), - postId: $postId - ); - } - - return $denied instanceof WP_Error - ? $denied - : Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); - } - - private function logPartial(string $postType, string $postId, string $summary): void - { - $this->log->recordPartial($this->name(), $postType, $postId, $summary); - } } diff --git a/src/Integrations/WordPress/Content/NewDraft.php b/src/Integrations/WordPress/Content/NewDraft.php new file mode 100644 index 0000000..753d7c9 --- /dev/null +++ b/src/Integrations/WordPress/Content/NewDraft.php @@ -0,0 +1,133 @@ + $data wp_insert_post() data (unslashed). + * @return int|WP_Error The new draft's ID, or WordPress' refusal as invalid input. + * @throws WriteFailed When a hook threw during the insert. + */ + public function insert(array $data): int|WP_Error + { + try { + $postId = wp_insert_post(wp_slash($data), true); + } catch (Throwable $e) { + // A hook threw – the row may or may not exist and its ID is unknown; nothing is + // searched or deleted, the client is told to check (lit_partial_write without id). + $this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden'); + throw WriteFailed::partial(self::REASON_INSERT, $e); + } + if ($postId instanceof WP_Error) { + return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message()); + } + + return (int) $postId; + } + + /** + * Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout + * against the new entry's own dynamic content options. A refusal removes the brand-new draft + * again and is reported like any invalid input. + * + * @param array|WP_Error $denied fields. => error, or the error itself. + * @throws WriteFailed When the draft cannot be deleted. + */ + public function refuse(int $postId, array|WP_Error $denied): WP_Error + { + try { + $deleted = (bool) wp_delete_post($postId, true); + } catch (Throwable $e) { + $deleted = false; + } + + if (! $deleted) { + $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); + $this->logPartial((string) $postId, $summary); + $fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied; + throw WriteFailed::partial( + ($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ') + . implode(', ', array_keys($fields)) . '.', + detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId), + postId: $postId + ); + } + + return $denied instanceof WP_Error + ? $denied + : Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); + } + + /** + * Rolls the brand-new draft back: terms it created, then the draft itself (force – + * it cannot have been used anywhere yet). $reason is the client text of the error. + * + * @throws WriteFailed Always. + */ + public function discard(int $postId, Throwable $cause, string $reason): never + { + $problems = Rollback::attempt( + fn () => $this->writer->deleteCreatedTerms(), + static fn () => self::deleteDraft($postId) + ); + + $detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage()); + if ($problems === []) { + throw WriteFailed::rolledBack($reason, $cause, $detail); + } + + $summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId); + $this->logPartial((string) $postId, $summary); + throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId); + } + + /** @throws RuntimeException When the draft is not deleted. */ + private static function deleteDraft(int $postId): void + { + try { + $deleted = wp_delete_post($postId, true); + } catch (Throwable $e) { + throw new RuntimeException(sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()), 0, $e); + } + if (! $deleted) { + throw new RuntimeException(sprintf('draft %d could not be deleted', $postId)); + } + } + + private function logPartial(string $postId, string $summary): void + { + $this->log->recordPartial($this->ability, $this->postType, $postId, $summary); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php new file mode 100644 index 0000000..00514aa --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php @@ -0,0 +1,62 @@ +expects('deleteCreatedTerms')->andThrow(new RuntimeException('term 15 bleibt')); + Functions\when('wp_delete_post')->alias(static function (): never { + throw new RuntimeException('delete_post hook kaputt'); + }); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial') + ->with('lit/content-create', 'page', '42', 'Entwurf 42: Anlegen nur teilweise zurückgenommen'); + $draft = new NewDraft($writer, $log, 'lit/content-create', 'page'); + + try { + $draft->discard(42, new RuntimeException('layout kaputt'), 'Grund für den Client.'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + 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; ' + . 'draft 42 could not be deleted: delete_post hook kaputt [rollback incomplete]', + $e->getMessage() + ); + } + } + + public function testACleanDiscardIsRolledBackWithTheGivenReason(): void + { + $writer = Mockery::mock(PostWriter::class); + $writer->expects('deleteCreatedTerms'); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(true); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->never(); + $draft = new NewDraft($writer, $log, 'lit/content-create', 'page'); + + try { + $draft->discard(42, new RuntimeException('meta kaputt'), NewDraft::REASON_EXTRAS); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(NewDraft::REASON_EXTRAS, $e->reason); + } + } +} From 3d96a4136649f6642dfab73ba9cebc41ee782c29 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 17:47:11 +0200 Subject: [PATCH 140/185] test: shared Divi fixtures in tests/Support (T1) - DiviFixtures: markup(), givenPost(), schema(), designSystem(), catalog(), dynamicContentRegistry(), maliciousRefs() - moved verbatim out of DiviPageLoaderTest, DesignSystemTest, ModuleCatalogTest, DynamicContentMapTest and VariableRefTest (its malicious() provider now returns maliciousRefs()). - ComposeFixture moves to tests/Support. - DiviWriterFixture: modules(), designReader(), composition(), a recording saver(), log() and writer(&$saved, &$failures, unfiltered, loader) for the five tests that wired the Divi write stack by hand. No test cases or assertions changed (1397 / 5834). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Compose => Support}/ComposeFixture.php | 16 +- tests/Support/DiviFixtures.php | 231 ++++++++++++++++++ tests/Support/DiviWriterFixture.php | 117 +++++++++ .../Divi/Compose/AttrWriterTest.php | 7 +- .../Divi/Compose/ComposerTest.php | 8 +- .../Divi/Compose/PlaceholdersTest.php | 1 + .../Divi/Compose/ShortformWriterTest.php | 4 +- .../Divi/Compose/StructureRulesTest.php | 1 + .../Divi/Compose/TreeEditorTest.php | 1 + .../Divi/Compose/VariableRefTest.php | 65 +---- .../Divi/Compose/WarningsTest.php | 7 +- .../Divi/Design/DesignSystemTest.php | 38 +-- .../Design/DiviDesignSystemAbilityTest.php | 3 +- .../Divi/Pages/DiviCompositionTest.php | 11 +- .../Divi/Pages/DiviContentComposerTest.php | 22 +- .../Divi/Pages/DiviPageLoaderTest.php | 64 ++--- .../Divi/Pages/DiviReadAbilitiesTest.php | 36 +-- .../Divi/Pages/DiviReplaceTextAbilityTest.php | 36 +-- .../Divi/Pages/DiviWriteAbilitiesTest.php | 51 +--- .../Divi/Pages/DiviWriterTest.php | 58 ++--- .../Divi/Pages/DynamicContentMapTest.php | 71 +----- .../Divi/Schema/DiviCatalogAbilityTest.php | 3 +- .../Divi/Schema/ModuleCatalogTest.php | 27 +- .../Content/ContentCreateDiviTest.php | 15 +- 24 files changed, 489 insertions(+), 404 deletions(-) rename tests/{Unit/Integrations/Divi/Compose => Support}/ComposeFixture.php (91%) create mode 100644 tests/Support/DiviFixtures.php create mode 100644 tests/Support/DiviWriterFixture.php diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Support/ComposeFixture.php similarity index 91% rename from tests/Unit/Integrations/Divi/Compose/ComposeFixture.php rename to tests/Support/ComposeFixture.php index 7ddce21..6a0439e 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Support/ComposeFixture.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; +namespace LIT\MCP\Tests\Support; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\Composer; @@ -12,8 +12,6 @@ use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\StructureRules; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; use LIT\MCP\Integrations\Divi\Design\DesignSystem; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; -use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; /** * Compose classes wired with the real Divi 5.2.1 sample catalog, the sample design system and @@ -130,12 +128,12 @@ final class ComposeFixture public static function placeholders(): Placeholders { - return new Placeholders(DesignSystemTest::sample()); + return new Placeholders(DiviFixtures::designSystem()); } public static function attrWriter(bool $unfiltered = false): AttrWriter { - return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered)); + return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::sanitizer($unfiltered)); } /** @@ -149,7 +147,7 @@ final class ComposeFixture public static function rules(): StructureRules { - return new StructureRules(ModuleCatalogTest::sampleCatalog()); + return new StructureRules(DiviFixtures::catalog()); } /** @@ -158,12 +156,12 @@ final class ComposeFixture */ public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer { - $design ??= DesignSystemTest::sample(); + $design ??= DiviFixtures::designSystem(); $placeholders = new Placeholders($design); - $attrWriter = new AttrWriter(ModuleCatalogTest::sampleCatalog(), $placeholders, self::sanitizer($unfiltered)); + $attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, self::sanitizer($unfiltered)); return new Composer( - ModuleCatalogTest::sampleCatalog(), + DiviFixtures::catalog(), self::rules(), new ShortformWriter($placeholders, self::media(), $attrWriter), $attrWriter, diff --git a/tests/Support/DiviFixtures.php b/tests/Support/DiviFixtures.php new file mode 100644 index 0000000..78f3d9e --- /dev/null +++ b/tests/Support/DiviFixtures.php @@ -0,0 +1,231 @@ + 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Start', 'post_content' => $content], + $fields + )); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); + Functions\when('add_query_arg')->alias(static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value); + + return $post; + } + + public static function schema(bool $diviEnabled = true): ContentSchema + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('diviEnabled')->andReturn($diviEnabled); + + return $schema; + } + + /** Shapes as GlobalData / GlobalPreset return them (Divi 5.2.1, verified in DDEV). */ + public static function designSystem(): DesignSystem + { + return DesignSystem::fromDivi( + [ + 'gcid-primary-color' => ['color' => '#2ea3f2', 'label' => 'Primärfarbe', 'status' => 'active'], + 'gcid-body-color' => ['color' => '#666666', 'label' => 'Fließtextfarbe', 'status' => 'active'], + 'gcid-a1b2' => ['color' => '#0b3d59', 'label' => 'Nachtblau', 'status' => 'active'], + 'gcid-old' => ['color' => '#ff0000', 'label' => 'Alt', 'status' => 'inactive'], + ], + [ + 'numbers' => ['gvid-r1' => ['id' => 'gvid-r1', 'label' => 'Radius', 'value' => '8px', 'status' => 'active']], + 'strings' => ['gvid-s1' => ['id' => 'gvid-s1', 'label' => 'Telefon', 'value' => '0221 123', 'status' => 'active']], + // Divi returns every variable type as stdClass (verified in DDEV). + 'fonts' => (object) [ + '--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', 'value' => 'Open Sans', 'status' => 'active'], + '--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', 'value' => 'Lato', 'status' => 'active'], + ], + 'links' => new \stdClass(), + ], + [ + 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ], + ['body_font_size' => '16'] + ); + } + + /** Real Divi 5.2.1 metadata (tests/Integration/divi-metadata-fixture.php). */ + public static function catalog(): ModuleCatalog + { + $sample = json_decode( + (string) file_get_contents(dirname(__DIR__) . '/Fixtures/divi/metadata-sample.json'), + true + ); + + return new ModuleCatalog( + $sample['metadata'], + static fn (string $name): array => (array) ($sample['defaults'][$name] ?? []) + ); + } + + /** + * Shape of DynamicContentOptions::get_options($pageId, 'edit') in Divi 5.2.1 (excerpt of a + * live dump for a page: no post_categories/post_tags – pages have no such taxonomies). + * + * @return array> + */ + public static function dynamicContentRegistry(): array + { + $text = static fn (): array => ['label' => 'x', 'type' => 'text', 'default' => '']; + $option = static fn (string $id, string $group, array $fields): array => [ + 'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields, + ]; + + return [ + 'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]), + 'post_excerpt' => $option('post_excerpt', 'Default', [ + 'before' => $text(), 'after' => $text(), 'words' => $text(), 'read_more_label' => $text(), + ]), + 'post_date' => $option('post_date', 'Default', [ + 'before' => $text(), + 'after' => $text(), + 'date_format' => ['label' => 'x', 'type' => 'select', 'default' => 'default'], + 'custom_date_format' => $text(), + ]), + 'post_author' => $option('post_author', 'Default', [ + 'before' => $text(), + 'after' => $text(), + 'name_format' => ['type' => 'select'], + 'link' => ['type' => 'yes_no_button'], + ]), + 'site_title' => $option('site_title', 'Default', ['before' => $text(), 'after' => $text()]), + 'post_link_url' => $option('post_link_url', 'Default', []), + 'post_link_url_page' => $option('post_link_url_page', 'Default', ['post_id' => ['type' => 'select_post']]), + 'post_featured_image' => $option('post_featured_image', 'Default', []), + 'post_meta_key' => $option('post_meta_key', 'Standard', [ + 'before' => $text(), + 'after' => $text(), + 'select_meta_key' => ['type' => 'select'], + 'meta_key' => $text(), + 'enable_html' => ['type' => 'yes_no_button'], + ]), + 'loop_post_title' => $option('loop_post_title', 'Loop', [ + 'before' => $text(), 'after' => $text(), 'loop_position' => $text(), + ]), + 'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [ + 'loop_meta_key' => $text(), + ]), + // Odd entries a third-party filter could add. + 'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]), + 'no_fields' => ['id' => 'no_fields', 'type' => 'text'], + 7 => $option('7', 'Default', []), + 'not_an_array' => 'x', + ]; + } + + /** + * Hand-crafted attacks (preflight §4 "Malicious $variable tests"). + * + * @return array Value => part of the German error. + */ + public static function maliciousRefs(): array + { + $end = self::escape('0029') . '$'; + + return [ + 'escaped end marker in before' => [self::content('post_title', '{"before":"x' . $end . ' y","after":""}'), 'allein im Wert'], + 'escaped end marker in after' => [self::content('post_title', '{"before":"","after":"' . $end . '"}'), 'allein im Wert'], + 'escaped end marker in date format' => [self::content('post_date', '{"custom_date_format":"d' . $end . '"}'), 'allein im Wert'], + 'escaped end marker in name' => [self::content('post_title' . $end, '{}'), 'Unbekannter Name'], + 'select with markup' => [self::content('post_date', '{"date_format":"d"}'), 'date_format'], + 'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'], + 'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'], + 'unknown gvid' => [self::content('gvid-nope', '{}'), 'gvid-nope'], + 'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'], + 'color gcid as content' => [self::content('gcid-a1b2', '{}'), 'gcid-a1b2'], + 'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'], + 'name with style breakout' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2;}","settings":{}}})$', + 'Name', + ], + 'settings on a global variable' => [self::content('gvid-s1', '{"before":"x"}'), 'before'], + 'settings on a theme font' => [self::content('--et_global_heading_font', '{"after":""}'), 'after'], + 'unknown settings key' => [self::content('post_title', '{"before":"","foo":"1"}'), 'foo'], + 'setting the option lacks' => [self::content('post_featured_image', '{"before":"x"}'), 'before'], + 'enable_html' => [self::content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], + 'color setting not numeric' => [ + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}"}}})$', + 'opacity', + ], + 'protected meta key' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], + 'doubled meta prefix' => [ + self::content('custom_meta_custom_meta__edit_lock', '{}'), + 'custom_meta_custom_meta__edit_lock', + ], + 'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'], + 'unknown dynamic content' => [self::content('post_meta_key', '{}'), 'post_meta_key'], + 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], + 'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'], + 'greedy smuggling' => ['$variable({"a":"})$"})$', 'allein im Wert'], + 'nested reference in before' => [self::content('post_title', '{"before":"$variable(x)$","after":""}'), 'allein im Wert'], + // S28: a marker the sanitizer rebuilds inside a cleaned setting (html(), text()). + 'tag-split marker in before' => [self::content('post_title', '{"before":"$variable(y","after":""}'), 'allein im Wert'], + 'NUL-split marker in after' => [ + self::content('post_title', '{"before":"","after":"$variable' . self::escape('0000') . '(y"}'), + 'allein im Wert', + ], + 'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), 'allein im Wert'], + // S32: text() keeps control characters (like sanitize_text_field); VariableRef strips them. + 'NUL-split marker in date format' => [ + self::content('post_date', '{"custom_date_format":"$variable' . self::escape('0000') . '(y"}'), + 'allein im Wert', + ], + 'DEL-split marker in date format' => [ + self::content('post_date', '{"custom_date_format":"$variable' . self::escape('007f') . '(y"}'), + 'allein im Wert', + ], + 'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'], + 'broken json' => ['$variable(kaputt)$', 'JSON'], + 'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'], + 'embedded in text' => ['

    Hi ' . self::content('post_title', '{"before":"","after":""}') . '

    ', 'allein im Wert'], + ]; + } + + private static function content(string $name, string $settings): string + { + return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$'; + } + + /** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */ + private static function escape(string $hex): string + { + return chr(92) . 'u' . $hex; + } +} diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php new file mode 100644 index 0000000..93292e1 --- /dev/null +++ b/tests/Support/DiviWriterFixture.php @@ -0,0 +1,117 @@ +allows('catalog')->andReturn(DiviFixtures::catalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + + return $modules; + } + + public static function designReader(): DesignSystemReader + { + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DiviFixtures::designSystem()); + + return $design; + } + + /** + * $design is the reader the writer gets too (one instance, like the live wiring); without + * $registry the composition uses DynamicContentMap's default (VariableRef's built-in map in + * unit tests, where Divi's classes are missing). + */ + public static function composition( + DesignSystemReader $design, + bool $unfiltered = false, + ?DynamicContentMap $registry = null + ): DiviComposition { + return new DiviComposition( + self::modules(), + $design, + ComposeFixture::sanitizer($unfiltered), + ComposeFixture::media(), + $registry ?? new DynamicContentMap() + ); + } + + /** + * A DiviSaver whose save() appends the content to $saved, puts it on the post and reports + * revision 811. Each call first takes the next entry of $failures and throws it, if any. + * + * @param list $saved + * @param list $failures + */ + public static function saver(array &$saved, array &$failures = []): DiviSaver + { + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(static function (WP_Post $post, string $content) use (&$saved, &$failures): array { + $failure = array_shift($failures); + if ($failure !== null) { + throw $failure; + } + $saved[] = $content; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + + return $saver; + } + + /** A ChangeLog that accepts every record (log id 4711). */ + public static function log(): ChangeLog + { + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(4711); + + return $log; + } + + /** + * The writer over the page of DiviFixtures::givenPost(), saving into $saved (see saver()). + * + * @param list $saved + * @param list $failures + */ + public static function writer( + array &$saved, + array &$failures = [], + bool $unfiltered = false, + ?DiviPageLoader $loader = null + ): DiviWriter { + $design = self::designReader(); + + return new DiviWriter( + $loader ?? new DiviPageLoader(DiviFixtures::schema()), + self::saver($saved, $failures), + self::log(), + $design, + self::composition($design, $unfiltered) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index af367c4..738b029 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -6,7 +6,8 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; -use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; +use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; final class AttrWriterTest extends TestCase @@ -156,7 +157,7 @@ final class AttrWriterTest extends TestCase public static function malicious(): array { $cases = []; - foreach (VariableRefTest::malicious() as $name => [$value]) { + foreach (DiviFixtures::maliciousRefs() as $name => [$value]) { $cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT]; } @@ -317,7 +318,7 @@ final class AttrWriterTest extends TestCase /** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */ public function testUnknownContentInnerContentIsRichText(): void { - self::assertSame('unknown', ModuleCatalogTest::sampleCatalog()->pathKind('divi/blurb', 'content.innerContent')); + self::assertSame('unknown', DiviFixtures::catalog()->pathKind('divi/blurb', 'content.innerContent')); self::assertSame('

    Hallo

    ', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '

    Hallo

    ')); // Other unknown innerContent stays text. self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', 'Los')); diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php index 22d6174..8256e0e 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -11,8 +11,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Compose\Composer; use LIT\MCP\Integrations\Divi\Design\DesignSystem; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; -use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; +use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; final class ComposerTest extends TestCase @@ -442,11 +442,11 @@ final class ComposerTest extends TestCase $this->expectException(InvalidArgumentException::class); new Composer( - ModuleCatalogTest::sampleCatalog(), + DiviFixtures::catalog(), ComposeFixture::rules(), ComposeFixture::shortform(), ComposeFixture::attrWriter(), - DesignSystemTest::sample(), + DiviFixtures::designSystem(), '5.2.1"},"x":{"' ); } diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php index 1cfbc4a..c773112 100644 --- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Tests\Support\ComposeFixture; use PHPUnit\Framework\TestCase; final class PlaceholdersTest extends TestCase diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 5849acc..4ca6441 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; +use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; final class ShortformWriterTest extends TestCase @@ -146,7 +148,7 @@ final class ShortformWriterTest extends TestCase public static function malicious(): array { $rows = []; - foreach (VariableRefTest::malicious() as $name => [$value]) { + foreach (DiviFixtures::maliciousRefs() as $name => [$value]) { foreach (array_keys(self::TARGETS) as $kind) { $rows[$kind . ': ' . $name] = [$kind, $value, $kind !== 'text']; } diff --git a/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php index 4513345..50d0fea 100644 --- a/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Tests\Support\ComposeFixture; use PHPUnit\Framework\TestCase; final class StructureRulesTest extends TestCase diff --git a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php index d2e12d7..d7af5b3 100644 --- a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php +++ b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; +use LIT\MCP\Tests\Support\ComposeFixture; use PHPUnit\Framework\TestCase; final class TreeEditorTest extends TestCase diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 30835ae..452ac83 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -6,7 +6,8 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\VariableRef; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; +use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; /** Ruling S1/S2/S12/S21/S22: every $variable(…)$ value is parsed, strictly checked, cleaned and re-serialized. */ @@ -15,7 +16,7 @@ final class VariableRefTest extends TestCase /** @param array>|null $dynamicContent */ private static function ref(?array $dynamicContent = null): VariableRef { - return new VariableRef(DesignSystemTest::sample(), $dynamicContent); + return new VariableRef(DiviFixtures::designSystem(), $dynamicContent); } private static function content(string $name, string $settings): string @@ -125,65 +126,7 @@ final class VariableRefTest extends TestCase */ public static function malicious(): array { - $end = self::escape('0029') . '$'; - - return [ - 'escaped end marker in before' => [self::content('post_title', '{"before":"x' . $end . ' y","after":""}'), 'allein im Wert'], - 'escaped end marker in after' => [self::content('post_title', '{"before":"","after":"' . $end . '"}'), 'allein im Wert'], - 'escaped end marker in date format' => [self::content('post_date', '{"custom_date_format":"d' . $end . '"}'), 'allein im Wert'], - 'escaped end marker in name' => [self::content('post_title' . $end, '{}'), 'Unbekannter Name'], - 'select with markup' => [self::content('post_date', '{"date_format":"d"}'), 'date_format'], - 'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'], - 'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'], - 'unknown gvid' => [self::content('gvid-nope', '{}'), 'gvid-nope'], - 'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'], - 'color gcid as content' => [self::content('gcid-a1b2', '{}'), 'gcid-a1b2'], - 'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'], - 'name with style breakout' => [ - '$variable({"type":"color","value":{"name":"gcid-a1b2;}","settings":{}}})$', - 'Name', - ], - 'settings on a global variable' => [self::content('gvid-s1', '{"before":"x"}'), 'before'], - 'settings on a theme font' => [self::content('--et_global_heading_font', '{"after":""}'), 'after'], - 'unknown settings key' => [self::content('post_title', '{"before":"","foo":"1"}'), 'foo'], - 'setting the option lacks' => [self::content('post_featured_image', '{"before":"x"}'), 'before'], - 'enable_html' => [self::content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'], - 'color setting not numeric' => [ - '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}"}}})$', - 'opacity', - ], - 'protected meta key' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], - 'doubled meta prefix' => [ - self::content('custom_meta_custom_meta__edit_lock', '{}'), - 'custom_meta_custom_meta__edit_lock', - ], - 'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'], - 'unknown dynamic content' => [self::content('post_meta_key', '{}'), 'post_meta_key'], - 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], - 'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'], - 'greedy smuggling' => ['$variable({"a":"})$"})$', 'allein im Wert'], - 'nested reference in before' => [self::content('post_title', '{"before":"$variable(x)$","after":""}'), 'allein im Wert'], - // S28: a marker the sanitizer rebuilds inside a cleaned setting (html(), text()). - 'tag-split marker in before' => [self::content('post_title', '{"before":"$variable(y","after":""}'), 'allein im Wert'], - 'NUL-split marker in after' => [ - self::content('post_title', '{"before":"","after":"$variable' . self::escape('0000') . '(y"}'), - 'allein im Wert', - ], - 'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), 'allein im Wert'], - // S32: text() keeps control characters (like sanitize_text_field); VariableRef strips them. - 'NUL-split marker in date format' => [ - self::content('post_date', '{"custom_date_format":"$variable' . self::escape('0000') . '(y"}'), - 'allein im Wert', - ], - 'DEL-split marker in date format' => [ - self::content('post_date', '{"custom_date_format":"$variable' . self::escape('007f') . '(y"}'), - 'allein im Wert', - ], - 'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'], - 'broken json' => ['$variable(kaputt)$', 'JSON'], - 'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'], - 'embedded in text' => ['

    Hi ' . self::content('post_title', '{"before":"","after":""}') . '

    ', 'allein im Wert'], - ]; + return DiviFixtures::maliciousRefs(); } /** @dataProvider malicious */ diff --git a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php index 898c05e..f73ebad 100644 --- a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php +++ b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php @@ -7,7 +7,8 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\Compose\Warnings; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; +use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; final class WarningsTest extends TestCase @@ -34,7 +35,7 @@ final class WarningsTest extends TestCase $document->append($block); } - $warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']); + $warnings = (new Warnings(DiviFixtures::designSystem()))->collect(NodeIndex::build($document), $composed['blocks']); self::assertSame( [ @@ -50,6 +51,6 @@ final class WarningsTest extends TestCase $warnings ); - self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), [])); + self::assertSame([], (new Warnings(DiviFixtures::designSystem()))->collect(NodeIndex::build($document), [])); } } diff --git a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php index c1d9a9b..906d27d 100644 --- a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php +++ b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php @@ -5,46 +5,14 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; final class DesignSystemTest extends TestCase { - /** Shapes as GlobalData / GlobalPreset return them (Divi 5.2.1, verified in DDEV). */ - public static function sample(): DesignSystem - { - return DesignSystem::fromDivi( - [ - 'gcid-primary-color' => ['color' => '#2ea3f2', 'label' => 'Primärfarbe', 'status' => 'active'], - 'gcid-body-color' => ['color' => '#666666', 'label' => 'Fließtextfarbe', 'status' => 'active'], - 'gcid-a1b2' => ['color' => '#0b3d59', 'label' => 'Nachtblau', 'status' => 'active'], - 'gcid-old' => ['color' => '#ff0000', 'label' => 'Alt', 'status' => 'inactive'], - ], - [ - 'numbers' => ['gvid-r1' => ['id' => 'gvid-r1', 'label' => 'Radius', 'value' => '8px', 'status' => 'active']], - 'strings' => ['gvid-s1' => ['id' => 'gvid-s1', 'label' => 'Telefon', 'value' => '0221 123', 'status' => 'active']], - // Divi returns every variable type as stdClass (verified in DDEV). - 'fonts' => (object) [ - '--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', 'value' => 'Open Sans', 'status' => 'active'], - '--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', 'value' => 'Lato', 'status' => 'active'], - ], - 'links' => new \stdClass(), - ], - [ - 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ - 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], - 'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'], - ]]], - 'group' => ['divi/font' => ['default' => '', 'items' => [ - 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], - ]]], - ], - ['body_font_size' => '16'] - ); - } - public function testReadsColorsVariablesFontsSizesAndPresets(): void { - $design = self::sample(); + $design = DiviFixtures::designSystem(); self::assertSame( ['id' => 'gcid-a1b2', 'label' => 'Nachtblau', 'value' => '#0b3d59'], @@ -68,7 +36,7 @@ final class DesignSystemTest extends TestCase public function testLooksUpByAliasLabelOrId(): void { - $design = self::sample(); + $design = DiviFixtures::designSystem(); self::assertSame('gcid-primary-color', $design->colorId('primary')); self::assertSame('gcid-a1b2', $design->colorId('nachtblau')); diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php index a26ed17..3a5da0b 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -15,7 +16,7 @@ final class DiviDesignSystemAbilityTest extends TestCase public function testReturnsDesignSystemWithPlaceholders(): void { $reader = Mockery::mock(DesignSystemReader::class); - $reader->allows('read')->andReturn(DesignSystemTest::sample()); + $reader->allows('read')->andReturn(DiviFixtures::designSystem()); Functions\when('current_user_can')->justReturn(true); $ability = new DiviDesignSystemAbility($reader); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index 4e6c7c9..3e18367 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -14,9 +14,8 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviModules; -use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; -use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; +use LIT\MCP\Tests\Support\ComposeFixture; +use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -28,14 +27,14 @@ final class DiviCompositionTest extends TestCase private function composition(): DiviComposition { $modules = Mockery::mock(DiviModules::class); - $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('catalog')->andReturn(DiviFixtures::catalog()); $modules->allows('builderVersion')->andReturn('5.2.1'); $design = Mockery::mock(DesignSystemReader::class); - $design->allows('read')->andReturn(DesignSystemTest::sample()); + $design->allows('read')->andReturn(DiviFixtures::designSystem()); $registry = new DynamicContentMap(function (int $postId): array { $this->registryCalls[] = $postId; - return DynamicContentMapTest::registry(); + return DiviFixtures::dynamicContentRegistry(); }); return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php index 7272a4c..881d169 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php @@ -6,15 +6,11 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Compose\VariableRef; -use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; -use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; -use LIT\MCP\Integrations\Divi\Schema\DiviModules; -use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; -use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; +use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use Mockery\MockInterface; @@ -48,20 +44,16 @@ final class DiviContentComposerTest extends TestCase */ private function composer(bool $diviEnabled = true, ?callable $registry = null): DiviContentComposer { - $modules = Mockery::mock(DiviModules::class); - $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); - $modules->allows('builderVersion')->andReturn('5.2.1'); - $design = Mockery::mock(DesignSystemReader::class); - $design->allows('read')->andReturn(DesignSystemTest::sample()); + $design = DiviWriterFixture::designReader(); $map = new DynamicContentMap(function (int $postId) use ($registry): array { $this->registryCalls[] = $postId; - return $registry !== null ? $registry($postId) : DynamicContentMapTest::registry(); + return $registry !== null ? $registry($postId) : DiviFixtures::dynamicContentRegistry(); }); return new DiviContentComposer( - DiviPageLoaderTest::schema($diviEnabled), - new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $map), + DiviFixtures::schema($diviEnabled), + DiviWriterFixture::composition($design, false, $map), $this->saver, $design ); @@ -124,7 +116,7 @@ final class DiviContentComposerTest extends TestCase public function testWriteRefusesWhatTheNewEntrysMapDoesNotOffer(): void { $registry = static function (int $postId): array { - $options = DynamicContentMapTest::registry(); + $options = DiviFixtures::dynamicContentRegistry(); if ($postId !== 0) { unset($options['site_title']); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php index 12692ca..1afb1c6 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php @@ -4,66 +4,34 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; -use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Pages\DiviPage; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; -use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Unit\TestCase; -use Mockery; use WP_Error; -use WP_Post; final class DiviPageLoaderTest extends TestCase { - public static function fixture(string $name): string - { - return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name); - } - - /** Stubs everything PostGuard::editable() needs for a post with $content. */ - public static function givenPost(string $content, array $fields = []): WP_Post - { - $post = new WP_Post(array_merge( - ['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Start', 'post_content' => $content], - $fields - )); - Functions\when('get_post')->justReturn($post); - Functions\when('current_user_can')->justReturn(true); - Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); - Functions\when('add_query_arg')->alias(static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value); - - return $post; - } - - public static function schema(bool $diviEnabled = true): ContentSchema - { - $schema = Mockery::mock(ContentSchema::class); - $schema->allows('isManaged')->andReturn(true); - $schema->allows('diviEnabled')->andReturn($diviEnabled); - - return $schema; - } - public function testLoadsTreeIndexAndTreeRev(): void { - $content = self::fixture('synthetic-basic.html'); - self::givenPost($content); + $content = DiviFixtures::markup('synthetic-basic.html'); + DiviFixtures::givenPost($content); - $page = (new DiviPageLoader(self::schema()))->load(7, true); + $page = (new DiviPageLoader(DiviFixtures::schema()))->load(7, true); self::assertInstanceOf(DiviPage::class, $page); self::assertSame(substr(hash('sha256', $content), 0, 12), $page->treeRev()); self::assertSame('divi/button', $page->index->find('s0.r0.c1.m2')?->name()); self::assertTrue($page->isDiviLayout()); self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $page->editUrl()); - self::assertNull((new DiviPageLoader(self::schema()))->writable($page)); + self::assertNull((new DiviPageLoader(DiviFixtures::schema()))->writable($page)); } public function testRefusesDivi4Content(): void { - self::givenPost(self::fixture('divi4-shortcodes.html')); + DiviFixtures::givenPost(DiviFixtures::markup('divi4-shortcodes.html')); - $result = (new DiviPageLoader(self::schema()))->load(7, false); + $result = (new DiviPageLoader(DiviFixtures::schema()))->load(7, false); self::assertInstanceOf(WP_Error::class, $result); self::assertSame('lit_unsupported', $result->get_error_code()); @@ -72,12 +40,12 @@ final class DiviPageLoaderTest extends TestCase public function testRefusesMalformedMarkupAndLogsIt(): void { - self::givenPost(''); + DiviFixtures::givenPost(''); // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); $previous = ini_set('error_log', $log); try { - $result = (new DiviPageLoader(self::schema()))->load(7, false); + $result = (new DiviPageLoader(DiviFixtures::schema()))->load(7, false); } finally { ini_set('error_log', (string) $previous); } @@ -90,8 +58,8 @@ final class DiviPageLoaderTest extends TestCase public function testTrashedEntriesCanBeReadButNotWritten(): void { - self::givenPost(self::fixture('synthetic-basic.html'), ['post_status' => 'trash']); - $loader = new DiviPageLoader(self::schema()); + DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html'), ['post_status' => 'trash']); + $loader = new DiviPageLoader(DiviFixtures::schema()); self::assertInstanceOf(DiviPage::class, $loader->load(7, false)); self::assertSame('lit_validation_failed', $loader->load(7, true)->get_error_code()); @@ -99,18 +67,18 @@ final class DiviPageLoaderTest extends TestCase public function testWritableChecks(): void { - $loader = new DiviPageLoader(self::schema(false)); - self::givenPost(''); + $loader = new DiviPageLoader(DiviFixtures::schema(false)); + DiviFixtures::givenPost(''); $empty = $loader->load(7, true); self::assertSame('lit_unsupported', $loader->writable($empty)->get_error_code()); - $loader = new DiviPageLoader(self::schema()); - self::givenPost("\n

    Gutenberg

    \n"); + $loader = new DiviPageLoader(DiviFixtures::schema()); + DiviFixtures::givenPost("\n

    Gutenberg

    \n"); $gutenberg = $loader->load(7, true); self::assertFalse($gutenberg->isDiviLayout()); self::assertStringContainsString('kein Divi-Layout', $loader->writable($gutenberg)->get_error_message()); - self::givenPost('', ['post_status' => 'future', 'post_date_gmt' => '2020-01-01 00:00:00']); + DiviFixtures::givenPost('', ['post_status' => 'future', 'post_date_gmt' => '2020-01-01 00:00:00']); $missed = $loader->load(7, true); self::assertTrue($missed->isDiviLayout()); self::assertSame('lit_validation_failed', $loader->writable($missed)->get_error_code()); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index df93599..c748c83 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -8,7 +8,7 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; +use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use stdClass; @@ -18,16 +18,16 @@ final class DiviReadAbilitiesTest extends TestCase private function design(): DesignSystemReader { $reader = Mockery::mock(DesignSystemReader::class); - $reader->allows('read')->andReturn(DesignSystemTest::sample()); + $reader->allows('read')->andReturn(DiviFixtures::designSystem()); return $reader; } public function testOutlineListsNodesWithTreeRevAndPostRev(): void { - $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); - DiviPageLoaderTest::givenPost($content); - $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + $content = DiviFixtures::markup('synthetic-basic.html'); + DiviFixtures::givenPost($content); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertTrue($ability->permission(['post_id' => 7])); $result = $ability->execute(['post_id' => 7]); @@ -46,8 +46,8 @@ final class DiviReadAbilitiesTest extends TestCase /** Ruling S18: the "limit" input caps children per parent; "truncated" reports the cut. */ public function testOutlineBreadthLimitTruncatesAndReportsIt(): void { - DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); - $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); $result = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1', 'limit' => 2]); @@ -58,8 +58,8 @@ final class DiviReadAbilitiesTest extends TestCase public function testOutlineOfASubtreeAndUnknownNode(): void { - DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); - $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); $result = $ability->execute(['post_id' => 7, 'id' => 's1', 'depth' => 1]); self::assertSame(['s1', 's1.r0'], array_column($result['nodes'], 'id')); @@ -71,8 +71,8 @@ final class DiviReadAbilitiesTest extends TestCase public function testOutlineValidatesTheSubtreeId(): void { - DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); - $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertSame( 'lit_validation_failed', @@ -82,11 +82,11 @@ final class DiviReadAbilitiesTest extends TestCase public function testOutlineHintsForEmptyAndReadOnlyContent(): void { - DiviPageLoaderTest::givenPost(''); - $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + DiviFixtures::givenPost(''); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]); - DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html')); + DiviFixtures::givenPost(DiviFixtures::markup('synthetic-mixed.html')); $hints = $ability->execute(['post_id' => 7])['hints']; self::assertStringContainsString('nur lesbar', $hints[0]); self::assertStringContainsString('nur als Ganzes', $hints[0]); @@ -94,8 +94,8 @@ final class DiviReadAbilitiesTest extends TestCase public function testNodeGetReturnsFullFormShortformAndPresets(): void { - DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); - $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); $heading = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1.m0']); @@ -117,8 +117,8 @@ final class DiviReadAbilitiesTest extends TestCase public function testNodeGetValidatesTheId(): void { - DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); - $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); self::assertSame('lit_validation_failed', $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code()); self::assertSame('lit_not_found', $ability->execute(['post_id' => 7, 'id' => 's5.r0'])->get_error_code()); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php index fd960cd..ba92f14 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php @@ -7,21 +7,14 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\WriteFailed; -use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; -use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; -use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility; -use LIT\MCP\Integrations\Divi\Pages\DiviSaver; -use LIT\MCP\Integrations\Divi\Pages\DiviWriter; -use LIT\MCP\Integrations\Divi\Schema\DiviModules; -use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; -use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; +use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use RuntimeException; @@ -47,29 +40,10 @@ final class DiviReplaceTextAbilityTest extends TestCase /** @param list $ids Rows of the prefilter query. */ private function ability(?string $content = null, bool $unfiltered = false, array $ids = [7]): DiviReplaceTextAbility { - $this->post = DiviPageLoaderTest::givenPost($content ?? DiviPageLoaderTest::fixture('synthetic-basic.html')); + $this->post = DiviFixtures::givenPost($content ?? DiviFixtures::markup('synthetic-basic.html')); Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); - $schema = DiviPageLoaderTest::schema(); - $modules = Mockery::mock(DiviModules::class); - $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); - $modules->allows('builderVersion')->andReturn('5.2.1'); - $design = Mockery::mock(DesignSystemReader::class); - $design->allows('read')->andReturn(DesignSystemTest::sample()); - $composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer($unfiltered), ComposeFixture::media()); - $saver = Mockery::mock(DiviSaver::class); - $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array { - $failure = array_shift($this->failures); - if ($failure !== null) { - throw $failure; - } - $this->saved[] = $content; - $post->post_content = $content; - - return ['revision_id' => 811, 'warnings' => []]; - }); - $log = Mockery::mock(ChangeLog::class); - $log->allows('record')->andReturn(4711); + $schema = DiviFixtures::schema(); $loader = new DiviPageLoader($schema); $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('get_col')->andReturnUsing(function (string $sql) use ($ids): array { @@ -81,7 +55,7 @@ final class DiviReplaceTextAbilityTest extends TestCase return new DiviReplaceTextAbility( $schema, $loader, - new DiviWriter($loader, $saver, $log, $design, $composition), + DiviWriterFixture::writer($this->saved, $this->failures, $unfiltered, $loader), $db ); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php index 9aa40ca..4092095 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php @@ -6,26 +6,19 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Rev; -use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; -use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; -use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; -use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviWriter; -use LIT\MCP\Integrations\Divi\Schema\DiviModules; -use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture; -use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest; -use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest; +use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Unit\TestCase; -use Mockery; use WP_Error; use WP_Post; @@ -42,34 +35,10 @@ final class DiviWriteAbilitiesTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->original = DiviPageLoaderTest::fixture('synthetic-basic.html'); - $this->post = DiviPageLoaderTest::givenPost($this->original); + $this->original = DiviFixtures::markup('synthetic-basic.html'); + $this->post = DiviFixtures::givenPost($this->original); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); - - $modules = Mockery::mock(DiviModules::class); - $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); - $modules->allows('builderVersion')->andReturn('5.2.1'); - $design = Mockery::mock(DesignSystemReader::class); - $design->allows('read')->andReturn(DesignSystemTest::sample()); - $composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()); - - $saver = Mockery::mock(DiviSaver::class); - $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array { - $this->saved[] = $content; - $post->post_content = $content; - - return ['revision_id' => 811, 'warnings' => []]; - }); - $log = Mockery::mock(ChangeLog::class); - $log->allows('record')->andReturn(4711); - - $this->writer = new DiviWriter( - new DiviPageLoader(DiviPageLoaderTest::schema()), - $saver, - $log, - $design, - $composition - ); + $this->writer = DiviWriterFixture::writer($this->saved); } private function treeRev(): string @@ -274,7 +243,7 @@ final class DiviWriteAbilitiesTest extends TestCase /** StructureRules apply on move: a module directly into a section, or into a specialty column. */ public function testMoveRefusedByStructureRules(): void { - $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); + $mixed = DiviFixtures::markup('synthetic-mixed.html'); $this->post->post_content = $mixed; $treeRev = Rev::of($mixed); @@ -342,7 +311,7 @@ final class DiviWriteAbilitiesTest extends TestCase /** An opaque/global module is removed as a whole – it never gets descended into. */ public function testDeleteRemovesAnOpaqueGlobalModuleAsAWhole(): void { - $mixed = DiviPageLoaderTest::fixture('synthetic-mixed.html'); + $mixed = DiviFixtures::markup('synthetic-mixed.html'); $this->post->post_content = $mixed; $globalRow = '\n

    Gutenberg

    \n"); @@ -81,6 +81,6 @@ final class DiviPageLoaderTest extends TestCase DiviFixtures::givenPost('', ['post_status' => 'future', 'post_date_gmt' => '2020-01-01 00:00:00']); $missed = $loader->load(7, true); self::assertTrue($missed->isDiviLayout()); - self::assertSame('lit_validation_failed', $loader->writable($missed)->get_error_code()); + self::assertToolError($loader->writable($missed), 'validation_failed'); } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index c748c83..11e7f0d 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -65,7 +65,7 @@ final class DiviReadAbilitiesTest extends TestCase self::assertSame(['s1', 's1.r0'], array_column($result['nodes'], 'id')); $missing = $ability->execute(['post_id' => 7, 'id' => 's7']); - self::assertSame('lit_not_found', $missing->get_error_code()); + self::assertToolError($missing, 'not_found'); self::assertStringContainsString('tree_rev', $missing->get_error_message()); } @@ -120,7 +120,7 @@ final class DiviReadAbilitiesTest extends TestCase DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design()); - self::assertSame('lit_validation_failed', $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code()); - self::assertSame('lit_not_found', $ability->execute(['post_id' => 7, 'id' => 's5.r0'])->get_error_code()); + self::assertToolError($ability->execute(['post_id' => 7, 'id' => 'hero']), 'validation_failed'); + self::assertToolError($ability->execute(['post_id' => 7, 'id' => 's5.r0']), 'not_found'); } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php index ba92f14..bcdb0c8 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php @@ -107,12 +107,6 @@ final class DiviReplaceTextAbilityTest extends TestCase return $block === null ? null : AttrMap::desktop($block->attrs(), $path); } - /** @return array */ - private static function clientError(WP_Error $error): array - { - return (array) json_decode($error->get_error_message(), true); - } - public function testDryRunListsHitsWithTreeRevAndChangesNothing(): void { $ability = $this->ability(); @@ -177,13 +171,13 @@ final class DiviReplaceTextAbilityTest extends TestCase { $ability = $this->ability(); - self::assertSame('lit_validation_failed', $ability->execute(['search' => 'Termin', 'replace' => 'X', 'dry_run' => false])->get_error_code()); - self::assertSame('lit_validation_failed', $ability->execute([ + self::assertToolError($ability->execute(['search' => 'Termin', 'replace' => 'X', 'dry_run' => false]), 'validation_failed'); + self::assertToolError($ability->execute([ 'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'confirm' => true, 'targets' => [['post_id' => 7]], - ])->get_error_code()); - self::assertSame('lit_confirm_required', $ability->execute([ + ]), 'validation_failed'); + self::assertToolError($ability->execute([ 'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'targets' => [['post_id' => 7, 'tree_rev' => 'abc']], - ])->get_error_code()); + ]), 'confirm_required'); self::assertSame([], $this->saved); } @@ -225,9 +219,9 @@ final class DiviReplaceTextAbilityTest extends TestCase $short = $ability->execute(['search' => 'T', 'replace' => 'X']); $reference = $ability->execute(['search' => 'Termin', 'replace' => self::REF, 'dry_run' => false, 'confirm' => true, 'targets' => [['post_id' => 7, 'tree_rev' => 'x']]]); - self::assertSame('lit_validation_failed', $short->get_error_code()); + self::assertToolError($short, 'validation_failed'); self::assertArrayHasKey('search', self::clientError($short)['fields']); - self::assertSame('lit_validation_failed', $reference->get_error_code()); + self::assertToolError($reference, 'validation_failed'); self::assertArrayHasKey('replace', self::clientError($reference)['fields']); self::assertSame([], $this->queries); self::assertSame([], $this->saved); @@ -357,7 +351,7 @@ final class DiviReplaceTextAbilityTest extends TestCase self::assertSame([], $this->saved); if ($result instanceof WP_Error) { - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertInstanceOf(WP_Error::class, $dry); return; @@ -454,7 +448,7 @@ final class DiviReplaceTextAbilityTest extends TestCase foreach (['', "Ter\0min", "Ter\x0Bmin"] as $search) { $error = $ability->execute(['search' => $search, 'replace' => 'X']); - self::assertSame('lit_validation_failed', $error->get_error_code(), json_encode($search)); + self::assertToolError($error, 'validation_failed', json_encode($search)); self::assertArrayHasKey('search', self::clientError($error)['fields']); } @@ -478,7 +472,7 @@ final class DiviReplaceTextAbilityTest extends TestCase self::assertStringContainsString('LIMIT 3 OFFSET 0', $this->queries[0]); self::assertStringContainsString('LIMIT 3 OFFSET 2', $this->queries[1]); self::assertIsString($second['next_cursor'], 'the fake query returns three rows again'); - self::assertSame('lit_validation_failed', $invalid->get_error_code()); + self::assertToolError($invalid, 'validation_failed'); self::assertStringContainsString('LIMIT 101 OFFSET 0', $this->queries[2]); self::assertCount(3, $capped['matches']); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php index a7d7498..d6840c2 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php @@ -171,7 +171,7 @@ final class DiviSaverTest extends TestCase self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); - self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'partial_write'); } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php index 4092095..3678ef2 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php @@ -71,12 +71,6 @@ final class DiviWriteAbilitiesTest extends TestCase return new DiviDuplicateNodeAbility($this->writer); } - /** @return array The structured error the client sees (message JSON, spec §8.2). */ - private static function clientError(WP_Error $error): array - { - return (array) json_decode($error->get_error_message(), true); - } - public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void { $result = $this->insert()->execute([ @@ -106,7 +100,7 @@ final class DiviWriteAbilitiesTest extends TestCase public function testStaleTreeRevAndInvalidNodesWriteNothing(): void { $stale = $this->insert()->execute(['post_id' => 7, 'tree_rev' => '000000000000', 'nodes' => [['type' => 'section']]]); - self::assertSame('lit_rev_mismatch', $stale->get_error_code()); + self::assertToolError($stale, 'rev_mismatch'); self::assertSame('rev_mismatch', self::clientError($stale)['code'], 'the client sees the code without lit_'); self::assertStringContainsString('lit-divi-outline', $stale->get_error_data()['hint']); self::assertSame($stale->get_error_code(), $this->insert()->permission(['post_id' => 7, 'tree_rev' => 'x'])->get_error_code()); @@ -120,7 +114,7 @@ final class DiviWriteAbilitiesTest extends TestCase ['type' => 'button', 'url' => 'javascript:alert(1)', 'text' => 'x', 'nope' => 1], ], ]); - self::assertSame('lit_invalid_attr_path', $invalid->get_error_code()); + self::assertToolError($invalid, 'invalid_attr_path'); $client = self::clientError($invalid); self::assertSame('invalid_attr_path', $client['code']); self::assertArrayHasKey('nodes[0].attrs.title.fnt', $client['fields']); @@ -128,7 +122,7 @@ final class DiviWriteAbilitiesTest extends TestCase self::assertStringContainsString('lit-divi-catalog', $client['hint']); $wrongPlace = $this->insert()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'nodes' => [['type' => 'button']]]); - self::assertSame('lit_validation_failed', $wrongPlace->get_error_code()); + self::assertToolError($wrongPlace, 'validation_failed'); $badPosition = $this->insert()->execute([ 'post_id' => 7, @@ -137,7 +131,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'after' => 's0.r0.c1.m1', 'nodes' => [['type' => 'text']], ]); - self::assertSame('lit_validation_failed', $badPosition->get_error_code()); + self::assertToolError($badPosition, 'validation_failed'); self::assertArrayHasKey('position', self::clientError($badPosition)['fields']); $unknown = $this->insert()->execute([ @@ -146,7 +140,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'parent' => 's9', 'nodes' => [['type' => 'section']], ]); - self::assertSame('lit_validation_failed', $unknown->get_error_code()); + self::assertToolError($unknown, 'validation_failed'); self::assertSame([], $this->saved); self::assertSame($this->original, $this->post->post_content); } @@ -177,7 +171,7 @@ final class DiviWriteAbilitiesTest extends TestCase ['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']], ['id' => 's0.r0.c1.m9', 'label' => 'x'], ]]); - self::assertSame('lit_validation_failed', $failed->get_error_code()); + self::assertToolError($failed, 'validation_failed'); $fields = self::clientError($failed)['fields']; self::assertArrayHasKey('changes[1].set.level', $fields); self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)'); @@ -220,7 +214,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'changes' => [['id' => 's0.r0.c1.m2', 'label' => 'x']], ]); - self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertToolError($result, 'rev_mismatch'); self::assertSame([], $this->saved); } @@ -251,14 +245,14 @@ final class DiviWriteAbilitiesTest extends TestCase $intoSection = $this->move()->execute([ 'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's1', ]); - self::assertSame('lit_validation_failed', $intoSection->get_error_code()); + self::assertToolError($intoSection, 'validation_failed'); self::assertStringContainsString('Zeilen (row)', self::clientError($intoSection)['fields']['position']); // s0.c1 is a specialty column (specialtyColumns set, holds a row-inner): refuses every child. $intoSpecialtyColumn = $this->move()->execute([ 'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's0.c1', ]); - self::assertSame('lit_validation_failed', $intoSpecialtyColumn->get_error_code()); + self::assertToolError($intoSpecialtyColumn, 'validation_failed'); self::assertStringContainsString('Spezialspalten', self::clientError($intoSpecialtyColumn)['fields']['position']); self::assertSame([], $this->saved); @@ -271,7 +265,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0', 'parent' => 's0.r0.c1', ]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString( 'nicht in sich selbst verschoben', self::clientError($result)['fields']['position'] @@ -285,7 +279,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'post_id' => 7, 'tree_rev' => '000000000000', 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0', ]); - self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertToolError($result, 'rev_mismatch'); self::assertSame('rev_mismatch', self::clientError($result)['code'], 'the client sees the code without lit_'); self::assertSame([], $this->saved); } @@ -349,7 +343,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s9'], 'confirm' => true, ]); - self::assertSame('lit_not_found', $result->get_error_code()); + self::assertToolError($result, 'not_found'); self::assertSame('not_found', self::clientError($result)['code']); self::assertSame([], $this->saved); } @@ -371,10 +365,10 @@ final class DiviWriteAbilitiesTest extends TestCase $delete = $this->delete(); $ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2', 's2.r0']]); - self::assertSame('lit_confirm_required', $ask->get_error_code()); + self::assertToolError($ask, 'confirm_required'); self::assertSame( '1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).', - json_decode($ask->get_error_message(), true)['message'] + self::clientError($ask)['message'] ); $deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2'], 'confirm' => true]); @@ -490,7 +484,7 @@ final class DiviWriteAbilitiesTest extends TestCase foreach ([$this->move(), $this->delete(), $this->duplicate()] as $ability) { $result = $ability->permission(['post_id' => 7, 'tree_rev' => $this->treeRev()]); self::assertInstanceOf(WP_Error::class, $result, $ability->name()); - self::assertSame('lit_forbidden', $result->get_error_code(), $ability->name()); + self::assertToolError($result, 'forbidden', $ability->name()); } self::assertSame([], $this->saved); } @@ -505,7 +499,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'changes' => [['id' => 's1.g1', 'label' => 'x']], ]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('nur lesbar', $result->get_error_message()); self::assertSame([], $this->saved); } @@ -518,7 +512,7 @@ final class DiviWriteAbilitiesTest extends TestCase ['id' => 's0.r0.c1.m2', 'set' => ['url' => '/zwei']], ]]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('changes[0]', self::clientError($result)['fields']['changes[1].id']); self::assertSame([], $this->saved); } @@ -532,7 +526,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]], ]]]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertArrayHasKey('changes[0].attrs.button.innerContent', self::clientError($result)['fields']); self::assertSame([], $this->saved); } @@ -551,7 +545,7 @@ final class DiviWriteAbilitiesTest extends TestCase ]], ]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertArrayHasKey('nodes[0].attrs.button.innerContent', self::clientError($result)['fields']); self::assertSame([], $this->saved); } @@ -564,7 +558,7 @@ final class DiviWriteAbilitiesTest extends TestCase $broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ ['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')], ]]); - self::assertSame('lit_validation_failed', $broken->get_error_code()); + self::assertToolError($broken, 'validation_failed'); self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']); self::assertSame([], $this->saved); @@ -636,9 +630,9 @@ final class DiviWriteAbilitiesTest extends TestCase public function testUpdateKeepsExactRowsStrict(): void { $broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing(null)]]); - self::assertSame('lit_validation_failed', $broken->get_error_code()); + self::assertToolError($broken, 'validation_failed'); $broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('10_24')]]); - self::assertSame('lit_validation_failed', $broken->get_error_code()); + self::assertToolError($broken, 'validation_failed'); self::assertSame([], $this->saved); } @@ -650,7 +644,7 @@ final class DiviWriteAbilitiesTest extends TestCase 'attrs' => ['module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'onclick', 'value' => 'x']]]]], ]]]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); $client = self::clientError($result); self::assertSame('validation_failed', $client['code']); self::assertStringContainsString('nur im Divi-Builder', $client['fields']['changes[0].attrs.module.decoration.attributes']); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php index 28b338c..89ca407 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -54,9 +54,9 @@ final class DiviWriterTest extends TestCase $writer = $this->writer(Mockery::mock(DiviSaver::class), FakeChangeLog::forbidden()); self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])); - self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code()); + self::assertToolError($writer->page(['post_id' => 7]), 'rev_mismatch'); $stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviFixtures::givenPost($content))]); - self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev'); + self::assertToolError($stale, 'rev_mismatch', 'the post rev is no tree_rev'); self::assertStringContainsString('nichts wurde', $stale->get_error_message()); } @@ -72,7 +72,7 @@ final class DiviWriterTest extends TestCase Mockery::mock(DiviComposition::class) ); - self::assertSame('lit_unsupported', $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])->get_error_code()); + self::assertToolError($writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]), 'unsupported'); } public function testSectionLabelOfANode(): void diff --git a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php index 50cf66b..21836c4 100644 --- a/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php @@ -78,10 +78,10 @@ final class DiviCatalogAbilityTest extends TestCase public function testUnknownModuleAndPermission(): void { - self::assertSame('lit_not_found', $this->ability()->execute(['module' => 'slider-pro'])->get_error_code()); + self::assertToolError($this->ability()->execute(['module' => 'slider-pro']), 'not_found'); Functions\when('current_user_can')->justReturn(false); - self::assertSame('lit_forbidden', $this->ability()->permission([])->get_error_code()); + self::assertToolError($this->ability()->permission([]), 'forbidden'); self::assertTrue($this->ability()->annotations()['readonly']); self::assertTrue($this->ability()->isDirect()); } diff --git a/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php index 8917ae3..9862cff 100644 --- a/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php @@ -41,7 +41,7 @@ final class MediaDeleteAbilityTest extends TestCase $result = $this->ability(self::USAGE, FakeChangeLog::forbidden())->execute(['id' => 33]); - self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertToolError($result, 'confirm_required'); self::assertStringContainsString('endgültig', $result->get_error_message()); self::assertStringContainsString('1 Stelle', $result->get_error_message()); self::assertSame(self::USAGE, $result->get_error_data()['usages']); @@ -51,7 +51,7 @@ final class MediaDeleteAbilityTest extends TestCase { $result = $this->ability([], FakeChangeLog::forbidden(), true)->execute(['id' => 33, 'confirm' => false]); - self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertToolError($result, 'confirm_required'); self::assertStringContainsString('Papierkorb', $result->get_error_message()); } @@ -61,7 +61,7 @@ final class MediaDeleteAbilityTest extends TestCase $result = $this->ability(self::USAGE, FakeChangeLog::forbidden())->execute(['id' => 33, 'confirm' => true]); - self::assertSame('lit_in_use', $result->get_error_code()); + self::assertToolError($result, 'in_use'); self::assertSame(self::USAGE, $result->get_error_data()['usages']); } @@ -71,7 +71,7 @@ final class MediaDeleteAbilityTest extends TestCase $result = $this->ability(self::POSSIBLE, FakeChangeLog::forbidden())->execute(['id' => 33, 'confirm' => true]); - self::assertSame('lit_in_use', $result->get_error_code()); + self::assertToolError($result, 'in_use'); } public function testForceDeletesUsedMedia(): void @@ -115,7 +115,7 @@ final class MediaDeleteAbilityTest extends TestCase $result = $this->ability([], FakeChangeLog::forbidden())->execute(['id' => 33, 'confirm' => true]); - self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertToolError($result, 'write_failed'); } public function testRefusedDeleteThatRemovedDataIsAPartialWrite(): void @@ -133,7 +133,7 @@ final class MediaDeleteAbilityTest extends TestCase $result = $this->ability([], $log)->execute(['id' => 33, 'confirm' => true]); - self::assertSame('lit_partial_write', $result->get_error_code()); + self::assertToolError($result, 'partial_write'); self::assertSame(33, $result->get_error_data()['attachment_id']); $log->assertPartials(['ability' => 'lit/media-delete', 'object_type' => 'attachment', 'object_id' => '33']); } @@ -162,7 +162,7 @@ final class MediaDeleteAbilityTest extends TestCase unlink($file); } - self::assertSame('lit_in_use', $result->get_error_code()); + self::assertToolError($result, 'in_use'); self::assertSame(MediaUsageFinder::FAILED_TITLE, $result->get_error_data()['usages'][0]['title']); } @@ -193,8 +193,8 @@ final class MediaDeleteAbilityTest extends TestCase $ability = $this->ability([], FakeChangeLog::forbidden()); - self::assertSame('lit_validation_failed', $ability->permission(['id' => 33])->get_error_code()); - self::assertSame('lit_validation_failed', $ability->execute(['id' => 33, 'confirm' => true])->get_error_code()); + self::assertToolError($ability->permission(['id' => 33]), 'validation_failed'); + self::assertToolError($ability->execute(['id' => 33, 'confirm' => true]), 'validation_failed'); } public function testChecksDeletePostOnTheAttachment(): void @@ -203,7 +203,7 @@ final class MediaDeleteAbilityTest extends TestCase $result = $this->ability([], FakeChangeLog::forbidden())->permission(['id' => 33]); - self::assertSame('lit_forbidden', $result->get_error_code()); + self::assertToolError($result, 'forbidden'); } public function testIsDestructiveAndDescribesWhatTheUsageCheckMisses(): void diff --git a/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php index 7817abf..d6edfbc 100644 --- a/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php @@ -59,7 +59,7 @@ final class MediaDownloadAbilityTest extends TestCase $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); - self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertToolError($result, 'unsupported'); } public function testDownloadsAreCappedAtTenMegabytesEvenWithAHigherUploadLimit(): void @@ -69,7 +69,7 @@ final class MediaDownloadAbilityTest extends TestCase $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); - self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertToolError($result, 'unsupported'); self::assertStringContainsString('Limit 10 MB', $result->get_error_message()); self::assertStringContainsString('URL', (string) $result->get_error_data()['hint']); } @@ -81,7 +81,7 @@ final class MediaDownloadAbilityTest extends TestCase $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40, 'size' => 'huge']); - self::assertSame('lit_not_found', $result->get_error_code()); + self::assertToolError($result, 'not_found'); self::assertNull((new MediaDownloadAbility(new Limits()))->outputSchema()); } @@ -92,6 +92,6 @@ final class MediaDownloadAbilityTest extends TestCase $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); - self::assertSame('lit_not_found', $result->get_error_code()); + self::assertToolError($result, 'not_found'); } } diff --git a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php index 91cfa1b..e501511 100644 --- a/tests/Unit/Integrations/Media/MediaGetAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaGetAbilityTest.php @@ -45,7 +45,7 @@ final class MediaGetAbilityTest extends TestCase Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page'])); $ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class)); - self::assertSame('lit_not_found', $ability->permission(['id' => 5])->get_error_code()); + self::assertToolError($ability->permission(['id' => 5]), 'not_found'); } public function testIsIndirectAndReadonly(): void diff --git a/tests/Unit/Integrations/Media/MediaGuardTest.php b/tests/Unit/Integrations/Media/MediaGuardTest.php index e8cacdc..8e49fa3 100644 --- a/tests/Unit/Integrations/Media/MediaGuardTest.php +++ b/tests/Unit/Integrations/Media/MediaGuardTest.php @@ -13,17 +13,17 @@ final class MediaGuardTest extends TestCase { public function testInvalidIdIsAValidationError(): void { - self::assertSame('lit_validation_failed', MediaGuard::attachment('abc')->get_error_code()); - self::assertSame('lit_validation_failed', MediaGuard::attachment(0)->get_error_code()); + self::assertToolError(MediaGuard::attachment('abc'), 'validation_failed'); + self::assertToolError(MediaGuard::attachment(0), 'validation_failed'); } public function testMissingOrNonAttachmentIsNotFound(): void { Functions\when('get_post')->justReturn(null); - self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code()); + self::assertToolError(MediaGuard::attachment(33), 'not_found'); Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'page'])); - self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code()); + self::assertToolError(MediaGuard::attachment(33), 'not_found'); } public function testChecksTheRequestedCapabilityOnTheAttachment(): void @@ -32,7 +32,7 @@ final class MediaGuardTest extends TestCase Functions\when('get_post')->justReturn($attachment); Functions\expect('current_user_can')->once()->with('delete_post', 33)->andReturn(false); - self::assertSame('lit_forbidden', MediaGuard::attachment('33', 'delete_post')->get_error_code()); + self::assertToolError(MediaGuard::attachment('33', 'delete_post'), 'forbidden'); } public function testRefusesTrashedAttachmentsUnlessAllowed(): void @@ -42,7 +42,7 @@ final class MediaGuardTest extends TestCase Functions\when('current_user_can')->justReturn(true); $error = MediaGuard::attachment(33); - self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertToolError($error, 'validation_failed'); self::assertStringContainsString('Papierkorb', $error->get_error_message()); self::assertSame($attachment, MediaGuard::attachment(33, 'read_post', true)); } diff --git a/tests/Unit/Integrations/Media/MediaImporterTest.php b/tests/Unit/Integrations/Media/MediaImporterTest.php index 6724102..af8d17f 100644 --- a/tests/Unit/Integrations/Media/MediaImporterTest.php +++ b/tests/Unit/Integrations/Media/MediaImporterTest.php @@ -136,7 +136,7 @@ final class MediaImporterTest extends TestCase { $result = $this->importer()->fromBase64(str_repeat('A', 2 * 1024 * 1024), 'big.png', []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload'); } @@ -147,7 +147,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromBase64(str_repeat('A', 16 * 1024 * 1024), 'big.png', []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('Limit 10 MB', $result->get_error_message()); self::assertStringContainsString('lit/media-upload-from-url', (string) $result->get_error_data()['hint']); self::assertSame([], $this->tempFiles); @@ -157,7 +157,7 @@ final class MediaImporterTest extends TestCase { $result = $this->importer()->fromBase64('***', 'x.png', []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertSame([], $this->tempFiles); } @@ -168,7 +168,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromBase64(base64_encode('get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertNotSame([], $this->tempFiles); $this->assertTempFilesRemoved(); } @@ -182,7 +182,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('png', $result->get_error_message()); } @@ -198,7 +198,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); $logged = (string) file_get_contents($this->errorLog); - self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertToolError($result, 'write_failed'); self::assertStringNotContainsString('/var/www', $result->get_error_message()); self::assertStringNotContainsString('/var/www', (string) json_encode($result->get_error_data())); self::assertStringContainsString('WordPress hat die Datei nicht übernommen', $result->get_error_message()); @@ -238,7 +238,7 @@ final class MediaImporterTest extends TestCase self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); - self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'write_failed'); } $this->assertTempFilesRemoved(); } @@ -258,7 +258,7 @@ final class MediaImporterTest extends TestCase self::assertFalse($e->rolledBack); self::assertSame(55, $e->postId); $error = $e->toError(); - self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertToolError($error, 'partial_write'); self::assertStringContainsString('Medium 55', $error->get_error_message()); self::assertSame(55, $error->get_error_data()['attachment_id']); } @@ -282,7 +282,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromUrl('http://127.0.0.1/wp-config.php', null, []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testOnlyHttpUrlsAreFetched(): void @@ -292,7 +292,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromUrl('ftp://example.com/logo.png', null, []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testUrlImportDownloadsSafelyWithTheSizeLimit(): void @@ -332,7 +332,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('Not Found', $result->get_error_message()); $this->assertTempFilesRemoved(); } @@ -344,7 +344,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('404', $result->get_error_message()); $this->assertTempFilesRemoved(); } @@ -356,7 +356,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); $this->assertTempFilesRemoved(); } @@ -368,7 +368,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); $this->assertTempFilesRemoved(); } @@ -396,7 +396,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromBase64(base64_encode(''), 'seite.html', []); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); $this->assertTempFilesRemoved(); } @@ -416,7 +416,7 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); - self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertToolError($result, 'write_failed'); self::assertFileDoesNotExist($moved); self::assertFalse(has_filter('wp_handle_upload')); } @@ -462,6 +462,6 @@ final class MediaImporterTest extends TestCase $result = $this->importer()->fromUrl('https://example.com/logo.png', null, []); - self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertToolError($result, 'write_failed'); } } diff --git a/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php b/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php index 572c62b..d0fbe73 100644 --- a/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php @@ -68,7 +68,7 @@ final class MediaPreviewAbilityTest extends TestCase $result = (new MediaPreviewAbility())->execute(['id' => 33]); - self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertToolError($result, 'unsupported'); self::assertStringContainsString( 'lit/media-download (direkt: lit-media-download)', (string) $result->get_error_data()['hint'] @@ -82,7 +82,7 @@ final class MediaPreviewAbilityTest extends TestCase $result = (new MediaPreviewAbility())->execute(['id' => 33]); - self::assertSame('lit_not_found', $result->get_error_code()); + self::assertToolError($result, 'not_found'); } public function testIsADirectToolWithoutOutputSchema(): void diff --git a/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php index a99cff8..f78ceb5 100644 --- a/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php @@ -68,7 +68,7 @@ final class MediaSearchAbilityTest extends TestCase $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['missing_alt' => true, 'mime' => 'application/pdf']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testMissingAltAllowsAnImageSubtypeMime(): void @@ -108,7 +108,7 @@ final class MediaSearchAbilityTest extends TestCase $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['cursor' => '???']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testExcludesAttachmentsTheUserCannotEdit(): void @@ -303,7 +303,7 @@ final class MediaSearchAbilityTest extends TestCase Functions\when('current_user_can')->justReturn(false); $ability = new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)); - self::assertSame('lit_forbidden', $ability->permission([])->get_error_code()); + self::assertToolError($ability->permission([]), 'forbidden'); self::assertTrue($ability->isDirect()); } } diff --git a/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php index 0895521..eff33b2 100644 --- a/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php @@ -92,7 +92,7 @@ final class MediaUpdateAbilityTest extends TestCase ]]); self::assertInstanceOf(WP_Error::class, $result); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('3 von 4', $result->get_error_message()); self::assertStringContainsString('nichts wurde gespeichert', $result->get_error_message()); $failures = $result->get_error_data()['failures']; @@ -109,7 +109,7 @@ final class MediaUpdateAbilityTest extends TestCase { $result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => '99', 'alt' => 'x']]]); - self::assertSame('lit_not_found', $result->get_error_code()); + self::assertToolError($result, 'not_found'); self::assertSame(99, $result->get_error_data()['failures'][0]['id']); } @@ -138,7 +138,7 @@ final class MediaUpdateAbilityTest extends TestCase ['id' => 34, 'alt' => 'neu', 'modified' => '2026-09-24 07:00:00'], ]]); - self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertToolError($result, 'rev_mismatch'); self::assertSame(1, $result->get_error_data()['failures'][0]['index']); self::assertStringContainsString('lit/media-get', $result->get_error_data()['hint']); self::assertSame([], $this->altUpdates); @@ -162,7 +162,7 @@ final class MediaUpdateAbilityTest extends TestCase self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); - self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'write_failed'); self::assertStringContainsString('neu lesen', $e->toError()->get_error_data()['hint']); self::assertStringContainsString('Datenbankfehler', $e->getMessage()); } @@ -232,7 +232,7 @@ final class MediaUpdateAbilityTest extends TestCase self::fail('WriteFailed expected'); } catch (WriteFailed $e) { $error = $e->toError(); - self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertToolError($error, 'partial_write'); self::assertSame([33], $error->get_error_data()['changed_ids']); self::assertStringContainsString('33', $error->get_error_message()); } diff --git a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php index 938c8fe..30d2d2e 100644 --- a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php +++ b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php @@ -106,7 +106,7 @@ final class MediaUploadAbilitiesTest extends TestCase $result = (new MediaUploadAbility($importer, $this->uploads(), new Limits())) ->execute(['filename' => 'x.png', 'data' => 'AAAA']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testAPartialUploadIsLoggedAsPartialAndRethrown(): void @@ -136,7 +136,7 @@ final class MediaUploadAbilitiesTest extends TestCase $ability = new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()); self::assertTrue($ability->permission(['url' => 'https://example.com/a.png'])); - self::assertSame('lit_forbidden', $ability->permission(['url' => 'https://example.com/a.png', 'post_id' => 5])->get_error_code()); + self::assertToolError($ability->permission(['url' => 'https://example.com/a.png', 'post_id' => 5]), 'forbidden'); } public function testPermissionRefusesUsersWithoutUploadRights(): void @@ -144,7 +144,7 @@ final class MediaUploadAbilitiesTest extends TestCase Functions\when('current_user_can')->justReturn(false); $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); - self::assertSame('lit_forbidden', $ability->permission(['filename' => 'a.png', 'data' => 'AAAA'])->get_error_code()); + self::assertToolError($ability->permission(['filename' => 'a.png', 'data' => 'AAAA']), 'forbidden'); } public function testUploadsForAParentThatDoesNotExistAreRefused(): void @@ -153,7 +153,7 @@ final class MediaUploadAbilitiesTest extends TestCase Functions\when('get_post')->justReturn(null); $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); - self::assertSame('lit_not_found', $ability->permission(['filename' => 'a.png', 'data' => 'AAAA', 'post_id' => 9])->get_error_code()); + self::assertToolError($ability->permission(['filename' => 'a.png', 'data' => 'AAAA', 'post_id' => 9]), 'not_found'); } public function testDescriptionNamesTheConfiguredLimit(): void diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php index 512a8d5..80bb101 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -76,7 +76,7 @@ final class ContentCreateAbilityTest extends TestCase $result = (new ContentCreateAbility($this->schema(), $writer, FakeChangeLog::forbidden())) ->execute(['post_type' => 'lit_person', 'title' => 'Anna']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testCreatesADraftWritesExtrasLogsAndReturnsAToolResult(): void @@ -131,7 +131,7 @@ final class ContentCreateAbilityTest extends TestCase self::assertTrue($failure->rolledBack); self::assertStringContainsString('Pods could not save the fields.', $failure->getMessage()); - self::assertSame('lit_write_failed', $failure->toError()->get_error_code()); + self::assertToolError($failure->toError(), 'write_failed'); } public function testMetaFieldsTheUserMayNotAddAreRefusedAndTheDraftIsRemoved(): void @@ -149,8 +149,8 @@ final class ContentCreateAbilityTest extends TestCase $result = (new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT); - self::assertSame('lit_validation_failed', $result->get_error_code()); - self::assertArrayHasKey('fields.city', $result->get_error_data()['fields']); + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('fields.city', self::errorFields($result)); } public function testARefusedDraftThatCannotBeDeletedIsAPartialWrite(): void @@ -219,7 +219,7 @@ final class ContentCreateAbilityTest extends TestCase self::assertFalse($failure->rolledBack); self::assertNull($failure->postId); - self::assertSame('lit_partial_write', $failure->toError()->get_error_code()); + self::assertToolError($failure->toError(), 'partial_write'); $log->assertPartials(['ability' => 'lit/content-create', 'object_type' => 'lit_person', 'object_id' => '']); } @@ -261,6 +261,6 @@ final class ContentCreateAbilityTest extends TestCase self::assertTrue($ability->permission(['post_type' => 'page'])); Functions\when('current_user_can')->justReturn(false); - self::assertSame('lit_forbidden', $ability->permission(['post_type' => 'page'])->get_error_code()); + self::assertToolError($ability->permission(['post_type' => 'page']), 'forbidden'); } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php index de1e24d..510e947 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -128,14 +128,14 @@ final class ContentCreateDiviTest extends TestCase Functions\expect('wp_insert_post')->never(); $inactive = $this->ability($this->composer(false))->execute(self::INPUT); - self::assertSame('lit_unsupported', $inactive->get_error_code()); + self::assertToolError($inactive, 'unsupported'); $none = $this->ability(null)->execute(self::INPUT); - self::assertSame('lit_unsupported', $none->get_error_code()); + self::assertToolError($none, 'unsupported'); $both = $this->ability($this->composer())->execute(self::INPUT + ['content' => '

    x

    ']); - self::assertSame('lit_validation_failed', $both->get_error_code()); - self::assertArrayHasKey('content', $both->get_error_data()['fields']); + self::assertToolError($both, 'validation_failed'); + self::assertArrayHasKey('content', self::errorFields($both)); } public function testEveryValidationErrorComesBackAndNothingIsCreated(): void @@ -149,10 +149,10 @@ final class ContentCreateDiviTest extends TestCase $error = $this->ability($composer, $writer)->execute(self::INPUT); - self::assertSame('lit_invalid_attr_path', $error->get_error_code()); + self::assertToolError($error, 'invalid_attr_path'); self::assertSame( ['fields.role' => 'Pflichtfeld fehlt.', 'divi[0].attrs.x' => 'unbekannt'], - $error->get_error_data()['fields'] + self::errorFields($error) ); } @@ -181,8 +181,8 @@ final class ContentCreateDiviTest extends TestCase $error = $this->ability($composer)->execute(self::INPUT); self::assertInstanceOf(WP_Error::class, $error); - self::assertSame('lit_validation_failed', $error->get_error_code()); - self::assertArrayHasKey('divi[0].modules[0].text', $error->get_error_data()['fields']); + self::assertToolError($error, 'validation_failed'); + self::assertArrayHasKey('divi[0].modules[0].text', self::errorFields($error)); } public function testARefusedLayoutWhoseDraftCannotBeDeletedIsAPartialWrite(): void @@ -254,8 +254,8 @@ final class ContentCreateDiviTest extends TestCase 'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Lang']], 'modules' => [['type' => 'button', 'text' => 'Los', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'x']]]]], ]]]); - self::assertSame('lit_validation_failed', $overlap->get_error_code()); - $fields = $overlap->get_error_data()['fields']; + self::assertToolError($overlap, 'validation_failed'); + $fields = self::errorFields($overlap); self::assertArrayHasKey('divi[0].attrs.module.meta.adminLabel', $fields, 'S41: label and attrs label'); self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.button.innerContent', $fields, 'S40'); self::assertSame([], $saved); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php index 0d1fea8..e21d765 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php @@ -47,7 +47,7 @@ final class ContentGetAbilityTest extends TestCase $schema = Mockery::mock(ContentSchema::class); $ability = new ContentGetAbility($schema, Mockery::mock(PostPresenter::class)); - self::assertSame('lit_not_found', $ability->permission(['post_id' => 99])->get_error_code()); + self::assertToolError($ability->permission(['post_id' => 99]), 'not_found'); } public function testOutputSchemaAllowsNullContentForDivi(): void diff --git a/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php index 50bbf4e..f38cd57 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentQueryAbilityTest.php @@ -96,7 +96,7 @@ final class ContentQueryAbilityTest extends TestCase { $result = $this->ability()->execute(['post_type' => 'page', 'cursor' => '???']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testQueryIsRestrictedToOwnPostsWithoutEditOthersPosts(): void @@ -193,7 +193,7 @@ final class ContentQueryAbilityTest extends TestCase $result = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => 'ghost']]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('role', $result->get_error_message()); } @@ -209,9 +209,9 @@ final class ContentQueryAbilityTest extends TestCase $password = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => 'secret']]); $protected = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => '_internal']]); - self::assertSame('lit_validation_failed', $password->get_error_code()); + self::assertToolError($password, 'validation_failed'); self::assertSame('Verfügbare Felder: role.', $password->get_error_data()['hint']); - self::assertSame('lit_validation_failed', $protected->get_error_code()); + self::assertToolError($protected, 'validation_failed'); self::assertSame('Verfügbare Felder: role.', $protected->get_error_data()['hint']); } @@ -225,7 +225,7 @@ final class ContentQueryAbilityTest extends TestCase 'taxonomy' => ['taxonomy' => 'ghost', 'terms' => [1]], ]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('category', $result->get_error_message()); } @@ -235,7 +235,7 @@ final class ContentQueryAbilityTest extends TestCase $schema->allows('isManaged')->andReturn(false); $ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class)); - self::assertSame('lit_not_found', $ability->permission(['post_type' => 'secret'])->get_error_code()); + self::assertToolError($ability->permission(['post_type' => 'secret']), 'not_found'); } public function testPermissionIsForbiddenWithoutEditRights(): void @@ -245,7 +245,7 @@ final class ContentQueryAbilityTest extends TestCase $schema = SchemaFixture::managed(); $ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class)); - self::assertSame('lit_forbidden', $ability->permission(['post_type' => 'page'])->get_error_code()); + self::assertToolError($ability->permission(['post_type' => 'page']), 'forbidden'); } public function testInputSchemaLimitsSearchLengthAndTaxonomyTermsCount(): void diff --git a/tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php index 38e4067..7b34756 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php @@ -65,7 +65,7 @@ final class ContentTypesAbilityTest extends TestCase $result = (new ContentTypesAbility($schema))->execute(['post_type' => 'nope']); self::assertInstanceOf(WP_Error::class, $result); - self::assertSame('lit_not_found', $result->get_error_code()); + self::assertToolError($result, 'not_found'); self::assertStringContainsString('page, post', $result->get_error_message()); } @@ -95,7 +95,7 @@ final class ContentTypesAbilityTest extends TestCase $result = (new ContentTypesAbility($schema))->execute(['post_type' => 'page']); self::assertInstanceOf(WP_Error::class, $result); - self::assertSame('lit_forbidden', $result->get_error_code()); + self::assertToolError($result, 'forbidden'); } public function testPermissionRequiresEditPosts(): void @@ -103,7 +103,7 @@ final class ContentTypesAbilityTest extends TestCase Functions\when('current_user_can')->justReturn(false); $ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class)); - self::assertSame('lit_forbidden', $ability->permission([])->get_error_code()); + self::assertToolError($ability->permission([]), 'forbidden'); } public function testPermissionForbidsANonEditableSpecificType(): void @@ -115,7 +115,7 @@ final class ContentTypesAbilityTest extends TestCase $result = (new ContentTypesAbility($schema))->permission(['post_type' => 'lit_person']); self::assertInstanceOf(WP_Error::class, $result); - self::assertSame('lit_forbidden', $result->get_error_code()); + self::assertToolError($result, 'forbidden'); } public function testPermissionAllowsAnEditableSpecificType(): void diff --git a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php index 4ab5b12..8d34db9 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php @@ -71,7 +71,7 @@ final class ContentUpdateAbilityTest extends TestCase $result = $this->ability(Mockery::mock(PostWriter::class)) ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testAMissedScheduleIsRefusedBecauseSavingWouldPublish(): void @@ -84,8 +84,8 @@ final class ContentUpdateAbilityTest extends TestCase $result = $this->ability(Mockery::mock(PostWriter::class)) ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']); - self::assertSame('lit_validation_failed', $result->get_error_code()); - self::assertArrayHasKey('post_id', $result->get_error_data()['fields']); + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('post_id', self::errorFields($result)); self::assertStringContainsString('lit/post-schedule', $result->get_error_data()['hint']); } @@ -96,7 +96,7 @@ final class ContentUpdateAbilityTest extends TestCase $result = $this->ability(Mockery::mock(PostWriter::class)) ->execute(['post_id' => 5, 'rev' => 'stale', 'title' => 'Neu']); - self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertToolError($result, 'rev_mismatch'); self::assertStringContainsString('lit-content-get', $result->get_error_data()['hint']); } @@ -105,7 +105,7 @@ final class ContentUpdateAbilityTest extends TestCase $result = $this->ability(Mockery::mock(PostWriter::class)) ->execute(['post_id' => 5, 'rev' => Rev::of('

    Alt

    '), 'title' => 'Neu']); - self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertToolError($result, 'rev_mismatch'); } public function testRawContentOnDiviPagesIsUnsupported(): void @@ -117,7 +117,7 @@ final class ContentUpdateAbilityTest extends TestCase $result = $this->ability($writer) ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'content' => '

    Neu

    ']); - self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertToolError($result, 'unsupported'); self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, $result->get_error_data()['hint']); } @@ -129,7 +129,7 @@ final class ContentUpdateAbilityTest extends TestCase $result = $this->ability($writer)->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post)]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testUpdateKeepsTheStatusAndCapturesARestorePointFirst(): void @@ -236,7 +236,7 @@ final class ContentUpdateAbilityTest extends TestCase $failure = $this->executeExpectingFailure($writer, $input); self::assertTrue($failure->rolledBack); - self::assertSame('lit_write_failed', $failure->toError()->get_error_code()); + self::assertToolError($failure->toError(), 'write_failed'); $message = $failure->toError()->get_error_message(); self::assertStringNotContainsString('Nichts wurde gespeichert', $message); self::assertStringContainsString('Die Änderung wurde nicht gespeichert (Revision 811 bleibt erhalten).', $message); @@ -326,7 +326,7 @@ final class ContentUpdateAbilityTest extends TestCase self::assertFalse($failure->rolledBack); self::assertStringContainsString('restore kaputt', $failure->getMessage()); $error = $failure->toError(); - self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertToolError($error, 'partial_write'); self::assertSame(5, $error->get_error_data()['post_id']); self::assertSame(811, $error->get_error_data()['revision_id']); $log->assertPartials([ diff --git a/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php index 8bd7146..ac7d603 100644 --- a/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php +++ b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php @@ -48,8 +48,8 @@ final class PostWriterTest extends TestCase 'template' => 'fehlt.php', ], true); - $fields = $result->get_error_data()['fields']; - self::assertSame('lit_validation_failed', $result->get_error_code()); + $fields = self::errorFields($result); + self::assertToolError($result, 'validation_failed'); self::assertSame('Pflichtfeld fehlt.', $fields['fields.role']); self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $fields['terms.genre']); self::assertSame('Inhaltstyp unterstützt kein Beitragsbild.', $fields['featured_image']); @@ -72,7 +72,7 @@ final class PostWriterTest extends TestCase $result = $writer->prepare('page', $input, false); self::assertFalse(is_array($result), $path . ' should be rejected'); - return (string) $result->get_error_data()['fields'][$path]; + return (string) self::errorFields($result)[$path]; } public function testParentMustBeAReadableNonDescendantEntryOfAHierarchicalType(): void @@ -152,7 +152,7 @@ final class PostWriterTest extends TestCase false ); - self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], $result->get_error_data()['fields']); + self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], self::errorFields($result)); self::assertSame([['edit_post_meta', 5, 'city'], ['edit_post_meta', 5, 'secret']], $checked); } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php index a1b0fe6..0f01a60 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuAssignLocationAbilityTest.php @@ -50,10 +50,10 @@ final class MenuAssignLocationAbilityTest extends TestCase $result = (new MenuAssignLocationAbility(FakeChangeLog::forbidden())) ->execute(['location' => 'primary-menu', 'menu_id' => 0]); - self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertToolError($result, 'confirm_required'); self::assertStringContainsString( '"Hauptmenü" (ID 3)', - json_decode($result->get_error_message(), true)['message'] + self::clientError($result)['message'] ); } @@ -104,7 +104,7 @@ final class MenuAssignLocationAbilityTest extends TestCase $result = (new MenuAssignLocationAbility(FakeChangeLog::forbidden())) ->execute(['location' => 'primary-menu', 'menu_id' => 99]); - self::assertSame('lit_not_found', $result->get_error_code()); + self::assertToolError($result, 'not_found'); } public function testPermissionNeedsThemeOptionRights(): void @@ -114,7 +114,7 @@ final class MenuAssignLocationAbilityTest extends TestCase $ability = new MenuAssignLocationAbility(FakeChangeLog::forbidden()); $input = ['location' => 'primary-menu', 'menu_id' => 8]; - self::assertSame('lit_forbidden', $ability->permission($input)->get_error_code()); + self::assertToolError($ability->permission($input), 'forbidden'); } public function testIsDestructiveBecauseItReplacesTheMenuOfTheLocation(): void diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php index b75ab3c..65bf163 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuCreateAbilityTest.php @@ -20,7 +20,7 @@ final class MenuCreateAbilityTest extends TestCase $ability = new MenuCreateAbility(FakeChangeLog::forbidden()); $result = $ability->execute(['name' => 'Neu', 'location' => 'nope']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('primary-menu', $result->get_error_message()); } @@ -32,7 +32,7 @@ final class MenuCreateAbilityTest extends TestCase $result = (new MenuCreateAbility(FakeChangeLog::forbidden()))->execute(['name' => 'Hauptmenü']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testCreatesAndAssignsTheLocation(): void @@ -65,6 +65,6 @@ final class MenuCreateAbilityTest extends TestCase $ability = new MenuCreateAbility(FakeChangeLog::forbidden()); - self::assertSame('lit_forbidden', $ability->permission(['name' => 'Neu'])->get_error_code()); + self::assertToolError($ability->permission(['name' => 'Neu']), 'forbidden'); } } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php index 68df89a..6cc6362 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuOperationsTest.php @@ -48,9 +48,9 @@ final class MenuOperationsTest extends TestCase $cycle = $operations->apply($this->tree(), [['op' => 'move', 'id' => 1, 'parent_id' => 2]]); $badOp = $operations->apply($this->tree(), [['op' => 'rename', 'id' => 1]]); - self::assertArrayHasKey('operations.0', $unknown->get_error_data()['fields']); - self::assertStringContainsString('unter sich selbst', $cycle->get_error_data()['fields']['operations.0']); - self::assertStringContainsString('add, move oder remove', $badOp->get_error_data()['fields']['operations.0']); + self::assertArrayHasKey('operations.0', self::errorFields($unknown)); + self::assertStringContainsString('unter sich selbst', self::errorFields($cycle)['operations.0']); + self::assertStringContainsString('add, move oder remove', self::errorFields($badOp)['operations.0']); } public function testExistingNodesKeepOnlyTheirIdSoTheWriterKeepsTheirData(): void diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php index d9d3a6f..1dfe760 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuReadAbilitiesTest.php @@ -51,14 +51,14 @@ final class MenuReadAbilitiesTest extends TestCase self::assertSame('Start', $result['items'][0]['title']); self::assertSame(Rev::ofMenu([self::item(10, 0, 'Start')]), $result['menu_rev']); self::assertContains('menu_rev', $ability->outputSchema()['required']); - self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9])->get_error_code()); + self::assertToolError($ability->execute(['menu_id' => 9]), 'not_found'); } public function testReadToolsNeedThemeOptionRights(): void { Functions\when('current_user_can')->justReturn(false); - self::assertSame('lit_forbidden', (new MenuListAbility())->permission([])->get_error_code()); - self::assertSame('lit_forbidden', (new MenuGetAbility())->permission(['menu_id' => 3])->get_error_code()); + self::assertToolError((new MenuListAbility())->permission([]), 'forbidden'); + self::assertToolError((new MenuGetAbility())->permission(['menu_id' => 3]), 'forbidden'); } } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php index 51a4798..7a476f8 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuSetItemsAbilityTest.php @@ -42,10 +42,10 @@ final class MenuSetItemsAbilityTest extends TestCase $stale = $ability->execute(['menu_id' => 3, 'menu_rev' => 'abcdefabcdef', 'items' => [['id' => 10]]]); $missing = $ability->execute(['menu_id' => 3, 'items' => [['id' => 10]]]); - self::assertSame('lit_rev_mismatch', $stale->get_error_code()); + self::assertToolError($stale, 'rev_mismatch'); self::assertStringContainsString('lit/menu-get', $stale->get_error_data()['hint']); self::assertStringContainsString('Menü', $stale->get_error_message()); - self::assertSame('lit_rev_mismatch', $missing->get_error_code()); + self::assertToolError($missing, 'rev_mismatch'); self::assertContains('menu_rev', $ability->inputSchema()['required']); self::assertStringContainsString('menu_rev', $ability->description()); } @@ -68,7 +68,7 @@ final class MenuSetItemsAbilityTest extends TestCase { $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), FakeChangeLog::forbidden()); - self::assertSame('lit_validation_failed', $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev()])->get_error_code()); + self::assertToolError($ability->execute(['menu_id' => 3, 'menu_rev' => self::rev()]), 'validation_failed'); self::assertSame( 'lit_validation_failed', $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), 'items' => [], 'operations' => [['op' => 'remove', 'id' => 10]]]) @@ -81,7 +81,7 @@ final class MenuSetItemsAbilityTest extends TestCase Functions\when('wp_get_nav_menu_object')->justReturn(false); $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), FakeChangeLog::forbidden()); - self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9, 'items' => []])->get_error_code()); + self::assertToolError($ability->execute(['menu_id' => 9, 'items' => []]), 'not_found'); } public function testRemovalsNeedConfirmationAndNothingIsWritten(): void @@ -92,7 +92,7 @@ final class MenuSetItemsAbilityTest extends TestCase $result = $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), 'items' => []]); - self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertToolError($result, 'confirm_required'); self::assertSame([['id' => 10, 'title' => 'Home']], $result->get_error_data()['removals']); } @@ -109,8 +109,8 @@ final class MenuSetItemsAbilityTest extends TestCase 'items' => [['id' => 10], ['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X']], ]); - self::assertSame('lit_validation_failed', $result->get_error_code()); - self::assertArrayHasKey('items.1', $result->get_error_data()['fields']); + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('items.1', self::errorFields($result)); } public function testInvalidOperationsWriteNothing(): void @@ -121,7 +121,7 @@ final class MenuSetItemsAbilityTest extends TestCase $result = $ability->execute(['menu_id' => 3, 'menu_rev' => self::rev(), 'operations' => [['op' => 'move', 'id' => 99]]]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testAppliesAndReturnsTheTreeBeforeAndAfter(): void @@ -190,7 +190,7 @@ final class MenuSetItemsAbilityTest extends TestCase ['menu_id' => 3, 'menu_rev' => self::rev(), 'items' => [['type' => 'post', 'object_id' => 7]], 'allow_unpublished' => true] ); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); self::assertArrayHasKey('allow_unpublished', $ability->inputSchema()['properties']); } @@ -209,8 +209,8 @@ final class MenuSetItemsAbilityTest extends TestCase $refused = $ability->execute($input); $allowed = $ability->execute($input + ['allow_unpublished' => true]); - self::assertSame('lit_validation_failed', $refused->get_error_code()); - self::assertStringContainsString('allow_unpublished', $refused->get_error_data()['fields']['items.1']); + self::assertToolError($refused, 'validation_failed'); + self::assertStringContainsString('allow_unpublished', self::errorFields($refused)['items.1']); self::assertStringContainsString('1 neu', $allowed['summary']); self::assertStringContainsString('sofort im Menü sichtbar', $allowed['warnings'][0]); } @@ -230,8 +230,8 @@ final class MenuSetItemsAbilityTest extends TestCase 'operations' => [['op' => 'add', 'item' => ['type' => 'post', 'object_id' => 7]]], ]); - self::assertSame('lit_validation_failed', $result->get_error_code()); - self::assertStringContainsString('Private Inhalte', $result->get_error_data()['fields']['items.1']); + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString('Private Inhalte', self::errorFields($result)['items.1']); } public function testAWriteFailureIsPassedOnAsWriteFailed(): void @@ -290,7 +290,7 @@ final class MenuSetItemsAbilityTest extends TestCase Functions\when('current_user_can')->justReturn(false); $ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), FakeChangeLog::forbidden()); - self::assertSame('lit_forbidden', $ability->permission(['menu_id' => 3, 'menu_rev' => self::rev()])->get_error_code()); + self::assertToolError($ability->permission(['menu_id' => 3, 'menu_rev' => self::rev()]), 'forbidden'); } public function testIsDestructiveAndDescribesWhenConfirmIsNeeded(): void diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php index b36fcb8..4edcfe2 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php @@ -334,7 +334,7 @@ final class MenuWriterTest extends TestCase } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertStringContainsString('kaputt', $e->getMessage()); - self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'write_failed'); } $restore = $calls[3]; @@ -357,7 +357,7 @@ final class MenuWriterTest extends TestCase } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); $error = $e->toError(); - self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertToolError($error, 'partial_write'); self::assertSame(3, $error->get_error_data()['menu_id']); self::assertSame('Home', $error->get_error_data()['previous_items'][0]['title']); self::assertStringContainsString('lit/menu-get', $error->get_error_data()['hint']); diff --git a/tests/Unit/Integrations/WordPress/PostGuardTest.php b/tests/Unit/Integrations/WordPress/PostGuardTest.php index b88bbe1..7e8fc69 100644 --- a/tests/Unit/Integrations/WordPress/PostGuardTest.php +++ b/tests/Unit/Integrations/WordPress/PostGuardTest.php @@ -23,16 +23,16 @@ final class PostGuardTest extends TestCase public function testRejectsInvalidIds(): void { - self::assertSame('lit_validation_failed', PostGuard::editable('abc', $this->schema())->get_error_code()); - self::assertSame('lit_validation_failed', PostGuard::editable(-3, $this->schema())->get_error_code()); + self::assertToolError(PostGuard::editable('abc', $this->schema()), 'validation_failed'); + self::assertToolError(PostGuard::editable(-3, $this->schema()), 'validation_failed'); } public function testMissingOrUnmanagedPostsAreNotFound(): void { Functions\when('get_post')->alias(static fn (int $id) => $id === 8 ? new WP_Post(['ID' => 8, 'post_type' => 'et_pb_layout']) : null); - self::assertSame('lit_not_found', PostGuard::editable(5, $this->schema())->get_error_code()); - self::assertSame('lit_not_found', PostGuard::editable(8, $this->schema())->get_error_code()); + self::assertToolError(PostGuard::editable(5, $this->schema()), 'not_found'); + self::assertToolError(PostGuard::editable(8, $this->schema()), 'not_found'); } public function testRequiresEditRightsAndReturnsThePost(): void @@ -44,7 +44,7 @@ final class PostGuardTest extends TestCase self::assertSame($post, PostGuard::editable('5', $this->schema())); Functions\when('current_user_can')->justReturn(false); - self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code()); + self::assertToolError(PostGuard::editable(5, $this->schema()), 'forbidden'); } public function testTrashedPostsAreRefusedUnlessAllowed(): void @@ -54,8 +54,8 @@ final class PostGuardTest extends TestCase Functions\when('current_user_can')->justReturn(true); $error = PostGuard::editable(5, $this->schema(), 'copy_id'); - self::assertSame('lit_validation_failed', $error->get_error_code()); - self::assertArrayHasKey('copy_id', $error->get_error_data()['fields']); + self::assertToolError($error, 'validation_failed'); + self::assertArrayHasKey('copy_id', self::errorFields($error)); self::assertSame($post, PostGuard::editable(5, $this->schema(), 'post_id', true)); } @@ -107,8 +107,8 @@ final class PostGuardTest extends TestCase $error = PostGuard::statusStable($post, 'original_id'); - self::assertSame('lit_validation_failed', $error->get_error_code()); - self::assertArrayHasKey('original_id', $error->get_error_data()['fields']); + self::assertToolError($error, 'validation_failed'); + self::assertArrayHasKey('original_id', self::errorFields($error)); self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']); self::assertStringContainsString('lit/post-schedule', $error->get_error_data()['hint']); self::assertNull(PostGuard::statusStable(new WP_Post(['post_status' => 'publish']))); @@ -141,12 +141,12 @@ final class PostGuardTest extends TestCase Functions\when('get_post_type_object')->justReturn($object); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_pages'); - self::assertSame('lit_not_found', PostGuard::postType('et_pb_layout', $this->schema(), 'create_posts')->get_error_code()); - self::assertSame('lit_not_found', PostGuard::postType(null, $this->schema(), 'create_posts')->get_error_code()); + self::assertToolError(PostGuard::postType('et_pb_layout', $this->schema(), 'create_posts'), 'not_found'); + self::assertToolError(PostGuard::postType(null, $this->schema(), 'create_posts'), 'not_found'); self::assertSame($object, PostGuard::postType('page', $this->schema(), 'create_posts')); Functions\when('current_user_can')->justReturn(false); - self::assertSame('lit_forbidden', PostGuard::postType('page', $this->schema(), 'create_posts')->get_error_code()); + self::assertToolError(PostGuard::postType('page', $this->schema(), 'create_posts'), 'forbidden'); } public function testAssertNoMissedScheduleRefusesAMissedScheduleWithTheCallersMessage(): void diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php index 9a6e110..1ec88e4 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php @@ -50,7 +50,7 @@ final class RevisionRestoreAbilityTest extends TestCase public function testNonRevisionIdIsNotFound(): void { - self::assertSame('lit_not_found', $this->ability(FakeChangeLog::forbidden())->permission(['revision_id' => 5])->get_error_code()); + self::assertToolError($this->ability(FakeChangeLog::forbidden())->permission(['revision_id' => 5]), 'not_found'); } public function testRequiresConfirmation(): void @@ -59,7 +59,7 @@ final class RevisionRestoreAbilityTest extends TestCase $result = $this->ability(FakeChangeLog::forbidden())->execute(['revision_id' => 810]); - self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertToolError($result, 'confirm_required'); } public function testOrphanRevisionYieldsValidationErrorOnRevisionIdField(): void @@ -71,8 +71,8 @@ final class RevisionRestoreAbilityTest extends TestCase $error = $this->ability(FakeChangeLog::forbidden()) ->permission(['revision_id' => 810, 'rev' => 'irrelevant', 'confirm' => true]); - self::assertSame('lit_validation_failed', $error->get_error_code()); - self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']); + self::assertToolError($error, 'validation_failed'); + self::assertArrayHasKey('revision_id', self::errorFields($error)); } public function testATrashedEntryIsRefused(): void @@ -86,8 +86,8 @@ final class RevisionRestoreAbilityTest extends TestCase $error = $this->ability(FakeChangeLog::forbidden())->execute(['revision_id' => 810, 'rev' => 'x', 'confirm' => true]); - self::assertSame('lit_validation_failed', $error->get_error_code()); - self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']); + self::assertToolError($error, 'validation_failed'); + self::assertArrayHasKey('revision_id', self::errorFields($error)); } public function testAMissedScheduleIsRefusedBecauseRestoringWouldPublish(): void @@ -110,7 +110,7 @@ final class RevisionRestoreAbilityTest extends TestCase 'confirm' => true, ]); - self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertToolError($error, 'validation_failed'); self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']); } @@ -121,7 +121,7 @@ final class RevisionRestoreAbilityTest extends TestCase $result = $this->ability(FakeChangeLog::forbidden()) ->execute(['revision_id' => 810, 'confirm' => true, 'rev' => 'stale']); - self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertToolError($result, 'rev_mismatch'); } public function testSavesTheCurrentStateThenRestores(): void @@ -173,7 +173,7 @@ final class RevisionRestoreAbilityTest extends TestCase 'rev' => $this->currentRev(), ]); - self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertToolError($result, 'write_failed'); self::assertStringNotContainsString('Nichts wurde gespeichert', $result->get_error_message()); self::assertStringContainsString( 'Die Änderung wurde nicht gespeichert (Revision 813 bleibt erhalten).', diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php index 8474782..69aac9e 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php @@ -79,6 +79,6 @@ final class RevisionsListAbilityTest extends TestCase { $result = $this->ability()->execute(['post_id' => 5, 'cursor' => 'not-a-cursor']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } } diff --git a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php index 01382d0..9ac1386 100644 --- a/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php +++ b/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php @@ -120,8 +120,8 @@ final class WpSettingsAbilitiesTest extends TestCase $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden())) ->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]); - self::assertSame('lit_validation_failed', $result->get_error_code()); - self::assertArrayHasKey('admin_email', $result->get_error_data()['fields']); + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('admin_email', self::errorFields($result)); } public function testRejectsAGenuinelyUnknownKey(): void @@ -131,8 +131,8 @@ final class WpSettingsAbilitiesTest extends TestCase $result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden())) ->execute(['settings' => ['template' => 'twentyten']]); - self::assertSame('lit_validation_failed', $result->get_error_code()); - self::assertArrayHasKey('template', $result->get_error_data()['fields']); + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('template', self::errorFields($result)); } public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void @@ -221,7 +221,7 @@ final class WpSettingsAbilitiesTest extends TestCase 'blogdescription' => 'Auch neu', ]); - self::assertSame('lit_write_failed', $error->get_error_code()); + self::assertToolError($error, 'write_failed'); $log->assertRecorded(); $log->assertPartials(); } @@ -252,7 +252,7 @@ final class WpSettingsAbilitiesTest extends TestCase 'blogdescription' => 'Auch neu', ]); - self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertToolError($error, 'partial_write'); self::assertStringContainsString('blogname', $error->get_error_message()); self::assertStringContainsString('lit/wp-settings-get', $error->get_error_data()['hint']); $log->assertPartials([ @@ -278,7 +278,7 @@ final class WpSettingsAbilitiesTest extends TestCase { Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options'); - self::assertSame('lit_forbidden', (new WpSettingsGetAbility(new SettingsWhitelist()))->permission([])->get_error_code()); + self::assertToolError((new WpSettingsGetAbility(new SettingsWhitelist()))->permission([]), 'forbidden'); self::assertSame( 'lit_forbidden', (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->permission([])->get_error_code() diff --git a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php index f2c58c0..55862cb 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php @@ -230,7 +230,7 @@ final class PostClonerTest extends TestCase self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); - self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'write_failed'); } } diff --git a/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php index 4a6ee0f..498925e 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostDuplicateAbilityTest.php @@ -71,7 +71,7 @@ final class PostDuplicateAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden())->execute(['post_id' => 5]); - self::assertSame('lit_forbidden', $result->get_error_code()); + self::assertToolError($result, 'forbidden'); } public function testATrashedOriginalIsRefused(): void @@ -82,7 +82,7 @@ final class PostDuplicateAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden())->execute(['post_id' => 5]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testIsIndirectAndNotIdempotent(): void diff --git a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php index 024ae10..87fc9d1 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php @@ -95,7 +95,7 @@ final class PostPublishAbilityTest extends TestCase self::assertFalse($e->rolledBack); self::assertSame(5, $e->postId); self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); - self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'partial_write'); } $log->assertPartials(['ability' => 'lit/post-publish', 'object_type' => 'page', 'object_id' => '5']); } @@ -107,7 +107,7 @@ final class PostPublishAbilityTest extends TestCase $result = $this->ability(FakeChangeLog::forbidden())->execute(['post_id' => 5]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testPermissionNeedsPublishRights(): void @@ -115,6 +115,6 @@ final class PostPublishAbilityTest extends TestCase $this->stubPost(['post_status' => 'draft']); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'publish_post'); - self::assertSame('lit_forbidden', $this->ability(FakeChangeLog::forbidden())->permission(['post_id' => 5])->get_error_code()); + self::assertToolError($this->ability(FakeChangeLog::forbidden())->permission(['post_id' => 5]), 'forbidden'); } } diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php index cd84fe7..48f7ced 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -68,7 +68,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden()) ->execute(['original_id' => 5, 'copy_id' => 77, 'rev' => Rev::ofPost($this->original)]); - self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertToolError($result, 'confirm_required'); } public function testCopyMustStemFromTheOriginal(): void @@ -79,7 +79,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden())->execute($this->input()); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } /** @return list */ @@ -103,7 +103,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden())->execute($this->input()); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testAMissedScheduleOfTheOriginalIsRefused(): void @@ -116,8 +116,8 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden())->execute($this->input()); - self::assertSame('lit_validation_failed', $result->get_error_code()); - self::assertArrayHasKey('original_id', $result->get_error_data()['fields']); + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('original_id', self::errorFields($result)); } public function testAPendingCopyIsAccepted(): void @@ -142,7 +142,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden())->execute($this->input(['rev' => 'veraltet'])); - self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertToolError($result, 'rev_mismatch'); } public function testDisabledTrashIsRefusedBeforeAnyChange(): void @@ -152,7 +152,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $result = $this->ability($cloner, FakeChangeLog::forbidden(), false)->execute($this->input()); - self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertToolError($result, 'unsupported'); } public function testRevisionThenReplaceThenTrashTheCopy(): void @@ -232,7 +232,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); - self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'write_failed'); self::assertSame(88, $e->toError()->get_error_data()['backup_id']); $message = $e->toError()->get_error_message(); self::assertStringNotContainsString('Nichts wurde gespeichert', $message); @@ -259,7 +259,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::assertSame(5, $e->postId); self::assertSame(900, $e->revisionId); $error = $e->toError(); - self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertToolError($error, 'partial_write'); self::assertSame(88, $error->get_error_data()['backup_id']); self::assertStringContainsString('Sicherung', $error->get_error_message()); self::assertStringContainsString('88', $error->get_error_message()); @@ -280,8 +280,8 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $cloner->expects('replace')->never(); $ability = $this->ability($cloner, FakeChangeLog::forbidden()); - self::assertSame('lit_forbidden', $ability->permission($this->input())->get_error_code()); - self::assertSame('lit_forbidden', $ability->execute($this->input())->get_error_code()); + self::assertToolError($ability->permission($this->input()), 'forbidden'); + self::assertToolError($ability->execute($this->input()), 'forbidden'); } public function testAFailedBackupAbortsBeforeTheOriginalIsTouched(): void @@ -298,7 +298,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $this->ability($cloner, FakeChangeLog::forbidden())->execute($this->input()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { - self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'write_failed'); } } diff --git a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php index 12fa5a3..e36c3a2 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php @@ -92,7 +92,7 @@ final class PostScheduleAbilityTest extends TestCase self::assertFalse($e->rolledBack); self::assertSame(5, $e->postId); self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); - self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'partial_write'); } $log->assertPartials(['ability' => 'lit/post-schedule', 'object_type' => 'post', 'object_id' => '5']); } @@ -104,7 +104,7 @@ final class PostScheduleAbilityTest extends TestCase $result = $this->ability(FakeChangeLog::forbidden())->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testPastAndImpossibleDatesAreRejected(): void @@ -115,9 +115,9 @@ final class PostScheduleAbilityTest extends TestCase $past = $ability->execute(['post_id' => 5, 'date' => '2020-01-01 10:00']); $impossible = $ability->execute(['post_id' => 5, 'date' => '2099-13-45 10:00']); - self::assertSame('lit_validation_failed', $past->get_error_code()); + self::assertToolError($past, 'validation_failed'); self::assertStringContainsString('Zukunft', $past->get_error_message()); - self::assertSame('lit_validation_failed', $impossible->get_error_code()); + self::assertToolError($impossible, 'validation_failed'); } public function testADateWithinTheNextMinuteIsRejectedBecauseWordPressWouldPublishAtOnce(): void @@ -131,6 +131,6 @@ final class PostScheduleAbilityTest extends TestCase $result = $this->ability(FakeChangeLog::forbidden()) ->execute(['post_id' => 5, 'date' => $soon->format('Y-m-d H:i')]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } } diff --git a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php index df680e4..38cfeb0 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php @@ -44,7 +44,7 @@ final class PostTrashAbilityTest extends TestCase $result = $this->ability(FakeChangeLog::forbidden())->execute(['post_id' => 5]); - self::assertSame('lit_confirm_required', $result->get_error_code()); + self::assertToolError($result, 'confirm_required'); } public function testMovesToTrashNeverDeletes(): void @@ -76,7 +76,7 @@ final class PostTrashAbilityTest extends TestCase $result = $ability->execute(['post_id' => 5, 'confirm' => true]); - self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertToolError($result, 'unsupported'); } public function testAHookThrowingDuringTheWriteIsAPartialWrite(): void @@ -93,7 +93,7 @@ final class PostTrashAbilityTest extends TestCase self::assertFalse($e->rolledBack); self::assertSame(5, $e->postId); self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); - self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'partial_write'); } $log->assertPartials(['ability' => 'lit/post-trash', 'object_type' => 'page', 'object_id' => '5']); } diff --git a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php index 11eee39..e5bd54f 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php @@ -52,7 +52,7 @@ final class PostUnpublishAbilityTest extends TestCase self::assertFalse($e->rolledBack); self::assertSame(5, $e->postId); self::assertStringContainsString('save_post-Hook kaputt', $e->getMessage()); - self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + self::assertToolError($e->toError(), 'partial_write'); } $log->assertPartials(['ability' => 'lit/post-unpublish', 'object_type' => 'page', 'object_id' => '5']); } @@ -63,7 +63,7 @@ final class PostUnpublishAbilityTest extends TestCase $result = $this->ability(FakeChangeLog::forbidden(), 'trash')->execute(['post_id' => 5]); - self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertToolError($result, 'validation_failed'); } public function testDraftIsANoOp(): void diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index ccdd205..b96c3e2 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -80,6 +80,6 @@ final class WordPressIntegrationTest extends TestCase ))[0]; $result = $create->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]]); - self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertToolError($result, 'unsupported'); } } diff --git a/tests/Unit/Support/AssertsToolErrorsTest.php b/tests/Unit/Support/AssertsToolErrorsTest.php new file mode 100644 index 0000000..7590084 --- /dev/null +++ b/tests/Unit/Support/AssertsToolErrorsTest.php @@ -0,0 +1,44 @@ + 'Pflichtfeld']), 'validation_failed'); + + self::assertSame('Titel fehlt.', $payload['message']); + self::assertSame(['title' => 'Pflichtfeld'], self::errorFields(Errors::invalidInput('x', ['title' => 'Pflichtfeld']))); + } + + /** @return array Built in the test: Errors needs the stubs of setUp(). */ + public static function notTheError(): array + { + return [ + 'another code' => [static fn () => Errors::notFound('weg')], + 'no JSON payload' => [static fn () => new WP_Error('lit_validation_failed', 'kein JSON')], + 'no error at all' => [static fn () => ['ok' => true]], + ]; + } + + /** @dataProvider notTheError */ + public function testFailsForAnythingElse(callable $result): void + { + $this->expectException(AssertionFailedError::class); + self::assertToolError($result(), 'validation_failed'); + } + + public function testErrorFieldsFailsWithoutFields(): void + { + $this->expectException(AssertionFailedError::class); + self::errorFields(Errors::invalidInput('x')); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 3636e88..7fa1e2c 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -6,12 +6,14 @@ namespace LIT\MCP\Tests\Unit; use Brain\Monkey; use Brain\Monkey\Functions; +use LIT\MCP\Tests\Support\AssertsToolErrors; use LIT\MCP\Tests\Support\FakeChangeLog; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PHPUnit\Framework\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { + use AssertsToolErrors; use MockeryPHPUnitIntegration; protected function setUp(): void From e420145cbe300fbbcfe13dcb88a26dfeef6aee8e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 18:02:51 +0200 Subject: [PATCH 143/185] fix: anchored input patterns end at the last character (/D) Without /D, "$" also matches before a trailing "\n", so "custom_meta_telefon\n", a color setting "5\n" (stored as sent) or a whole reference followed by "\n" passed VariableRef. Added /D to every ^...$ pattern that validates client or admin input: - VariableRef META_KEY, NUMBER, WHOLE (canonical/typeOf; Placeholders::field shares META_KEY) - Cursor::decode, GuideRepository topic names (MCP input and filter keys) - ExposurePolicy::sanitize ability keys and group ids, LogTab list filters Not changed: Placeholders::PATTERN (runs on trim()), Warnings hex checks (hints only), NodeSummary/AttrPathIndex/LogListTable (stored or Divi data, no client input). Each fixed pattern has a test with a trailing "\n" that is rejected. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Cursor.php | 2 +- src/Abilities/ExposurePolicy.php | 4 +-- src/Admin/LogTab.php | 4 +-- src/Guides/GuideRepository.php | 2 +- src/Integrations/Divi/Compose/VariableRef.php | 6 ++--- tests/Unit/Abilities/CursorTest.php | 6 +++++ tests/Unit/Abilities/ExposurePolicyTest.php | 14 ++++++++++ tests/Unit/Admin/LogTabTest.php | 16 +++++++++++ tests/Unit/Guides/GuideRepositoryTest.php | 9 +++++++ .../Divi/Compose/VariableRefTest.php | 27 +++++++++++++++++++ 10 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/Abilities/Cursor.php b/src/Abilities/Cursor.php index e60c645..c98688b 100644 --- a/src/Abilities/Cursor.php +++ b/src/Abilities/Cursor.php @@ -27,7 +27,7 @@ final class Cursor } $raw = base64_decode(strtr($cursor, '-_', '+/'), true); - if ($raw === false || preg_match('/^o:(\d{1,9})$/', $raw, $matches) !== 1) { + if ($raw === false || preg_match('/^o:(\d{1,9})$/D', $raw, $matches) !== 1) { return null; } diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index 1db96b8..4b975dc 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -261,7 +261,7 @@ final class ExposurePolicy } } elseif ( is_string($value) - && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $key) === 1 + && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $key) === 1 && self::isAllowed($key, $value) ) { $clean[$key] = $value; @@ -275,7 +275,7 @@ final class ExposurePolicy /** @param list|null $groupIds */ private static function isGroupId(string $id, ?array $groupIds): bool { - if (preg_match('#^[a-z0-9-]+$#', $id) !== 1) { + if (preg_match('#^[a-z0-9-]+$#D', $id) !== 1) { return false; } diff --git a/src/Admin/LogTab.php b/src/Admin/LogTab.php index b411b0b..fc83c4d 100644 --- a/src/Admin/LogTab.php +++ b/src/Admin/LogTab.php @@ -54,7 +54,7 @@ final class LogTab implements AdminTab // phpcs:enable WordPress.Security.NonceVerification.Recommended return [ - 'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $ability) === 1 ? $ability : '', + 'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $ability) === 1 ? $ability : '', 'user_id' => $userId, ]; } @@ -83,7 +83,7 @@ final class LogTab implements AdminTab private static function isDate(string $value): bool { - return preg_match('#^\d{4}-\d{2}-\d{2}$#', $value) === 1; + return preg_match('#^\d{4}-\d{2}-\d{2}$#D', $value) === 1; } public function render(): void diff --git a/src/Guides/GuideRepository.php b/src/Guides/GuideRepository.php index 05f848a..64b6561 100644 --- a/src/Guides/GuideRepository.php +++ b/src/Guides/GuideRepository.php @@ -13,7 +13,7 @@ namespace LIT\MCP\Guides; */ final class GuideRepository { - private const TOPIC_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/'; + private const TOPIC_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/D'; /** @var array|null Filtered topics, read once per instance (= request). */ private ?array $filtered = null; diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index a21ebfa..d8d4799 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -47,7 +47,7 @@ final class VariableRef public const MARKER = '$variable('; /** custom_meta_: no leading "_" – Divi renders protected meta publicly (ruling S12). */ - public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/'; + public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D'; /** @var array> Placeholders::POST values and their settings. */ public const DEFAULT_DYNAMIC_CONTENT = [ @@ -79,7 +79,7 @@ final class VariableRef private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity']; - private const WHOLE = '/^\$variable\((.*)\)\$$/s'; + private const WHOLE = '/^\$variable\((.*)\)\$$/sD'; private const META_PREFIX = 'custom_meta_'; @@ -95,7 +95,7 @@ final class VariableRef 'loop_term_meta_key_', ]; - private const NUMBER = '/^-?\d+(\.\d+)?$/'; + private const NUMBER = '/^-?\d+(\.\d+)?$/D'; /** @var array> */ private array $dynamicContent; diff --git a/tests/Unit/Abilities/CursorTest.php b/tests/Unit/Abilities/CursorTest.php index fae2be8..3ee1fdf 100644 --- a/tests/Unit/Abilities/CursorTest.php +++ b/tests/Unit/Abilities/CursorTest.php @@ -27,6 +27,12 @@ final class CursorTest extends TestCase self::assertNull(Cursor::decode(base64_encode('o:12;DROP'))); } + /** "$" without /D also matches before a trailing newline – the cursor must end with the digits. */ + public function testCursorWithTrailingNewlineIsRejected(): void + { + self::assertNull(Cursor::decode(rtrim(strtr(base64_encode("o:5\n"), '+/', '-_'), '='))); + } + public function testLimitIsClamped(): void { self::assertSame(20, Cursor::limit(null)); diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index d68f258..fb15188 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -236,6 +236,20 @@ final class ExposurePolicyTest extends TestCase ); } + /** "$" without /D also matches before a trailing newline – ability names and group ids end with their last character. */ + public function testSanitizeRejectsKeysWithTrailingNewline(): void + { + self::assertSame( + [], + ExposurePolicy::sanitize([ + "lit/menu-list\n" => 'disabled', + "@group:divi\n" => 'indirect', + "@readonly:divi\n" => true, + ]) + ); + self::assertSame([], ExposurePolicy::sanitize(["@group:divi\n" => 'indirect'], ['core', 'divi'])); + } + public function testOldFlatOptionStaysValid(): void { $old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct']; diff --git a/tests/Unit/Admin/LogTabTest.php b/tests/Unit/Admin/LogTabTest.php index 4624e63..540b3c8 100644 --- a/tests/Unit/Admin/LogTabTest.php +++ b/tests/Unit/Admin/LogTabTest.php @@ -63,6 +63,22 @@ final class LogTabTest extends TestCase self::assertSame(['result' => '', 'date_from' => '', 'date_to' => '2026-13-99'], $tab->extraFilters()); } + /** + * "$" without /D also matches before a trailing newline. sanitize_text_field() already drops + * it in WordPress; the whitelist must hold on its own, so the sanitizer passes it through here. + */ + public function testFiltersRejectATrailingNewlineEvenWithoutTheSanitizer(): void + { + Functions\when('sanitize_text_field')->returnArg(); + $tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits()); + $_GET['ability'] = "lit/content-update\n"; + $_GET['date_from'] = "2026-09-01\n"; + $_GET['date_to'] = "2026-09-25\n"; + + self::assertSame('', $tab->filters()['ability']); + self::assertSame(['result' => '', 'date_from' => '', 'date_to' => ''], $tab->extraFilters()); + } + public function testClearNeedsNonceAndRights(): void { Functions\stubTranslationFunctions(); diff --git a/tests/Unit/Guides/GuideRepositoryTest.php b/tests/Unit/Guides/GuideRepositoryTest.php index b1f1d17..9da1dba 100644 --- a/tests/Unit/Guides/GuideRepositoryTest.php +++ b/tests/Unit/Guides/GuideRepositoryTest.php @@ -76,6 +76,15 @@ final class GuideRepositoryTest extends TestCase self::assertNull($this->guides->get('not-string')); } + /** "$" without /D also matches before a trailing newline – such a filter topic is no topic name. */ + public function testATopicWithTrailingNewlineIsRejected(): void + { + Filters\expectApplied('lit_mcp_guides')->andReturn(["site-rules\n" => 'eingeschleust']); + + self::assertSame(['divi-shortform', 'overview'], $this->guides->topics()); + self::assertNull($this->guides->get("site-rules\n")); + } + public function testANonArrayFilterResultIsIgnored(): void { Filters\expectApplied('lit_mcp_guides')->andReturn('kaputt'); diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 452ac83..770d110 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -138,6 +138,33 @@ final class VariableRefTest extends TestCase self::ref()->canonical($input, ComposeFixture::sanitizer()); } + /** + * "$" without /D also matches before a trailing newline: the whole-reference pattern, a meta + * key name and a color setting number must each end with their last character. + */ + public function testATrailingNewlineIsNeverAnAnchorMatch(): void + { + $color = VariableRef::serialize('color', 'gcid-a1b2', []); + $rejects = [ + 'whole reference' => [$color . "\n", 'Verweise nur allein im Wert'], + 'meta key name' => [ + VariableRef::serialize('content', "custom_meta_telefon\n", ['before' => '', 'after' => '']), + 'Unbekannter Name', + ], + 'color number' => [VariableRef::serialize('color', 'gcid-a1b2', ['hue' => "5\n"]), 'nur Zahlen'], + ]; + foreach ($rejects as $label => [$input, $message]) { + try { + self::ref()->canonical($input, ComposeFixture::sanitizer()); + self::fail($label . ': accepted'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString($message, $e->getMessage(), $label); + } + } + + self::assertNull(VariableRef::typeOf($color . "\n")); + } + /** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */ public function testEscapedEndMarkerIsBytesNotTheCharacter(): void { From 874a92c6bed94f4bc55783e29512a502cb4cdc06 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 18:05:38 +0200 Subject: [PATCH 144/185] refactor: split VariableRef into ControlChars and DynamicContentRules (H2) - ControlChars::strip()/stripDeep() (was VariableRef::withoutControls/Deep) with the S32/S32a/S35 docblock; callers AttrWriter, ShortformWriter, TextReplacer and the setting cleaner use it. - DynamicContentRules (design system + dynamic content map): fieldsFor() (was contentFields: meta prefix -> map -> loop_ -> design names -> unknown), cleanSetting() (was setting(): raw strip -> raw marker -> field type -> final-string check -> meta key check, same order), one private method per field family (textSetting, selectPostSetting, scalarSetting), isMetaKey(), checkMap(), and the constants DEFAULT_DYNAMIC_CONTENT, META_SETTINGS, DESIGN_SETTINGS, META_KEY. - VariableRef keeps the syntax: const END = ')$' replaces the six literals, payloadOf() is shared by typeOf() and canonical(); unknownName() is public for the rules. Error messages unchanged. Tests unchanged apart from a class name in DynamicContentMapTest. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/AttrWriter.php | 6 +- .../Divi/Compose/ControlChars.php | 35 ++ .../Divi/Compose/DynamicContentRules.php | 305 ++++++++++++++++ .../Divi/Compose/Placeholders.php | 4 +- .../Divi/Compose/ShortformWriter.php | 4 +- .../Divi/Compose/TextReplacer.php | 6 +- src/Integrations/Divi/Compose/VariableRef.php | 336 +++--------------- .../Divi/Pages/DynamicContentMap.php | 20 +- .../Divi/Pages/DynamicContentMapTest.php | 3 +- 9 files changed, 403 insertions(+), 316 deletions(-) create mode 100644 src/Integrations/Divi/Compose/ControlChars.php create mode 100644 src/Integrations/Divi/Compose/DynamicContentRules.php diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 17829ec..28c6a84 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -70,7 +70,7 @@ final class AttrWriter public function apply(string $module, array $attrs, array $changes, string $field): array { // Ruling S35: controls go before placeholder resolution ("{\0{post:title}}" is a placeholder). - $changes = VariableRef::withoutControlsDeep($changes); + $changes = ControlChars::stripDeep($changes); $errors = []; $invalid = []; foreach ($changes as $path => $map) { @@ -248,7 +248,7 @@ final class AttrWriter // Ruling S32/S32a: control characters go on the RAW value, before the marker check and // before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<" // for strip_tags), so any later strip could build a tag or a marker nobody checked. - $value = VariableRef::withoutControls($value); + $value = ControlChars::strip($value); $routed = $this->kind($module, $path, $subName, $key); if ($kind !== null && ! self::tightens($kind, $routed)) { @@ -276,7 +276,7 @@ final class AttrWriter // Ruling S28: sanitizers strip characters ("", "%41") and can thereby build a marker the // raw check above never saw – so the final string is checked again. After the sanitizer // the only action is rejection (S32a): nothing transforms the cleaned string any more. - if (VariableRef::contains($clean) || VariableRef::withoutControls($clean) !== $clean) { + if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) { throw VariableRef::notAlone(); } diff --git a/src/Integrations/Divi/Compose/ControlChars.php b/src/Integrations/Divi/Compose/ControlChars.php new file mode 100644 index 0000000..e2d4fcb --- /dev/null +++ b/src/Integrations/Divi/Compose/ControlChars.php @@ -0,0 +1,35 @@ + with META_KEY (no leading "_", ruling S12; settings from the map entry + * of that exact name, else META_SETTINGS); or a key of the dynamic content map. + * + * Dynamic content map (ruling S22), constructor argument $map: + * array> option name => [setting key => field type] + * e.g. ['post_title' => ['before' => 'text', 'after' => 'text'], 'post_date' => [… 'date_format' => 'select']] + * Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); "url" → url() + * (refused when it empties a value); "select_post" → a numeric id of a post the user may read; + * "select", "yes_no_button", "number" → a scalar without markup ("<", ">"), strings cleaned with + * text(); any other type is refused (the map comes from a filter – untrusted, ruling S37). + * Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry + * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => + * field "type"). Always, whatever the map says: "enable_html" is rejected (VariableRef), loop_* + * names only when the map names them, custom_meta_ names and the values of settings whose + * key ends in "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's + * own prefix stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28) + * – may contain a reference or ")$". A map of another shape fails in the constructor with + * InvalidArgumentException. + */ +final class DynamicContentRules +{ + /** custom_meta_: no leading "_" – Divi renders protected meta publicly (ruling S12). */ + public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D'; + + /** @var array> Placeholders::POST values and their settings. */ + public const DEFAULT_DYNAMIC_CONTENT = [ + 'post_title' => self::BEFORE_AFTER, + 'post_excerpt' => self::BEFORE_AFTER, + 'post_date' => self::BEFORE_AFTER + self::DATE, + 'post_modified_date' => self::BEFORE_AFTER + self::DATE, + 'post_author' => self::BEFORE_AFTER, + 'post_link_url' => [], + 'post_featured_image' => [], + 'post_categories' => self::BEFORE_AFTER, + 'post_tags' => self::BEFORE_AFTER, + ]; + + /** Settings of custom_meta_ without a map entry of its own. */ + public const META_SETTINGS = self::BEFORE_AFTER; + + /** + * Settings of content references to global variables and theme fonts: none (ruling S27). + * Divi ignores them: theme fonts resolve to var() (DynamicData.php:300), and the gvid + * option "doesn't have any settings" and renders without them + * (DynamicContentGlobalVariableOptions.php:66-69, 155-161). + */ + public const DESIGN_SETTINGS = []; + + private const BEFORE_AFTER = ['before' => 'richtext', 'after' => 'richtext']; + + private const DATE = ['date_format' => 'select', 'custom_date_format' => 'text']; + + private const META_PREFIX = 'custom_meta_'; + + /** + * Prefixes Divi strips from a meta key option with str_replace – every occurrence, not just + * the leading one (DynamicContentOptionPostMetaKey.php:232,249,275,286; + * DynamicContentOptionLoopPostMetaKey.php:366-373). Ruling S30. + */ + private const META_KEY_PREFIXES = [ + self::META_PREFIX, + 'loop_post_meta_key_', + 'loop_user_meta_key_', + 'loop_term_meta_key_', + ]; + + /** @var array> */ + private array $map; + + /** + * @param array|null $map See the class docblock. + * @throws InvalidArgumentException When the map does not have that shape. + */ + public function __construct(private DesignSystem $design, ?array $map = null) + { + $this->map = $map === null ? self::DEFAULT_DYNAMIC_CONTENT : self::checkMap($map); + } + + /** + * @return array Setting key => field type allowed for the content name. + * @throws InvalidArgumentException When the name is not allowed. + */ + public function fieldsFor(string $name): array + { + // Ruling S29: the meta key rule holds for map entries too. + if (str_starts_with($name, self::META_PREFIX)) { + if (! self::isMetaKey($name)) { + throw VariableRef::unknownName($name, 'content'); + } + + return $this->map[$name] ?? self::META_SETTINGS; + } + + if (isset($this->map[$name])) { + return $this->map[$name]; + } + + if (str_starts_with($name, 'loop_')) { + throw new InvalidArgumentException(sprintf( + 'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).', + $name + )); + } + + $design = array_values(DesignSystem::FONT_VARIABLES); + foreach ($this->design->variables() as $variable) { + if ($variable['type'] !== 'colors') { + $design[] = $variable['id']; + } + } + if (in_array($name, $design, true)) { + return self::DESIGN_SETTINGS; + } + + throw VariableRef::unknownName($name, 'content'); + } + + /** + * Cleans one content setting by its field type (rulings S21/S22). The steps, in this order: + * 1. control characters off the RAW value (S32/S32a); + * 2. the raw value must not contain a reference marker or ")$" (S2); + * 3. the field type's check and sanitizer (textSetting / selectPostSetting / scalarSetting); + * 4. the final string must not contain a marker, ")$" or control characters (S28) – only rejection; + * 5. settings named …meta_key must name an unprotected meta key (S29/S30). + * + * @throws InvalidArgumentException + */ + public static function cleanSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed + { + // Ruling S32/S32a: control characters go on the RAW value, before every check and the + // sanitizer ("<" + VT stays a literal "<" for strip_tags; a later strip would build a tag). + if (is_string($value)) { + $value = ControlChars::strip($value); + } + + if (is_string($value) && (VariableRef::contains($value) || str_contains($value, VariableRef::END))) { + throw VariableRef::notAlone(); + } + + $clean = match ($fieldType) { + 'text', 'richtext', 'html', 'url' => self::textSetting($key, $value, $fieldType, $sanitizer), + 'select_post' => self::selectPostSetting($key, $value, $sanitizer), + 'select', 'yes_no_button', 'number' => self::scalarSetting($key, $value, $sanitizer), + // Ruling S37: map types come from a filter (untrusted) – unknown types are refused. + default => throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis: Feldtyp "%s" wird nicht unterstützt.', + $key, + $fieldType + )), + }; + + // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). After the + // sanitizer the only action is rejection (S32a) – controls went on the raw value above. + if ( + is_string($clean) + && ( + VariableRef::contains($clean) + || str_contains($clean, VariableRef::END) + || ControlChars::strip($clean) !== $clean + ) + ) { + throw VariableRef::notAlone(); + } + + // Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …) + // name a meta key – never a protected one. Matched by the key SUFFIX "meta_key" (S29/S30): + // re-check Divi's dynamic content options for new meta key settings on every Divi version bump. + if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) { + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", ' + . 'nicht mit "_" am Anfang).', + $key + )); + } + + return $clean; + } + + /** + * Checks the meta key Divi derives from $key (ruling S30): a leading known prefix is removed the + * way Divi does it – str_replace of every occurrence – and a prefix still present afterwards is + * rejected (defensive). "telefon", "custom_meta_telefon", "loop_post_meta_key_telefon" yes; + * "_edit_lock", "custom_meta__edit_lock", "custom_meta_custom_meta__edit_lock", + * "loop_post_meta_key__edit_lock", "a b" no. + */ + public static function isMetaKey(string $key): bool + { + foreach (self::META_KEY_PREFIXES as $prefix) { + if (str_starts_with($key, $prefix)) { + $key = str_replace($prefix, '', $key); + break; + } + } + + foreach (self::META_KEY_PREFIXES as $prefix) { + if (str_contains($key, $prefix)) { + return false; + } + } + + return preg_match(self::META_KEY, $key) === 1; + } + + /** + * text, richtext/html and url settings: a string, cleaned by the matching sanitizer; a URL + * the sanitizer empties is refused. + * + * @throws InvalidArgumentException + */ + private static function textSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): string + { + if (! is_string($value)) { + throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key)); + } + + $clean = match ($fieldType) { + 'text' => $sanitizer->text($value), + 'url' => $sanitizer->url($value), + default => $sanitizer->html($value), + }; + if ($fieldType === 'url' && $clean === '' && trim($value) !== '') { + throw new InvalidArgumentException( + sprintf('Einstellung "%s" im Divi-Verweis: ungültige URL.', $key) + ); + } + + return $clean; + } + + /** + * Ruling S37: a numeric post id the user may read – no slugs (Divi resolves those itself). + * Returned as sent (int or digit string). + * + * @throws InvalidArgumentException + */ + private static function selectPostSetting(string $key, mixed $value, ValueSanitizer $sanitizer): mixed + { + $digits = is_string($value) && preg_match('/^[1-9][0-9]*$/D', $value) === 1; + $id = is_int($value) ? $value : ($digits ? (int) $value : 0); + if ($id <= 0 || ! $sanitizer->canReadPost($id)) { + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet.', + $key + )); + } + + return $value; + } + + /** + * select, yes_no_button and number settings: a scalar without markup; strings cleaned as text. + * + * @throws InvalidArgumentException + */ + private static function scalarSetting(string $key, mixed $value, ValueSanitizer $sanitizer): mixed + { + if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) { + throw new InvalidArgumentException(sprintf( + 'Einstellung "%s" im Divi-Verweis muss ein einfacher Wert ohne Markup sein.', + $key + )); + } + + return is_string($value) ? $sanitizer->text($value) : $value; + } + + /** + * @param array $map + * @return array> + * @throws InvalidArgumentException + */ + private static function checkMap(array $map): array + { + foreach ($map as $name => $fields) { + if (! is_string($name) || ! is_array($fields)) { + throw new InvalidArgumentException('Dynamic content map: erwartet Name => [Einstellung => Feldtyp].'); + } + foreach ($fields as $key => $type) { + if (! is_string($key) || ! is_string($type)) { + throw new InvalidArgumentException(sprintf( + 'Dynamic content map "%s": erwartet Einstellung => Feldtyp (Text).', + $name + )); + } + } + } + + /** @var array> $map */ + return $map; + } +} diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php index 5bbfa7d..ac35da6 100644 --- a/src/Integrations/Divi/Compose/Placeholders.php +++ b/src/Integrations/Divi/Compose/Placeholders.php @@ -40,7 +40,7 @@ final class Placeholders /** * @param array>|null $dynamicContent Divi's dynamic content - * options for VariableRef (ruling S22); null = VariableRef::DEFAULT_DYNAMIC_CONTENT. + * options for VariableRef (ruling S22); null = DynamicContentRules::DEFAULT_DYNAMIC_CONTENT. */ public function __construct(private DesignSystem $design, ?array $dynamicContent = null) { @@ -209,7 +209,7 @@ final class Placeholders private function field(string $name): string { - if (preg_match(VariableRef::META_KEY, $name) !== 1) { + if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) { throw new InvalidArgumentException(sprintf( 'Ungültiger Feldschlüssel "%s" (erlaubt: Buchstaben, Ziffern, "_" und "-", nicht mit "_" am Anfang).', $name diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index 6caf160..b736dc1 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -49,7 +49,7 @@ final class ShortformWriter public function apply(string $type, array $attrs, array $values, string $field): array { // Ruling S35: controls go before placeholder resolution and trim ("{\0{post:title}}"). - $values = VariableRef::withoutControlsDeep($values); + $values = ControlChars::stripDeep($values); $fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []); $errors = []; foreach ($values as $name => $value) { @@ -200,7 +200,7 @@ final class ShortformWriter { // Controls are stripped at the choke point before its marker check (S32a) – so a // NUL-split marker counts as a marker here too, and a canonical result is refused. - if ($alt === '' || VariableRef::contains(VariableRef::withoutControls($alt))) { + if ($alt === '' || VariableRef::contains(ControlChars::strip($alt))) { return ''; } diff --git a/src/Integrations/Divi/Compose/TextReplacer.php b/src/Integrations/Divi/Compose/TextReplacer.php index 5b978cf..15f258d 100644 --- a/src/Integrations/Divi/Compose/TextReplacer.php +++ b/src/Integrations/Divi/Compose/TextReplacer.php @@ -72,7 +72,7 @@ final class TextReplacer $problems = []; if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) { $problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.'; - } elseif (VariableRef::withoutControls($search) !== $search) { + } elseif (ControlChars::strip($search) !== $search) { $problems['search'] = 'Suchtext darf keine Steuerzeichen enthalten.'; } elseif (VariableRef::contains($search)) { $problems['search'] = 'Suchtext darf keinen $variable(…)$-Verweis enthalten; dynamische Inhalte werden ' @@ -456,7 +456,7 @@ final class TextReplacer foreach (explode("\n", $value) as $line) { $marker = strrpos($line, VariableRef::MARKER); - $end = $marker === false ? false : strrpos($line, ')$'); + $end = $marker === false ? false : strrpos($line, VariableRef::END); if ($marker !== false && ($end === false || $end < $marker)) { return true; } @@ -486,7 +486,7 @@ final class TextReplacer */ private static function stripped(string $value): string { - $value = VariableRef::withoutControls($value); + $value = ControlChars::strip($value); do { $before = $value; $kept = ''; diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index d8d4799..5d7ab28 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -22,93 +22,33 @@ use stdClass; * Allowed names: * - type "color": a global color ID of the design system or a global variable of type "colors"; * settings hue/saturation/lightness/opacity, numbers only. - * - type "content": a non-color global variable or a theme font (no settings, DESIGN_SETTINGS, S27); - * custom_meta_ with META_KEY (no leading "_", ruling S12; settings from the map entry of - * that exact name, else META_SETTINGS); or a key of the dynamic content map. - * - * Dynamic content map (ruling S22), constructor argument $dynamicContent: - * array> option name => [setting key => field type] - * e.g. ['post_title' => ['before' => 'text', 'after' => 'text'], 'post_date' => [… 'date_format' => 'select']] - * Field types: "text" → ValueSanitizer::text(); "richtext" or "html" → html(); "url" → url() - * (refused when it empties a value); "select_post" → a numeric id of a post the user may read; - * "select", "yes_no_button", "number" → a scalar without markup ("<", ">"), strings cleaned with - * text(); any other type is refused (the map comes from a filter – untrusted, ruling S37). - * Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry - * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => - * field "type"). Always, whatever the map says: "enable_html" is rejected, loop_* names only - * when the map names them, custom_meta_ names and the values of settings whose key ends in - * "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's own prefix - * stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28) – may - * contain a reference or ")$". A map of another shape fails in the constructor with - * InvalidArgumentException. + * - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_ + * (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content + * setting. "enable_html" is always rejected here. + * A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException. */ final class VariableRef { public const MARKER = '$variable('; - /** custom_meta_: no leading "_" – Divi renders protected meta publicly (ruling S12). */ - public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D'; - - /** @var array> Placeholders::POST values and their settings. */ - public const DEFAULT_DYNAMIC_CONTENT = [ - 'post_title' => self::BEFORE_AFTER, - 'post_excerpt' => self::BEFORE_AFTER, - 'post_date' => self::BEFORE_AFTER + self::DATE, - 'post_modified_date' => self::BEFORE_AFTER + self::DATE, - 'post_author' => self::BEFORE_AFTER, - 'post_link_url' => [], - 'post_featured_image' => [], - 'post_categories' => self::BEFORE_AFTER, - 'post_tags' => self::BEFORE_AFTER, - ]; - - /** Settings of custom_meta_ without a map entry of its own. */ - public const META_SETTINGS = self::BEFORE_AFTER; - - /** - * Settings of content references to global variables and theme fonts: none (ruling S27). - * Divi ignores them: theme fonts resolve to var() (DynamicData.php:300), and the gvid - * option "doesn't have any settings" and renders without them - * (DynamicContentGlobalVariableOptions.php:66-69, 155-161). - */ - public const DESIGN_SETTINGS = []; - - private const BEFORE_AFTER = ['before' => 'richtext', 'after' => 'richtext']; - - private const DATE = ['date_format' => 'select', 'custom_date_format' => 'text']; + /** End of a reference: Divi's non-greedy match ends the reference at the first ")$". */ + public const END = ')$'; private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity']; private const WHOLE = '/^\$variable\((.*)\)\$$/sD'; - private const META_PREFIX = 'custom_meta_'; - - /** - * Prefixes Divi strips from a meta key option with str_replace – every occurrence, not just - * the leading one (DynamicContentOptionPostMetaKey.php:232,249,275,286; - * DynamicContentOptionLoopPostMetaKey.php:366-373). Ruling S30. - */ - private const META_KEY_PREFIXES = [ - self::META_PREFIX, - 'loop_post_meta_key_', - 'loop_user_meta_key_', - 'loop_term_meta_key_', - ]; - private const NUMBER = '/^-?\d+(\.\d+)?$/D'; - /** @var array> */ - private array $dynamicContent; + private DynamicContentRules $content; /** - * @param array|null $dynamicContent See the class docblock. + * @param array|null $dynamicContent See DynamicContentRules. * @throws InvalidArgumentException When the map does not have that shape. */ public function __construct(private DesignSystem $design, ?array $dynamicContent = null) { - $this->dynamicContent = $dynamicContent === null - ? self::DEFAULT_DYNAMIC_CONTENT - : self::checkMap($dynamicContent); + $this->content = new DynamicContentRules($design, $dynamicContent); } /** Whether $value contains a reference anywhere (such a value may only be a whole reference). */ @@ -124,12 +64,8 @@ final class VariableRef */ public static function typeOf(string $value): ?string { - if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) { - return null; - } - try { - $type = self::decode($match[1])['type'] ?? null; + $type = self::payloadOf($value)['type'] ?? null; } catch (InvalidArgumentException) { return null; } @@ -137,32 +73,6 @@ final class VariableRef return $type === 'color' || $type === 'content' ? $type : null; } - /** - * Removes C0 control characters except tab, LF and CR, and DEL (rulings S32/S32a). Applied - * to RAW values only, before the marker check and the sanitizer: the WordPress sanitizers keep - * them (sanitize_text_field keeps NUL; strip_tags reads "<" + VT/FF as a literal "<"), so a - * strip after sanitizing could build a marker or a tag no check saw. - */ - public static function withoutControls(string $value): string - { - return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); - } - - /** - * withoutControls() on every string inside $value (keys stay: odd keys are rejected). Ruling - * S35: AttrWriter::apply() and ShortformWriter::apply() run it once on their raw input, before - * placeholder resolution and trim – so "{\0{post:title}}" is the placeholder, checked as one, - * and never literal text that slipped past the placeholder check. - */ - public static function withoutControlsDeep(mixed $value): mixed - { - if (is_string($value)) { - return self::withoutControls($value); - } - - return is_array($value) ? array_map([self::class, 'withoutControlsDeep'], $value) : $value; - } - /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { @@ -171,12 +81,12 @@ final class VariableRef } $json = Json::encode(['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]); - if (str_contains($json, ')$')) { + if (str_contains($json, self::END)) { // Divi would end the reference there (non-greedy match) – never write that. throw self::notAlone(); } - return '$variable(' . $json . ')$'; + return self::MARKER . $json . self::END; } /** @@ -188,11 +98,11 @@ final class VariableRef */ public function canonical(string $value, ValueSanitizer $sanitizer, bool $url = false): string { - if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) { + $payload = self::payloadOf($value); + if ($payload === null) { throw self::notAlone(); } - $payload = self::decode($match[1]); self::onlyKeys($payload, ['type', 'value'], 'Divi-Verweis'); $type = $payload['type'] ?? null; if ($type !== 'color' && $type !== 'content') { @@ -230,6 +140,31 @@ final class VariableRef ); } + public static function unknownName(string $name, string $type): InvalidArgumentException + { + return new InvalidArgumentException(sprintf( + 'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, ' + . 'Theme-Schriften, Divi-Inhaltsquellen und custom_meta_ ohne führendes "_").', + $name, + $type + )); + } + + /** + * The decoded payload of $value when it is exactly one whole reference; null otherwise. + * + * @return array|null + * @throws InvalidArgumentException When the payload is no JSON object (decode()). + */ + private static function payloadOf(string $value): ?array + { + if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], self::END)) { + return null; + } + + return self::decode($match[1]); + } + /** @return array */ private static function decode(string $json): array { @@ -301,7 +236,7 @@ final class VariableRef */ private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array { - $fields = $this->contentFields($name); + $fields = $this->content->fieldsFor($name); $clean = []; foreach ($settings as $key => $value) { $key = (string) $key; @@ -311,201 +246,12 @@ final class VariableRef if ($url && ($key === 'before' || $key === 'after') && $value !== '') { throw new InvalidArgumentException(sprintf('"%s" im Divi-Verweis: in URL-Feldern verboten.', $key)); } - $clean[$key] = self::setting($key, $value, $fields[$key], $sanitizer); + $clean[$key] = DynamicContentRules::cleanSetting($key, $value, $fields[$key], $sanitizer); } return $clean; } - /** - * @return array Setting key => field type allowed for the content name. - * @throws InvalidArgumentException When the name is not allowed. - */ - private function contentFields(string $name): array - { - // Ruling S29: the meta key rule holds for map entries too. - if (str_starts_with($name, self::META_PREFIX)) { - if (! self::isMetaKey($name)) { - throw self::unknownName($name, 'content'); - } - - return $this->dynamicContent[$name] ?? self::META_SETTINGS; - } - - if (isset($this->dynamicContent[$name])) { - return $this->dynamicContent[$name]; - } - - if (str_starts_with($name, 'loop_')) { - throw new InvalidArgumentException(sprintf( - 'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).', - $name - )); - } - - $design = array_values(DesignSystem::FONT_VARIABLES); - foreach ($this->design->variables() as $variable) { - if ($variable['type'] !== 'colors') { - $design[] = $variable['id']; - } - } - if (in_array($name, $design, true)) { - return self::DESIGN_SETTINGS; - } - - throw self::unknownName($name, 'content'); - } - - /** Cleans one content setting by its field type (ruling S21/S22). */ - private static function setting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer): mixed - { - // Ruling S32/S32a: control characters go on the RAW value, before every check and the - // sanitizer ("<" + VT stays a literal "<" for strip_tags; a later strip would build a tag). - if (is_string($value)) { - $value = self::withoutControls($value); - } - - if (is_string($value) && (self::contains($value) || str_contains($value, ')$'))) { - throw self::notAlone(); - } - - switch ($fieldType) { - case 'text': - case 'richtext': - case 'html': - case 'url': - if (! is_string($value)) { - throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis: nur Text.', $key)); - } - - $clean = match ($fieldType) { - 'text' => $sanitizer->text($value), - 'url' => $sanitizer->url($value), - default => $sanitizer->html($value), - }; - if ($fieldType === 'url' && $clean === '' && trim($value) !== '') { - throw new InvalidArgumentException( - sprintf('Einstellung "%s" im Divi-Verweis: ungültige URL.', $key) - ); - } - break; - case 'select_post': - // Ruling S37: a numeric post id the user may read – no slugs (Divi resolves those itself). - $digits = is_string($value) && preg_match('/^[1-9][0-9]*$/D', $value) === 1; - $id = is_int($value) ? $value : ($digits ? (int) $value : 0); - if ($id <= 0 || ! $sanitizer->canReadPost($id)) { - throw new InvalidArgumentException(sprintf( - 'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet.', - $key - )); - } - - $clean = $value; - break; - case 'select': - case 'yes_no_button': - case 'number': - if (! is_scalar($value) || (is_string($value) && preg_match('/[<>]/', $value) === 1)) { - throw new InvalidArgumentException(sprintf( - 'Einstellung "%s" im Divi-Verweis muss ein einfacher Wert ohne Markup sein.', - $key - )); - } - - $clean = is_string($value) ? $sanitizer->text($value) : $value; - break; - default: - // Ruling S37: map types come from a filter (untrusted) – unknown types are refused. - throw new InvalidArgumentException(sprintf( - 'Einstellung "%s" im Divi-Verweis: Feldtyp "%s" wird nicht unterstützt.', - $key, - $fieldType - )); - } - - // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). After the - // sanitizer the only action is rejection (S32a) – controls went on the raw value above. - if ( - is_string($clean) - && (self::contains($clean) || str_contains($clean, ')$') || self::withoutControls($clean) !== $clean) - ) { - throw self::notAlone(); - } - - // Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …) - // name a meta key – never a protected one. Matched by the key SUFFIX "meta_key" (S29/S30): - // re-check Divi's dynamic content options for new meta key settings on every Divi version bump. - if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) { - throw new InvalidArgumentException(sprintf( - 'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", ' - . 'nicht mit "_" am Anfang).', - $key - )); - } - - return $clean; - } - - /** - * Checks the meta key Divi derives from $key (ruling S30): a leading known prefix is removed the - * way Divi does it – str_replace of every occurrence – and a prefix still present afterwards is - * rejected (defensive). "telefon", "custom_meta_telefon", "loop_post_meta_key_telefon" yes; - * "_edit_lock", "custom_meta__edit_lock", "custom_meta_custom_meta__edit_lock", - * "loop_post_meta_key__edit_lock", "a b" no. - */ - private static function isMetaKey(string $key): bool - { - foreach (self::META_KEY_PREFIXES as $prefix) { - if (str_starts_with($key, $prefix)) { - $key = str_replace($prefix, '', $key); - break; - } - } - - foreach (self::META_KEY_PREFIXES as $prefix) { - if (str_contains($key, $prefix)) { - return false; - } - } - - return preg_match(self::META_KEY, $key) === 1; - } - - /** - * @param array $map - * @return array> - * @throws InvalidArgumentException - */ - private static function checkMap(array $map): array - { - foreach ($map as $name => $fields) { - if (! is_string($name) || ! is_array($fields)) { - throw new InvalidArgumentException('Dynamic content map: erwartet Name => [Einstellung => Feldtyp].'); - } - foreach ($fields as $key => $type) { - if (! is_string($key) || ! is_string($type)) { - throw new InvalidArgumentException(sprintf( - 'Dynamic content map "%s": erwartet Einstellung => Feldtyp (Text).', - $name - )); - } - } - } - - /** @var array> $map */ - return $map; - } - - private static function unknownName(string $name, string $type): InvalidArgumentException - { - return new InvalidArgumentException(sprintf( - 'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, ' - . 'Theme-Schriften, Divi-Inhaltsquellen und custom_meta_ ohne führendes "_").', - $name, - $type - )); - } - /** @param list $allowed */ private static function unknownSetting(string $key, array $allowed): InvalidArgumentException { diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 369aa72..c824443 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -6,24 +6,24 @@ namespace LIT\MCP\Integrations\Divi\Pages; use Closure; use LIT\MCP\Integrations\Divi\Compose\Placeholders; -use LIT\MCP\Integrations\Divi\Compose\VariableRef; +use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Log; use Throwable; /** - * VariableRef's dynamic content allow map (ruling S22) from Divi's live registry + * DynamicContentRules' dynamic content allow map (ruling S22) from Divi's live registry * DynamicContentOptions::get_options($postId, 'edit') – the options the Visual Builder offers * for this entry (filter divi_module_dynamic_content_options; the list depends on the post: * post_categories/post_tags only for types with those taxonomies). Shape: * option name => [setting key => field type], e.g. post_date => [before => richtext, …, - * date_format => select]. VariableRef routes each setting by that type and keeps its fixed + * date_format => select]. DynamicContentRules routes each setting by that type and keeps its fixed * rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28). * * - loop_* options never enter the map: Divi offers them only inside loop-enabled modules, - * which the tools do not track – VariableRef refuses them ("nur innerhalb von Divi-Schleifen"). - * - The map REPLACES VariableRef's default, so every Placeholders::POST option is added when - * the registry lacks it, with the settings the placeholders write (VariableRef's default). - * - Registry unavailable (no Divi, empty, error): null – VariableRef's default map applies. + * which the tools do not track – DynamicContentRules refuses them ("nur innerhalb von Divi-Schleifen"). + * - The map REPLACES DynamicContentRules' default, so every Placeholders::POST option is added when + * the registry lacks it, with the settings the placeholders write (DynamicContentRules' default). + * - Registry unavailable (no Divi, empty, error): null – DynamicContentRules' default map applies. * * Not final so abilities can be tested with a fake registry. */ @@ -38,7 +38,7 @@ class DynamicContentMap { } - /** @return array>|null Null = use VariableRef's default map. */ + /** @return array>|null Null = use DynamicContentRules' default map. */ public function forPost(int $postId): ?array { try { @@ -74,7 +74,7 @@ class DynamicContentMap } foreach (Placeholders::POST as $option) { - $map[$option] = ($map[$option] ?? []) + (VariableRef::DEFAULT_DYNAMIC_CONTENT[$option] ?? []); + $map[$option] = ($map[$option] ?? []) + (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT[$option] ?? []); } return $map; @@ -85,7 +85,7 @@ class DynamicContentMap * outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's * unfiltered_html), and sanitize_text_field() would trim their meaningful spaces ("Von "). * html() is kses for editors, the S28 marker checks hold either way. No readable type: '' – - * VariableRef's strictest branch (a scalar without markup). + * DynamicContentRules' strictest branch (a scalar without markup). */ private static function type(string $key, mixed $field): string { diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index 90a56e9..a932439 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; @@ -50,7 +51,7 @@ final class DynamicContentMapTest extends TestCase foreach (Placeholders::POST as $option) { self::assertArrayHasKey($option, $map, $option); - foreach (array_keys(VariableRef::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) { + foreach (array_keys(DynamicContentRules::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) { self::assertArrayHasKey($setting, $map[$option], "$option.$setting"); } } From 203d14a2f903807366cc59e9588ba4c88ddf0705 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 18:07:56 +0200 Subject: [PATCH 145/185] refactor: one VariableRef per composition, design lookups in DesignSystem (H3) - DiviComposition::references() builds the one VariableRef with the entry's live map (S22/S31) and passes it to AttrWriter; Placeholders only needs the design system now, Placeholders::references() is gone (no reach-through). - DesignSystem::colorIdFor()/variableFor() replace the dead colorId()/variable() and carry Placeholders' lookup incl. the S13a ambiguity check (byLabel moved, messages unchanged); isColorVariable() decides color vs content for {{var:}}. - DesignSystem::colorReferenceIds() (VariableRef color names) and contentVariableIds() (DynamicContentRules design names) replace the two inline computations; same lists, same order. Tests: fixtures/tests get the VariableRef from ComposeFixture::references() or build it with the map; DesignSystemTest uses the new lookups (+2 tests). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/AttrWriter.php | 3 +- .../Divi/Compose/DynamicContentRules.php | 8 +- .../Divi/Compose/Placeholders.php | 75 ++----------- src/Integrations/Divi/Compose/VariableRef.php | 8 +- src/Integrations/Divi/Design/DesignSystem.php | 102 +++++++++++++++--- .../Divi/Pages/DiviComposition.php | 29 +++-- tests/Support/ComposeFixture.php | 15 ++- .../Divi/Compose/PlaceholdersTest.php | 2 +- .../Divi/Design/DesignSystemTest.php | 50 +++++++-- .../Divi/Pages/DiviCompositionTest.php | 3 + .../Divi/Pages/DiviWriterTest.php | 2 +- .../Divi/Pages/DynamicContentMapTest.php | 5 +- 12 files changed, 184 insertions(+), 118 deletions(-) diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 28c6a84..16a6ec5 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -58,6 +58,7 @@ final class AttrWriter public function __construct( private ModuleCatalog $catalog, private Placeholders $placeholders, + private VariableRef $references, private ValueSanitizer $sanitizer ) { } @@ -263,7 +264,7 @@ final class AttrWriter $kind ??= $routed; if (VariableRef::contains($value)) { // The only way a reference is stored: whole, straight from canonical(). - return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url'); + return $this->references->canonical($value, $this->sanitizer, $kind === 'url'); } $clean = match ($kind) { diff --git a/src/Integrations/Divi/Compose/DynamicContentRules.php b/src/Integrations/Divi/Compose/DynamicContentRules.php index cdaffbc..d19149b 100644 --- a/src/Integrations/Divi/Compose/DynamicContentRules.php +++ b/src/Integrations/Divi/Compose/DynamicContentRules.php @@ -116,13 +116,7 @@ final class DynamicContentRules )); } - $design = array_values(DesignSystem::FONT_VARIABLES); - foreach ($this->design->variables() as $variable) { - if ($variable['type'] !== 'colors') { - $design[] = $variable['id']; - } - } - if (in_array($name, $design, true)) { + if (in_array($name, $this->design->contentVariableIds(), true)) { return self::DESIGN_SETTINGS; } diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php index ac35da6..05a6c6a 100644 --- a/src/Integrations/Divi/Compose/Placeholders.php +++ b/src/Integrations/Divi/Compose/Placeholders.php @@ -12,7 +12,8 @@ use LIT\MCP\Integrations\Divi\Design\DesignSystem; * {{color:primary||gcid-…}}, {{var:|gvid-…}}, {{font:heading|body}}, * {{post:title|…}}, {{field:}}. A placeholder must be the whole value (D9); unknown * names fail with the list of existing ones, labels two entries share fail with their IDs - * (ruling S13a). The result is still checked by VariableRef wherever it is written. Pure PHP. + * (ruling S13a, DesignSystem::colorIdFor/variableFor). The result is still checked by VariableRef + * wherever it is written. Pure PHP. */ final class Placeholders { @@ -36,21 +37,8 @@ final class Placeholders private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/'; - private VariableRef $references; - - /** - * @param array>|null $dynamicContent Divi's dynamic content - * options for VariableRef (ruling S22); null = DynamicContentRules::DEFAULT_DYNAMIC_CONTENT. - */ - public function __construct(private DesignSystem $design, ?array $dynamicContent = null) + public function __construct(private DesignSystem $design) { - $this->references = new VariableRef($design, $dynamicContent); - } - - /** The strict checker of $variable(…)$ values against this design system (ruling S1). */ - public function references(): VariableRef - { - return $this->references; } /** @@ -102,18 +90,11 @@ final class Placeholders private function color(string $name): string { - $colors = $this->design->colors(); - $ids = array_column($colors, 'id'); - $alias = DesignSystem::COLOR_ALIASES[mb_strtolower($name)] ?? null; - $id = match (true) { - in_array($name, $ids, true) => $name, - $alias !== null && in_array($alias, $ids, true) => $alias, - default => self::byLabel($colors, $name, 'globale Farbe'), - }; - + $id = $this->design->colorIdFor($name); if ($id === null) { + $colors = $this->design->colors(); $known = array_merge( - array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)), + array_keys(array_intersect(DesignSystem::COLOR_ALIASES, array_column($colors, 'id'))), array_column($colors, 'label') ); throw new InvalidArgumentException(sprintf( @@ -128,12 +109,9 @@ final class Placeholders private function variable(string $name): string { - $variables = $this->design->variables(); - $id = in_array($name, array_column($variables, 'id'), true) - ? $name - : self::byLabel($variables, $name, 'globale Variable'); + $id = $this->design->variableFor($name); if ($id === null) { - $labels = array_column($variables, 'label'); + $labels = array_column($this->design->variables(), 'label'); throw new InvalidArgumentException(sprintf( 'Unbekannte globale Variable "%s". Vorhanden: %s.', $name, @@ -141,42 +119,7 @@ final class Placeholders )); } - $type = 'content'; - foreach ($variables as $variable) { - if ($variable['id'] === $id && $variable['type'] === 'colors') { - $type = 'color'; - } - } - - return VariableRef::serialize($type, $id, []); - } - - /** - * ID of the one entry labelled $name (case-insensitive); null when none is. - * - * @param list $entries - * @throws InvalidArgumentException When several entries share the label (ruling S13a). - */ - private static function byLabel(array $entries, string $name, string $what): ?string - { - $key = mb_strtolower($name); - $ids = []; - foreach ($entries as $entry) { - if (mb_strtolower($entry['label']) === $key) { - $ids[] = $entry['id']; - } - } - - if (count($ids) > 1) { - throw new InvalidArgumentException(sprintf( - 'Name der %s "%s" ist mehrdeutig; bitte die ID angeben: %s.', - $what, - $name, - implode(', ', $ids) - )); - } - - return $ids[0] ?? null; + return VariableRef::serialize($this->design->isColorVariable($id) ? 'color' : 'content', $id, []); } private function font(string $name): string diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 5d7ab28..ac24806 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -204,13 +204,7 @@ final class VariableRef */ private function colorSettings(string $name, array $settings): array { - $names = array_column($this->design->colors(), 'id'); - foreach ($this->design->variables() as $variable) { - if ($variable['type'] === 'colors') { - $names[] = $variable['id']; - } - } - if (! in_array($name, $names, true)) { + if (! in_array($name, $this->design->colorReferenceIds(), true)) { throw self::unknownName($name, 'color'); } diff --git a/src/Integrations/Divi/Design/DesignSystem.php b/src/Integrations/Divi/Design/DesignSystem.php index a030b59..65cde67 100644 --- a/src/Integrations/Divi/Design/DesignSystem.php +++ b/src/Integrations/Divi/Design/DesignSystem.php @@ -137,31 +137,60 @@ final class DesignSystem return $this->sizes; } - /** gcid of an alias (primary …), an ID or a label (case-insensitive); null when unknown. */ - public function colorId(string $name): ?string + /** + * gcid for a placeholder name: an ID, an alias (primary …) of an existing customizer color, + * else the one color of that label (case-insensitive); null when unknown. + * + * @throws InvalidArgumentException When several colors share the label (ruling S13a). + */ + public function colorIdFor(string $name): ?string { - $key = mb_strtolower(trim($name)); - $alias = self::COLOR_ALIASES[$key] ?? null; - foreach ($this->colors as $color) { - if ($color['id'] === $alias || $color['id'] === trim($name) || mb_strtolower($color['label']) === $key) { - return $color['id']; - } - } + $ids = array_column($this->colors, 'id'); + $alias = self::COLOR_ALIASES[mb_strtolower($name)] ?? null; - return null; + return match (true) { + in_array($name, $ids, true) => $name, + $alias !== null && in_array($alias, $ids, true) => $alias, + default => self::byLabel($this->colors, $name, 'globale Farbe'), + }; } - /** @return array{id: string, type: string, label: string, value: string}|null By label or ID. */ - public function variable(string $name): ?array + /** + * gvid for a placeholder name: an ID, else the one variable of that label (case-insensitive); + * null when unknown. + * + * @throws InvalidArgumentException When several variables share the label (ruling S13a). + */ + public function variableFor(string $name): ?string { - $key = mb_strtolower(trim($name)); + return in_array($name, array_column($this->variables, 'id'), true) + ? $name + : self::byLabel($this->variables, $name, 'globale Variable'); + } + + /** Whether $id is a global variable of type "colors" (a color reference, not content). */ + public function isColorVariable(string $id): bool + { + return in_array($id, $this->colorVariableIds(), true); + } + + /** @return list Names a color reference may carry: global color IDs, then color variable IDs. */ + public function colorReferenceIds(): array + { + return array_merge(array_column($this->colors, 'id'), $this->colorVariableIds()); + } + + /** @return list Design names a content reference may carry: theme fonts, then non-color variable IDs. */ + public function contentVariableIds(): array + { + $ids = array_values(self::FONT_VARIABLES); foreach ($this->variables as $variable) { - if ($variable['id'] === trim($name) || mb_strtolower($variable['label']) === $key) { - return $variable; + if ($variable['type'] !== 'colors') { + $ids[] = $variable['id']; } } - return null; + return $ids; } /** @@ -232,6 +261,47 @@ final class DesignSystem return '{{color:' . ($alias !== false ? $alias : $label) . '}}'; } + /** @return list */ + private function colorVariableIds(): array + { + $ids = []; + foreach ($this->variables as $variable) { + if ($variable['type'] === 'colors') { + $ids[] = $variable['id']; + } + } + + return $ids; + } + + /** + * ID of the one entry labelled $name (case-insensitive); null when none is. + * + * @param list $entries + * @throws InvalidArgumentException When several entries share the label (ruling S13a). + */ + private static function byLabel(array $entries, string $name, string $what): ?string + { + $key = mb_strtolower($name); + $ids = []; + foreach ($entries as $entry) { + if (mb_strtolower($entry['label']) === $key) { + $ids[] = $entry['id']; + } + } + + if (count($ids) > 1) { + throw new InvalidArgumentException(sprintf( + 'Name der %s "%s" ist mehrdeutig; bitte die ID angeben: %s.', + $what, + $name, + implode(', ', $ids) + )); + } + + return $ids[0] ?? null; + } + private static function plain(mixed $value): mixed { if (is_object($value)) { diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index 46cfdd6..e1dc829 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -12,6 +12,7 @@ use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\StructureRules; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Compose\Warnings; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Schema\DiviModules; @@ -19,8 +20,9 @@ use LIT\MCP\Integrations\Divi\Schema\DiviModules; /** * Wires the pure Compose classes with the live catalog, design system, Divi's dynamic content * registry and the WordPress sanitizer/media lookup – once per request and entry. Ruling S31: - * ONE Placeholders instance (carrying the live dynamic content map of the entry, S22) is shared - * by AttrWriter and ShortformWriter, and the same AttrWriter serves ShortformWriter and Composer. + * ONE VariableRef (carrying the live dynamic content map of the entry, S22) and ONE Placeholders + * instance are shared by AttrWriter and ShortformWriter, and the same AttrWriter serves + * ShortformWriter and Composer. * * forPost() binds the composition to the entry being written (DiviWriter::composition() does it * for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be @@ -29,6 +31,7 @@ use LIT\MCP\Integrations\Divi\Schema\DiviModules; class DiviComposition { private ?int $postId = null; + private ?VariableRef $references = null; private ?Placeholders $placeholders = null; private ?AttrWriter $attrWriter = null; private ?Composer $composer = null; @@ -42,11 +45,12 @@ class DiviComposition ) { } - /** A fresh composition for entry $postId (own Placeholders with that entry's live map). */ + /** A fresh composition for entry $postId (own VariableRef with that entry's live map). */ public function forPost(int $postId): static { $bound = clone $this; $bound->postId = $postId; + $bound->references = null; $bound->placeholders = null; $bound->attrWriter = null; $bound->composer = null; @@ -54,18 +58,29 @@ class DiviComposition return $bound; } - /** The one Placeholders instance of this composition (ruling S31). */ - public function placeholders(): Placeholders + /** The one checker of $variable(…)$ values of this composition, with the entry's live map (S22, S31). */ + public function references(): VariableRef { - return $this->placeholders ??= new Placeholders( + return $this->references ??= new VariableRef( $this->design->read(), $this->postId === null ? null : $this->dynamicContent->forPost($this->postId) ); } + /** The one Placeholders instance of this composition (ruling S31). */ + public function placeholders(): Placeholders + { + return $this->placeholders ??= new Placeholders($this->design->read()); + } + public function attrWriter(): AttrWriter { - return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->sanitizer); + return $this->attrWriter ??= new AttrWriter( + $this->modules->catalog(), + $this->placeholders(), + $this->references(), + $this->sanitizer + ); } public function composer(): Composer diff --git a/tests/Support/ComposeFixture.php b/tests/Support/ComposeFixture.php index 6a0439e..f40bdf1 100644 --- a/tests/Support/ComposeFixture.php +++ b/tests/Support/ComposeFixture.php @@ -11,6 +11,7 @@ use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\StructureRules; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystem; /** @@ -131,9 +132,15 @@ final class ComposeFixture return new Placeholders(DiviFixtures::designSystem()); } + /** The strict $variable(…)$ checker for the sample design system and the default map. */ + public static function references(): VariableRef + { + return new VariableRef(DiviFixtures::designSystem()); + } + public static function attrWriter(bool $unfiltered = false): AttrWriter { - return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::sanitizer($unfiltered)); + return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::references(), self::sanitizer($unfiltered)); } /** @@ -151,14 +158,14 @@ final class ComposeFixture } /** - * Builder version 5.2.1. $design replaces the sample design system (one Placeholders instance - * shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31). + * Builder version 5.2.1. $design replaces the sample design system (one Placeholders and one + * VariableRef instance shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31). */ public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer { $design ??= DiviFixtures::designSystem(); $placeholders = new Placeholders($design); - $attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, self::sanitizer($unfiltered)); + $attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, new VariableRef($design), self::sanitizer($unfiltered)); return new Composer( DiviFixtures::catalog(), diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php index c773112..c25688e 100644 --- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -44,7 +44,7 @@ final class PlaceholdersTest extends TestCase self::assertSame($expected, $resolved); if ($expected !== $input) { // What placeholders produce passes the strict checker unchanged (ruling S1/S25). - self::assertSame($resolved, $placeholders->references()->canonical($resolved, ComposeFixture::sanitizer())); + self::assertSame($resolved, ComposeFixture::references()->canonical($resolved, ComposeFixture::sanitizer())); } } diff --git a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php index 906d27d..e59e21f 100644 --- a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php +++ b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; +use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Design\DesignSystem; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -34,16 +35,53 @@ final class DesignSystemTest extends TestCase self::assertSame(['p-1' => 'Standard', 'p-2' => 'CTA primär', 'g-1' => 'Fließtext groß'], $design->presetNames()); } + /** The name lists VariableRef/DynamicContentRules check references against (H3: one place). */ + public function testReferenceNameLists(): void + { + $design = new DesignSystem( + [['id' => 'gcid-a', 'label' => 'A', 'value' => '#000']], + [ + ['id' => 'gvid-c', 'type' => 'colors', 'label' => 'C', 'value' => '#fff'], + ['id' => 'gvid-t', 'type' => 'strings', 'label' => 'T', 'value' => 'x'], + ], + [], + ['heading' => '', 'body' => ''], + [] + ); + + self::assertSame(['gcid-a', 'gvid-c'], $design->colorReferenceIds()); + self::assertSame(['--et_global_heading_font', '--et_global_body_font', 'gvid-t'], $design->contentVariableIds()); + self::assertTrue($design->isColorVariable('gvid-c')); + self::assertFalse($design->isColorVariable('gvid-t')); + self::assertFalse($design->isColorVariable('gcid-a')); + } + + public function testLabelLookupsRefuseAmbiguousLabels(): void + { + $design = new DesignSystem( + [['id' => 'gcid-a', 'label' => 'Blau', 'value' => '#00f'], ['id' => 'gcid-b', 'label' => 'blau', 'value' => '#00e']], + [], + [], + ['heading' => '', 'body' => ''], + [] + ); + + self::assertSame('gcid-b', $design->colorIdFor('gcid-b')); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Name der globale Farbe "Blau" ist mehrdeutig; bitte die ID angeben: gcid-a, gcid-b.'); + $design->colorIdFor('Blau'); + } + public function testLooksUpByAliasLabelOrId(): void { $design = DiviFixtures::designSystem(); - self::assertSame('gcid-primary-color', $design->colorId('primary')); - self::assertSame('gcid-a1b2', $design->colorId('nachtblau')); - self::assertSame('gcid-a1b2', $design->colorId('gcid-a1b2')); - self::assertNull($design->colorId('secondary')); - self::assertNull($design->colorId('Alt')); - self::assertSame('gvid-s1', $design->variable('telefon')['id'] ?? null); + self::assertSame('gcid-primary-color', $design->colorIdFor('primary')); + self::assertSame('gcid-a1b2', $design->colorIdFor('nachtblau')); + self::assertSame('gcid-a1b2', $design->colorIdFor('gcid-a1b2')); + self::assertNull($design->colorIdFor('secondary')); + self::assertNull($design->colorIdFor('Alt')); + self::assertSame('gvid-s1', $design->variableFor('telefon')); self::assertSame('p-2', $design->modulePresetId('divi/button', 'cta PRIMÄR')); self::assertNull($design->modulePresetId('divi/text', 'Standard')); self::assertSame(['Standard', 'CTA primär'], $design->modulePresetNames('divi/button')); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index 3e18367..a565513 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -71,6 +71,7 @@ final class DiviCompositionTest extends TestCase self::assertSame([7], $this->registryCalls, 'the registry is read once per post'); self::assertSame($composition->attrWriter(), $composition->attrWriter()); self::assertSame($composition->placeholders(), $composition->placeholders()); + self::assertSame($composition->references(), $composition->references()); } public function testWithoutAPostTheDefaultMapApplies(): void @@ -97,6 +98,8 @@ final class DiviCompositionTest extends TestCase self::assertNotSame($base, $seven); self::assertNotSame($seven->placeholders(), $eight->placeholders()); self::assertNotSame($base->placeholders(), $seven->placeholders()); + self::assertNotSame($seven->references(), $eight->references()); + self::assertNotSame($base->references(), $seven->references()); self::assertSame([7, 8], $this->registryCalls); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php index 89ca407..ce0f3e7 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -91,7 +91,7 @@ final class DiviWriterTest extends TestCase $writer = $this->writer(Mockery::mock(DiviSaver::class), FakeChangeLog::forbidden()); $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); - $writer->composition($page)->placeholders(); + $writer->composition($page)->references(); self::assertSame([7], $this->registryCalls, 'the live dynamic content map of post 7 (ruling S22)'); } diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index a932439..3136006 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -61,12 +61,13 @@ final class DynamicContentMapTest extends TestCase self::assertSame('select', $map['post_date']['date_format']); self::assertSame('text', $map['post_excerpt']['words']); - $placeholders = new Placeholders(DiviFixtures::designSystem(), $map); + $placeholders = new Placeholders(DiviFixtures::designSystem()); + $references = new VariableRef(DiviFixtures::designSystem(), $map); foreach (array_keys(Placeholders::POST) as $key) { $reference = $placeholders->resolve('{{post:' . $key . '}}'); self::assertSame( $reference, - $placeholders->references()->canonical($reference, ComposeFixture::sanitizer()), + $references->canonical($reference, ComposeFixture::sanitizer()), $key ); } From 62ce99e5589ebbe1ee33eae7bfe6815874e6dadb Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 18:11:28 +0200 Subject: [PATCH 146/185] refactor: ValueCleaner is the named choke point (H1) The choke point moves out of AttrWriter into ValueCleaner (catalog, the composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue), kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged. - ValueCleaner's docblock is the one canonical description: steps 0-8 with their rulings in code order, invariants I1-I3; the step comments in cleanNode carry the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks point there and keep only their own rules. - ValueCleaner::textReplacementFor($module) holds the D10 rule that was a closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind). - AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition builds one per composition (cleaner()). Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner()); mixed tests call the cleaner in place; +1 test for textReplacementFor. Every provider row and per-test assertion count unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/AttrWriter.php | 219 +------------- src/Integrations/Divi/Compose/Composer.php | 7 +- .../Divi/Compose/ShortformWriter.php | 25 +- .../Divi/Compose/TextReplacer.php | 4 +- .../Divi/Compose/ValueCleaner.php | 276 ++++++++++++++++++ .../Divi/Pages/DiviComposition.php | 25 +- .../Divi/Pages/DiviContentComposer.php | 4 +- .../Divi/Pages/DiviReplaceTextAbility.php | 16 +- .../Divi/Pages/WpValueSanitizer.php | 2 +- tests/Support/ComposeFixture.php | 22 +- .../Divi/Compose/AttrWriterTest.php | 198 +------------ .../Divi/Compose/ComposerTest.php | 1 + .../Divi/Compose/ValueCleanerTest.php | 200 +++++++++++++ 13 files changed, 552 insertions(+), 447 deletions(-) create mode 100644 src/Integrations/Divi/Compose/ValueCleaner.php create mode 100644 tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 16a6ec5..5125a8d 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -15,33 +15,14 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; * ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop. * Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP. * - * cleanValue() is the single choke point through which every client string reaches Divi - * post_content, which is saved without kses (D7): strings are routed by the catalog's value - * kind (ruling S3/S3a), $variable(…)$ values are re-serialized by VariableRef (S1/S2), keys - * inside values are plain identifiers (S13), and Divi's own HTML/attribute fields need - * unfiltered_html (S7). + * Every value goes through ValueCleaner (the single choke point under the kses bypass, D7 – + * see its pipeline); apply() adds step 0 (S35 control characters before placeholders), the + * guard for removals (S7/S26) and its own rule: structure attributes are refused (S41). */ final class AttrWriter { private const STATES = ['value', 'hover', 'sticky']; - /** Keys whose string values are URLs when the catalog names no kind (ruling S3). */ - private const URL_KEY = '/(url|src|link|href|image)$/i'; - - /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */ - private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D'; - - /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ - private const URL_SEGMENT = '/url$/i'; - - /** - * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by - * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, - * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). - */ - /** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */ - private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2]; - /** * Structure attributes (ruling S41), refused for everyone and whatever the catalog says: * the section type (regular/fullwidth/specialty) and a column's specialty columns decide @@ -53,13 +34,10 @@ final class AttrWriter 'divi/column' => ['module.advanced.specialtyColumns'], ]; - private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; - public function __construct( private ModuleCatalog $catalog, private Placeholders $placeholders, - private VariableRef $references, - private ValueSanitizer $sanitizer + private ValueCleaner $cleaner ) { } @@ -94,7 +72,7 @@ final class AttrWriter } try { - $this->guardPath($path); + $this->cleaner->guardPath($path); if ($map === null) { $attrs = AttrMap::remove($attrs, $path); continue; @@ -113,40 +91,6 @@ final class AttrWriter return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid]; } - /** - * Cleans one value written to $path (used by the short form too). $subKey names the - * sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its - * string alone. $kind ('html'|'code'|'url'|'text') replaces the catalog routing for every - * string of $value: the short form passes its curated field kind (ShortformMap), so - * icon-list-item text is always plain text. It may only tighten (ruling S33a): code < html - * < text; url only where the routing says url – a weaker or other-family kind is refused. - * - * @param 'html'|'code'|'url'|'text'|null $kind - * @throws InvalidArgumentException - */ - public function cleanValue( - string $module, - string $path, - mixed $value, - ?string $subKey = null, - ?string $kind = null - ): mixed { - $this->guardPath($path); - - return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind); - } - - /** - * The kind cleanValue() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text - * uses it to leave URL and code fields alone (D10). - * - * @return 'html'|'code'|'url'|'text' - */ - public function kindOf(string $module, string $path, ?string $subKey = null): string - { - return $this->kind($module, $path, $subKey, $subKey); - } - /** * @return array> Breakpoint => state => value. * @throws InvalidArgumentException @@ -189,157 +133,6 @@ final class AttrWriter return false; } - /** - * Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also - * for removals, which the short form does without cleanValue(). - * - * @throws InvalidArgumentException - */ - public function guardPath(string $path): void - { - if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { - throw new InvalidArgumentException(sprintf( - '"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar ' - . '(oder mit dem Recht unfiltered_html).', - $path - )); - } - } - - /** - * @param string|null $subName First key below the state value: selects the sub-kind of a composite path. - * @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind. - * @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form). - * @throws InvalidArgumentException - */ - private function cleanNode( - string $module, - string $path, - mixed $value, - ?string $subName, - ?string $key, - ?string $kind = null - ): mixed { - if (is_array($value)) { - $clean = []; - foreach ($value as $childKey => $item) { - if (is_string($childKey) && preg_match(self::VALUE_KEY, $childKey) !== 1) { - throw new InvalidArgumentException(sprintf( - 'Ungültiger Schlüssel "%s" im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-").', - $childKey - )); - } - - $clean[$childKey] = is_string($childKey) - ? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey, $kind) - : $this->cleanNode($module, $path, $item, $subName, $key, $kind); - } - - return $clean; - } - - if (! is_string($value)) { - return $value; - } - - if (! mb_check_encoding($value, 'UTF-8')) { - throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.'); - } - - // Ruling S32/S32a: control characters go on the RAW value, before the marker check and - // before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<" - // for strip_tags), so any later strip could build a tag or a marker nobody checked. - $value = ControlChars::strip($value); - - $routed = $this->kind($module, $path, $subName, $key); - if ($kind !== null && ! self::tightens($kind, $routed)) { - throw new InvalidArgumentException(sprintf( - 'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").', - $kind, - $path, - $routed - )); - } - - $kind ??= $routed; - if (VariableRef::contains($value)) { - // The only way a reference is stored: whole, straight from canonical(). - return $this->references->canonical($value, $this->sanitizer, $kind === 'url'); - } - - $clean = match ($kind) { - 'html' => $this->sanitizer->html($value), - 'code' => $this->sanitizer->code($value), - 'url' => $this->sanitizer->url($value), - default => $this->sanitizer->text($value), - }; - - // Ruling S28: sanitizers strip characters ("", "%41") and can thereby build a marker the - // raw check above never saw – so the final string is checked again. After the sanitizer - // the only action is rejection (S32a): nothing transforms the cleaned string any more. - if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) { - throw VariableRef::notAlone(); - } - - return $clean; - } - - /** - * Whether the kind override $kind is at least as strict as the routed kind (ruling S33a). - * Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text - * (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is - * tightened by the others, so only url → url passes. - */ - private static function tightens(string $kind, string $routed): bool - { - if ($kind === 'url' || $routed === 'url') { - return $kind === $routed; - } - - return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed]; - } - - /** - * Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the - * sub-field for a composite path – decides. A string reached through a key below a path - * with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys - * at any depth), then to the last path segment (…Url), then content.innerContent → rich - * text (S23: fields Divi defines only in JS), else text – never laxer than text. - * - * @return 'html'|'code'|'url'|'text' - */ - private function kind(string $module, string $path, ?string $subName, ?string $key): string - { - // "unknown" also for a composite path whose sub-kinds do not name $subName. - $kind = $this->catalog->pathKind($module, $path, $subName); - if ($key !== null && is_string($this->catalog->kinds($module)[$path] ?? null)) { - $kind = 'unknown'; - } - - return match ($kind) { - 'richtext' => 'html', - 'code' => 'code', - 'url', 'image' => 'url', - 'unknown' => $this->unknownKind($path, $key), - default => 'text', - }; - } - - /** @return 'html'|'url'|'text' */ - private function unknownKind(string $path, ?string $key): string - { - if ($key !== null) { - return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text'; - } - - $segments = explode('.', $path); - if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) { - return 'url'; - } - - return $path === 'content.innerContent' ? 'html' : 'text'; - } - /** * @param array> $normalized * @return array> @@ -349,7 +142,7 @@ final class AttrWriter { foreach ($normalized as $breakpoint => $states) { foreach ($states as $state => $value) { - $normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value); + $normalized[$breakpoint][$state] = $this->cleaner->clean($module, $path, $value); } } diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index 4151a92..37ca2bf 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -19,7 +19,7 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap; * Pure PHP. * * Divi saves bypass kses (D7): every client string reaches the attributes only through - * ShortformWriter / AttrWriter::cleanValue() (the single choke point) – short-form fields, + * ShortformWriter / AttrWriter / ValueCleaner::clean() (the single choke point) – short-form fields, * full-form attrs, admin labels, background_color (ShortformWriter::color(), ruling S13) and * preset ids. A value that fails there is an error, never stored raw (S13). What Composer * writes itself is not client text: the builder version (checked in the constructor), block @@ -69,6 +69,7 @@ final class Composer private StructureRules $rules, private ShortformWriter $shortform, private AttrWriter $attrWriter, + private ValueCleaner $cleaner, private DesignSystem $design, private string $builderVersion ) { @@ -502,7 +503,7 @@ final class Composer } try { - $clean = $this->attrWriter->cleanValue($name, self::LABEL_PATH, $label, null, 'text'); + $clean = $this->cleaner->clean($name, self::LABEL_PATH, $label, null, 'text'); } catch (InvalidArgumentException $e) { $this->errors[$path] = $e->getMessage(); @@ -552,7 +553,7 @@ final class Composer // Design-system data, but still through the choke point: an id text() would change is refused. try { - $clean = $this->attrWriter->cleanValue($name, 'modulePreset', $id, null, 'text'); + $clean = $this->cleaner->clean($name, 'modulePreset', $id, null, 'text'); } catch (InvalidArgumentException $e) { $clean = null; } diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index b736dc1..1b4aa78 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -13,10 +13,11 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap; * kind, resolves placeholders (not for code) and sets it at ".desktop.value[.]"; * null removes the field. Pure PHP. * - * Every string goes through AttrWriter::cleanValue() – the single choke point under the kses - * bypass (D7) – with the curated kind of the field: $variable(…)$ values only whole and - * re-serialized by VariableRef (S1/S2/S25), the final string checked again (S28), URL fields - * through the URL sanitizer (S3), builder-only paths guarded (S7/S26). Nothing is passed through. + * Every string goes through ValueCleaner::clean() – the single choke point under the kses + * bypass (D7), see its pipeline – with the curated kind of the field; apply() adds step 0 (S35) + * and set() the guard for removals (S7/S26). Own rules on top: colors are placeholders, color + * references or plain CSS colors, checked before and after cleaning (S13); an attachment's alt + * text carrying a reference is dropped (S33b). Nothing is passed through. */ final class ShortformWriter { @@ -25,7 +26,7 @@ final class ShortformWriter private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; - /** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */ + /** Curated kind => sanitizer kind of ValueCleaner::clean(). */ private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text']; /** @@ -35,7 +36,7 @@ final class ShortformWriter public function __construct( private Placeholders $placeholders, private MediaResolver $media, - private AttrWriter $attrs, + private ValueCleaner $cleaner, private ?array $modules = null ) { } @@ -106,7 +107,7 @@ final class ShortformWriter $value = trim($this->placeholders->resolve($value)); if (VariableRef::contains($value)) { - $reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); + $reference = $this->cleaner->clean($module, $path, $value, $subKey, 'text'); if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -118,7 +119,7 @@ final class ShortformWriter throw new InvalidArgumentException(self::COLOR_ERROR); } - $clean = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); + $clean = $this->cleaner->clean($module, $path, $value, $subKey, 'text'); if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -138,7 +139,7 @@ final class ShortformWriter $path = (string) $spec['path']; $sub = $spec['sub'] === null ? null : (string) $spec['sub']; $base = $path . '.desktop.value'; - $this->attrs->guardPath($path); + $this->cleaner->guardPath($path); if ($value === null) { return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub); } @@ -174,7 +175,7 @@ final class ShortformWriter [$urlKey, $idKey, $altKey] = $keys; $base = $path . '.desktop.value.'; - $url = $this->attrs->cleanValue($module, $path, $image['url'], $urlKey, 'url'); + $url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, 'url'); if ($url === '') { throw new InvalidArgumentException($notFound); } @@ -205,7 +206,7 @@ final class ShortformWriter } try { - $clean = $this->attrs->cleanValue($module, $path, $alt, $altKey, 'text'); + $clean = $this->cleaner->clean($module, $path, $alt, $altKey, 'text'); } catch (InvalidArgumentException) { return ''; } @@ -262,7 +263,7 @@ final class ShortformWriter $value = $this->placeholders->resolve($value); } - $clean = $this->attrs->cleanValue($module, $path, $value, $sub, $kind); + $clean = $this->cleaner->clean($module, $path, $value, $sub, $kind); if ($kind === 'url' && $clean === '' && trim($value) !== '') { throw new InvalidArgumentException('ungültige URL'); } diff --git a/src/Integrations/Divi/Compose/TextReplacer.php b/src/Integrations/Divi/Compose/TextReplacer.php index 15f258d..d3f191e 100644 --- a/src/Integrations/Divi/Compose/TextReplacer.php +++ b/src/Integrations/Divi/Compose/TextReplacer.php @@ -18,8 +18,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\Serializer; * Rulings S8/S45: each string is tokenized (quote-aware tags, comments, entities, references, * raw-text bodies such as ')); - self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', '')); - self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px')); + self::assertSame('', $cleaner->clean('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl')); + self::assertSame('

    x

    ', $cleaner->clean('divi/text', 'content.innerContent', '

    x

    ')); + self::assertSame('', $cleaner->clean('divi/code', 'content.innerContent', '')); + self::assertSame('10px', $cleaner->clean('divi/text', 'module.decoration.spacing', '10px')); self::assertSame( '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', - $writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$') + $cleaner->clean('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$') ); self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x'])); } - /** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */ - public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void - { - $writer = ComposeFixture::attrWriter(); - - // Unknown kind, URL keys deep inside the value. - self::assertSame( - ['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]], - $writer->cleanValue('divi/text', 'module.decoration.background', [ - 'color' => '#fff', - 'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']], - ]) - ); - self::assertSame( - ['url' => '', 'target' => 'on'], - $writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => 'on']) - ); - self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [ - 'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()', - ])); - // Rich text keeps markup, text kind strips it, unknown innerContent is text too. - self::assertSame('

    Hallo Welt

    ', $writer->cleanValue('divi/text', 'content.innerContent', '

    Hallo Welt

    ')); - self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '

    Titel

    ')); - self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', 'Los', 'text')); - // Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url). - self::assertSame( - ['text' => 'Mehr', 'linkUrl' => ''], - $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [ - 'text' => 'Mehr', - 'linkUrl' => 'javascript:alert(1)', - ]) - ); - self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl')); - // An explicit sub-kind wins over the key name; the image module's alt is text, src a URL. - self::assertSame( - ['useIcon' => 'on', 'src' => ''], - $writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x']) - ); - self::assertSame( - ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'], - $writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team']) - ); - // Numbers and booleans are no strings: unchanged. - self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true])); - } - /** * Ruling S6: each malicious reference fails and nothing is stored (as an editor without * unfiltered_html). @@ -249,14 +203,6 @@ final class AttrWriterTest extends TestCase ); } - public function testEmbeddedReferenceNamesTheRule(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Verweise nur allein im Wert'); - - ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b'); - } - /** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */ public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void { @@ -281,75 +227,18 @@ final class AttrWriterTest extends TestCase // Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2). foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) { try { - ComposeFixture::attrWriter()->cleanValue('divi/canvas-portal', $path, 'x'); + ComposeFixture::cleaner()->clean('divi/canvas-portal', $path, 'x'); self::fail('Accepted for an editor: ' . $path); } catch (InvalidArgumentException $e) { self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); } } - self::assertSame('x', ComposeFixture::attrWriter(true)->cleanValue('divi/canvas-portal', 'css.advanced.customCss', 'x')); - self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.htmlish', 'x')); + self::assertSame('x', ComposeFixture::cleaner(true)->clean('divi/canvas-portal', 'css.advanced.customCss', 'x')); + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.htmlish', 'x')); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('nur im Divi-Builder'); - ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]); - } - - /** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */ - public function testCustomCssPathsNeedUnfilteredHtml(): void - { - $css = ['mainElement' => 'color:red;} body{display:none']; - foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) { - try { - ComposeFixture::attrWriter()->cleanValue('divi/text', $path, $css); - self::fail('Accepted for an editor: ' . $path); - } catch (InvalidArgumentException $e) { - self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); - } - - self::assertSame($css, ComposeFixture::attrWriter(true)->cleanValue('divi/text', $path, $css)); - } - - // Only a whole "css" segment counts. - self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.cssClass', 'x')); - self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.cssish', 'x')); - } - - /** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */ - public function testUnknownContentInnerContentIsRichText(): void - { - self::assertSame('unknown', DiviFixtures::catalog()->pathKind('divi/blurb', 'content.innerContent')); - self::assertSame('

    Hallo

    ', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '

    Hallo

    ')); - // Other unknown innerContent stays text. - self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', 'Los')); - } - - /** kindOf() names the routing cleanValue() applies (lit/divi-replace-text skips url/code). */ - public function testKindOfNamesTheRouting(): void - { - $writer = ComposeFixture::attrWriter(); - - self::assertSame('html', $writer->kindOf('divi/text', 'content.innerContent')); - self::assertSame('text', $writer->kindOf('divi/heading', 'title.innerContent')); - self::assertSame('code', $writer->kindOf('divi/code', 'content.innerContent')); - self::assertSame('text', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text')); - self::assertSame('url', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl')); - self::assertSame('text', $writer->kindOf('divi/image', 'image.innerContent', 'alt')); - self::assertSame('url', $writer->kindOf('divi/image', 'image.innerContent', 'src')); - } - - /** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */ - public function testKeysBelowScalarKindsAndUrlPathSegments(): void - { - $writer = ComposeFixture::attrWriter(); - - self::assertSame( - ['url' => '', 'text' => 'a'], - $writer->cleanValue('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => 'a']) - ); - self::assertSame('', $writer->cleanValue('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)')); - self::assertSame('', $writer->cleanValue('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)')); - self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', 'Titel')); + ComposeFixture::cleaner()->clean('divi/button', 'module.decoration.attributes', ['attributes' => []]); } /** Ruling S13: keys inside value maps are plain identifiers. */ @@ -387,41 +276,6 @@ final class AttrWriterTest extends TestCase ); } - /** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */ - public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void - { - $writer = ComposeFixture::attrWriter(); - - self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f")); - self::assertSame("

    a\nb\r

    ", $writer->cleanValue('divi/text', 'content.innerContent', "

    a\x0b\nb\r\x08

    ")); - self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f")); - - // S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes - // through canonical() like any reference – validated, cleaned (S21) and re-serialized. - $tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; - $canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; - $targets = [ - 'text' => ['divi/heading', 'title.innerContent'], - 'html' => ['divi/text', 'content.innerContent'], - 'code' => ['divi/code', 'content.innerContent'], - ]; - foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) { - $value = '$variable' . $control . $tail; - // The fake keeps them like sanitize_text_field does: the production code handles it. - self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x')); - foreach ($targets as $kind => [$module, $path]) { - self::assertSame($canonical, $writer->cleanValue($module, $path, $value), $kind . ' ' . ord($control)); - } - // Embedded in text it is an embedded reference: refused. - try { - $writer->cleanValue('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail); - self::fail('embedded control-split reference accepted'); - } catch (InvalidArgumentException $e) { - self::assertStringContainsString('allein im Wert', $e->getMessage()); - } - } - } - /** * Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field; * stripping the control character only after the sanitizer would build a real tag. Controls @@ -430,6 +284,7 @@ final class AttrWriterTest extends TestCase public function testControlCharactersGoBeforeTheSanitizer(): void { $writer = ComposeFixture::attrWriter(); + $cleaner = ComposeFixture::cleaner(); $img = '<' . chr(11) . 'img src=x onerror=alert(1)>'; $script = '<' . chr(12) . 'script>alert(1)'; // The fake reproduces the WordPress behaviour the attack relies on. @@ -441,7 +296,7 @@ final class AttrWriterTest extends TestCase $stored = $result['attrs']['title']['innerContent']['desktop']['value']; self::assertStringNotContainsString('<', $stored); - $button = $writer->cleanValue('divi/button', 'button.innerContent', $value, 'text'); + $button = $cleaner->clean('divi/button', 'button.innerContent', $value, 'text'); self::assertStringNotContainsString('<', $button); } } @@ -456,35 +311,6 @@ final class AttrWriterTest extends TestCase self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']); } - /** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */ - public function testKindOverrideMayOnlyTighten(): void - { - $writer = ComposeFixture::attrWriter(); - - self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '

    x

    ', null, 'text')); - self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', 'x', null, 'text')); - self::assertSame('

    x

    ', $writer->cleanValue('divi/code', 'content.innerContent', '

    x

    ', null, 'html')); - self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', 'Los', null, 'text')); - self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url')); - - $weaker = [ - 'html over text' => ['divi/heading', 'title.innerContent', null, 'html'], - 'code over text' => ['divi/heading', 'title.innerContent', null, 'code'], - 'code over html' => ['divi/text', 'content.innerContent', null, 'code'], - 'url over text' => ['divi/heading', 'title.innerContent', null, 'url'], - 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'], - 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'], - ]; - foreach ($weaker as $name => [$module, $path, $sub, $kind]) { - try { - $writer->cleanValue($module, $path, 'x', $sub, $kind); - self::fail($name . ' was accepted'); - } catch (InvalidArgumentException $e) { - self::assertStringContainsString('Feldart', $e->getMessage(), $name); - } - } - } - /** * Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a * control-split placeholder is the placeholder (resolved and checked), never literal text diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php index 8256e0e..155b441 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -446,6 +446,7 @@ final class ComposerTest extends TestCase ComposeFixture::rules(), ComposeFixture::shortform(), ComposeFixture::attrWriter(), + ComposeFixture::cleaner(), DiviFixtures::designSystem(), '5.2.1"},"x":{"' ); diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php new file mode 100644 index 0000000..71cb9ae --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -0,0 +1,200 @@ + '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]], + $cleaner->clean('divi/text', 'module.decoration.background', [ + 'color' => '#fff', + 'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']], + ]) + ); + self::assertSame( + ['url' => '', 'target' => 'on'], + $cleaner->clean('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => 'on']) + ); + self::assertSame(['href' => '', 'src' => '', 'image' => ''], $cleaner->clean('divi/text', 'module.advanced.link', [ + 'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()', + ])); + // Rich text keeps markup, text kind strips it, unknown innerContent is text too. + self::assertSame('

    Hallo Welt

    ', $cleaner->clean('divi/text', 'content.innerContent', '

    Hallo Welt

    ')); + self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '

    Titel

    ')); + self::assertSame('Los', $cleaner->clean('divi/button', 'button.innerContent', 'Los', 'text')); + // Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url). + self::assertSame( + ['text' => 'Mehr', 'linkUrl' => ''], + $cleaner->clean('divi/fullwidth-header', 'buttonOne.innerContent', [ + 'text' => 'Mehr', + 'linkUrl' => 'javascript:alert(1)', + ]) + ); + self::assertSame('', $cleaner->clean('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl')); + // An explicit sub-kind wins over the key name; the image module's alt is text, src a URL. + self::assertSame( + ['useIcon' => 'on', 'src' => ''], + $cleaner->clean('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x']) + ); + self::assertSame( + ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'], + $cleaner->clean('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team']) + ); + // Numbers and booleans are no strings: unchanged. + self::assertSame(['size' => 12, 'on' => true], $cleaner->clean('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true])); + } + + public function testEmbeddedReferenceNamesTheRule(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Verweise nur allein im Wert'); + + ComposeFixture::cleaner()->clean('divi/text', 'content.innerContent', 'a $variable({}) b'); + } + + /** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */ + public function testCustomCssPathsNeedUnfilteredHtml(): void + { + $css = ['mainElement' => 'color:red;} body{display:none']; + foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) { + try { + ComposeFixture::cleaner()->clean('divi/text', $path, $css); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + + self::assertSame($css, ComposeFixture::cleaner(true)->clean('divi/text', $path, $css)); + } + + // Only a whole "css" segment counts. + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.cssClass', 'x')); + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.cssish', 'x')); + } + + /** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */ + public function testUnknownContentInnerContentIsRichText(): void + { + self::assertSame('unknown', DiviFixtures::catalog()->pathKind('divi/blurb', 'content.innerContent')); + self::assertSame('

    Hallo

    ', ComposeFixture::cleaner()->clean('divi/blurb', 'content.innerContent', '

    Hallo

    ')); + // Other unknown innerContent stays text. + self::assertSame('Los', ComposeFixture::cleaner()->clean('divi/button', 'button.innerContent', 'Los')); + } + + /** kindOf() names the routing clean() applies (lit/divi-replace-text skips url/code). */ + public function testKindOfNamesTheRouting(): void + { + $cleaner = ComposeFixture::cleaner(); + + self::assertSame('html', $cleaner->kindOf('divi/text', 'content.innerContent')); + self::assertSame('text', $cleaner->kindOf('divi/heading', 'title.innerContent')); + self::assertSame('code', $cleaner->kindOf('divi/code', 'content.innerContent')); + self::assertSame('text', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text')); + self::assertSame('url', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl')); + self::assertSame('text', $cleaner->kindOf('divi/image', 'image.innerContent', 'alt')); + self::assertSame('url', $cleaner->kindOf('divi/image', 'image.innerContent', 'src')); + } + + /** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */ + public function testKeysBelowScalarKindsAndUrlPathSegments(): void + { + $cleaner = ComposeFixture::cleaner(); + + self::assertSame( + ['url' => '', 'text' => 'a'], + $cleaner->clean('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => 'a']) + ); + self::assertSame('', $cleaner->clean('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)')); + self::assertSame('', $cleaner->clean('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)')); + self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', 'Titel')); + } + + /** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */ + public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void + { + $cleaner = ComposeFixture::cleaner(); + + self::assertSame("a\tb\n", $cleaner->clean('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f")); + self::assertSame("

    a\nb\r

    ", $cleaner->clean('divi/text', 'content.innerContent', "

    a\x0b\nb\r\x08

    ")); + self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', "Ti\x00tel\x7f")); + + // S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes + // through canonical() like any reference – validated, cleaned (S21) and re-serialized. + $tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; + $canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$'; + $targets = [ + 'text' => ['divi/heading', 'title.innerContent'], + 'html' => ['divi/text', 'content.innerContent'], + 'code' => ['divi/code', 'content.innerContent'], + ]; + foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) { + $value = '$variable' . $control . $tail; + // The fake keeps them like sanitize_text_field does: the production code handles it. + self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x')); + foreach ($targets as $kind => [$module, $path]) { + self::assertSame($canonical, $cleaner->clean($module, $path, $value), $kind . ' ' . ord($control)); + } + // Embedded in text it is an embedded reference: refused. + try { + $cleaner->clean('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail); + self::fail('embedded control-split reference accepted'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('allein im Wert', $e->getMessage()); + } + } + } + + /** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */ + public function testKindOverrideMayOnlyTighten(): void + { + $cleaner = ComposeFixture::cleaner(); + + self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '

    x

    ', null, 'text')); + self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', 'x', null, 'text')); + self::assertSame('

    x

    ', $cleaner->clean('divi/code', 'content.innerContent', '

    x

    ', null, 'html')); + self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', 'Los', null, 'text')); + self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url')); + + $weaker = [ + 'html over text' => ['divi/heading', 'title.innerContent', null, 'html'], + 'code over text' => ['divi/heading', 'title.innerContent', null, 'code'], + 'code over html' => ['divi/text', 'content.innerContent', null, 'code'], + 'url over text' => ['divi/heading', 'title.innerContent', null, 'url'], + 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'], + 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'], + ]; + foreach ($weaker as $name => [$module, $path, $sub, $kind]) { + try { + $cleaner->clean($module, $path, 'x', $sub, $kind); + self::fail($name . ' was accepted'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('Feldart', $e->getMessage(), $name); + } + } + } + + /** D10: replace-text inserts only into text/html places, cleaned by the routed kind; URL and code stay. */ + public function testTextReplacementTouchesOnlyTextAndHtml(): void + { + $cleaner = ComposeFixture::cleaner(); + + self::assertSame('

    x

    ', ($cleaner->textReplacementFor('divi/text'))('content.innerContent', null, '

    x

    ')); + self::assertSame('Los', ($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'text', 'Los')); + self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test')); + self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x')); + } +} From 570c1e61422fa9348a0eb823d642f0200e4dc9c7 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 18:13:23 +0200 Subject: [PATCH 147/185] refactor: ValueKind enum instead of kind string literals (L1) enum ValueKind: string { Html, Code, Url, Text } with strictness() and tightens(self $routed) (the S33a rule formerly AttrWriter/ValueCleaner::tightens over STRICTNESS). ValueCleaner::clean() takes ?ValueKind, kindOf()/kind()/ unknownKind() return it, the sanitizer match is exhaustive over the four cases; the "Feldart" message prints ->value, so its text is unchanged. ShortformWriter drops the identity map CLEAN_KIND for ValueKind::tryFrom($spec['kind']) ?? ValueKind::Text; Composer and ShortformWriter pass ValueKind::Text/Url. The installed PHPCompatibility sniffs do not know enums: two sniffs are disabled in ValueKind.php only, with the reason. Tests: ValueCleanerTest passes/asserts ValueKind cases instead of strings; +ValueKindTest (full tightens matrix, case values). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/Composer.php | 4 +- .../Divi/Compose/ShortformWriter.php | 18 ++--- .../Divi/Compose/ValueCleaner.php | 80 +++++++------------ src/Integrations/Divi/Compose/ValueKind.php | 48 +++++++++++ .../Divi/Compose/ValueCleanerTest.php | 37 ++++----- .../Divi/Compose/ValueKindTest.php | 37 +++++++++ 6 files changed, 143 insertions(+), 81 deletions(-) create mode 100644 src/Integrations/Divi/Compose/ValueKind.php create mode 100644 tests/Unit/Integrations/Divi/Compose/ValueKindTest.php diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index 37ca2bf..a6bfd32 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -503,7 +503,7 @@ final class Composer } try { - $clean = $this->cleaner->clean($name, self::LABEL_PATH, $label, null, 'text'); + $clean = $this->cleaner->clean($name, self::LABEL_PATH, $label, null, ValueKind::Text); } catch (InvalidArgumentException $e) { $this->errors[$path] = $e->getMessage(); @@ -553,7 +553,7 @@ final class Composer // Design-system data, but still through the choke point: an id text() would change is refused. try { - $clean = $this->cleaner->clean($name, 'modulePreset', $id, null, 'text'); + $clean = $this->cleaner->clean($name, 'modulePreset', $id, null, ValueKind::Text); } catch (InvalidArgumentException $e) { $clean = null; } diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index 1b4aa78..06408e2 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -26,9 +26,6 @@ final class ShortformWriter private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; - /** Curated kind => sanitizer kind of ValueCleaner::clean(). */ - private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text']; - /** * @param array>>|null $modules Short form per * type (tests only); null = ShortformMap. @@ -107,7 +104,7 @@ final class ShortformWriter $value = trim($this->placeholders->resolve($value)); if (VariableRef::contains($value)) { - $reference = $this->cleaner->clean($module, $path, $value, $subKey, 'text'); + $reference = $this->cleaner->clean($module, $path, $value, $subKey, ValueKind::Text); if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -119,7 +116,7 @@ final class ShortformWriter throw new InvalidArgumentException(self::COLOR_ERROR); } - $clean = $this->cleaner->clean($module, $path, $value, $subKey, 'text'); + $clean = $this->cleaner->clean($module, $path, $value, $subKey, ValueKind::Text); if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -175,7 +172,7 @@ final class ShortformWriter [$urlKey, $idKey, $altKey] = $keys; $base = $path . '.desktop.value.'; - $url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, 'url'); + $url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, ValueKind::Url); if ($url === '') { throw new InvalidArgumentException($notFound); } @@ -206,7 +203,7 @@ final class ShortformWriter } try { - $clean = $this->cleaner->clean($module, $path, $alt, $altKey, 'text'); + $clean = $this->cleaner->clean($module, $path, $alt, $altKey, ValueKind::Text); } catch (InvalidArgumentException) { return ''; } @@ -253,18 +250,19 @@ final class ShortformWriter return $this->color($module, $path, $value, $sub); } - $kind = self::CLEAN_KIND[$spec['kind']] ?? 'text'; + // The curated kind as sanitizer kind; any other kind is text. + $kind = ValueKind::tryFrom((string) $spec['kind']) ?? ValueKind::Text; if (! is_string($value) && ! is_int($value) && ! is_float($value)) { throw new InvalidArgumentException('Text erwartet'); } $value = (string) $value; - if ($kind !== 'code') { + if ($kind !== ValueKind::Code) { $value = $this->placeholders->resolve($value); } $clean = $this->cleaner->clean($module, $path, $value, $sub, $kind); - if ($kind === 'url' && $clean === '' && trim($value) !== '') { + if ($kind === ValueKind::Url && $clean === '' && trim($value) !== '') { throw new InvalidArgumentException('ungültige URL'); } diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index 8d3d8c4..9e4b9fa 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -20,7 +20,8 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; * 3. UTF-8 check of the raw string. * 4. S32/S32a: ControlChars::strip() on the RAW string. * 5. S3/S3a/S23/S24: kind routing by catalog kind, sub-field, key name, path segment - * (kind()); S33a: a kind override may only tighten the routed kind (tightens()). + * (kind(), a ValueKind); S33a: a kind override may only tighten the routed kind + * (ValueKind::tightens()). * 6. S1/S2: a string with the $variable( marker → VariableRef::canonical() (whole value only; * URL fields only content references) – its result is returned as it is. * 7. the sanitizer of the kind: html / code / url / text (ValueSanitizer). @@ -48,9 +49,6 @@ final class ValueCleaner /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ private const URL_SEGMENT = '/url$/i'; - /** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */ - private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2]; - /** * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, @@ -68,12 +66,11 @@ final class ValueCleaner /** * Cleans one value written to $path (steps 1–8 above). $subKey names the sub-field of a * composite path ("linkUrl" of button.innerContent) when $value is its string alone. $kind - * ('html'|'code'|'url'|'text') replaces the catalog routing for every string of $value: the - * short form passes its curated field kind (ShortformMap), so icon-list-item text is always - * plain text. It may only tighten (ruling S33a): code < html < text; url only where the - * routing says url – a weaker or other-family kind is refused. + * replaces the catalog routing for every string of $value: the short form passes its + * curated field kind (ShortformMap), so icon-list-item text is always plain text. It may only + * tighten (ruling S33a): code < html < text; url only where the routing says url – a weaker + * or other-family kind is refused. * - * @param 'html'|'code'|'url'|'text'|null $kind * @throws InvalidArgumentException */ public function clean( @@ -81,7 +78,7 @@ final class ValueCleaner string $path, mixed $value, ?string $subKey = null, - ?string $kind = null + ?ValueKind $kind = null ): mixed { $this->guardPath($path); @@ -91,10 +88,8 @@ final class ValueCleaner /** * The kind clean() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text * uses it to leave URL and code fields alone (D10). - * - * @return 'html'|'code'|'url'|'text' */ - public function kindOf(string $module, string $path, ?string $subKey = null): string + public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind { return $this->kind($module, $path, $subKey, $subKey); } @@ -111,7 +106,7 @@ final class ValueCleaner return function (string $path, ?string $key, string $value) use ($module): ?string { $kind = $this->kindOf($module, $path, $key); - return $kind === 'text' || $kind === 'html' + return $kind === ValueKind::Text || $kind === ValueKind::Html ? (string) $this->clean($module, $path, $value, $key) : null; }; @@ -139,7 +134,7 @@ final class ValueCleaner * * @param string|null $subName First key below the state value: selects the sub-kind of a composite path. * @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind. - * @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form). + * @param ValueKind|null $kind Fixed kind instead of the routing (short form). * @throws InvalidArgumentException */ private function cleanNode( @@ -148,7 +143,7 @@ final class ValueCleaner mixed $value, ?string $subName, ?string $key, - ?string $kind = null + ?ValueKind $kind = null ): mixed { if (is_array($value)) { $clean = []; @@ -185,27 +180,27 @@ final class ValueCleaner // Step 5 (S3/S3a/S23/S24, S33a). $routed = $this->kind($module, $path, $subName, $key); - if ($kind !== null && ! self::tightens($kind, $routed)) { + if ($kind !== null && ! $kind->tightens($routed)) { throw new InvalidArgumentException(sprintf( 'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").', - $kind, + $kind->value, $path, - $routed + $routed->value )); } $kind ??= $routed; // Step 6 (S1/S2): the only way a reference is stored – whole, straight from canonical(). if (VariableRef::contains($value)) { - return $this->references->canonical($value, $this->sanitizer, $kind === 'url'); + return $this->references->canonical($value, $this->sanitizer, $kind === ValueKind::Url); } // Step 7. $clean = match ($kind) { - 'html' => $this->sanitizer->html($value), - 'code' => $this->sanitizer->code($value), - 'url' => $this->sanitizer->url($value), - default => $this->sanitizer->text($value), + ValueKind::Html => $this->sanitizer->html($value), + ValueKind::Code => $this->sanitizer->code($value), + ValueKind::Url => $this->sanitizer->url($value), + ValueKind::Text => $this->sanitizer->text($value), }; // Step 8 (S28): sanitizers strip characters ("", "%41") and can thereby build a marker @@ -218,31 +213,14 @@ final class ValueCleaner return $clean; } - /** - * Whether the kind override $kind is at least as strict as the routed kind (ruling S33a). - * Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text - * (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is - * tightened by the others, so only url → url passes. - */ - private static function tightens(string $kind, string $routed): bool - { - if ($kind === 'url' || $routed === 'url') { - return $kind === $routed; - } - - return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed]; - } - /** * Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the * sub-field for a composite path – decides. A string reached through a key below a path * with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys * at any depth), then to the last path segment (…Url), then content.innerContent → rich * text (S23: fields Divi defines only in JS), else text – never laxer than text. - * - * @return 'html'|'code'|'url'|'text' */ - private function kind(string $module, string $path, ?string $subName, ?string $key): string + private function kind(string $module, string $path, ?string $subName, ?string $key): ValueKind { // "unknown" also for a composite path whose sub-kinds do not name $subName. $kind = $this->catalog->pathKind($module, $path, $subName); @@ -251,26 +229,26 @@ final class ValueCleaner } return match ($kind) { - 'richtext' => 'html', - 'code' => 'code', - 'url', 'image' => 'url', + 'richtext' => ValueKind::Html, + 'code' => ValueKind::Code, + 'url', 'image' => ValueKind::Url, 'unknown' => $this->unknownKind($path, $key), - default => 'text', + default => ValueKind::Text, }; } - /** @return 'html'|'url'|'text' */ - private function unknownKind(string $path, ?string $key): string + /** Html, Url or Text – never Code. */ + private function unknownKind(string $path, ?string $key): ValueKind { if ($key !== null) { - return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text'; + return preg_match(self::URL_KEY, $key) === 1 ? ValueKind::Url : ValueKind::Text; } $segments = explode('.', $path); if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) { - return 'url'; + return ValueKind::Url; } - return $path === 'content.innerContent' ? 'html' : 'text'; + return $path === 'content.innerContent' ? ValueKind::Html : ValueKind::Text; } } diff --git a/src/Integrations/Divi/Compose/ValueKind.php b/src/Integrations/Divi/Compose/ValueKind.php new file mode 100644 index 0000000..bdca4a6 --- /dev/null +++ b/src/Integrations/Divi/Compose/ValueKind.php @@ -0,0 +1,48 @@ + 0, + self::Html => 1, + self::Text => 2, + self::Url => null, + }; + } + + /** + * Whether this kind, as an override, is at least as strict as the routed kind (ruling S33a). + * Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text + * (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is + * tightened by the others, so only url → url passes. + */ + public function tightens(self $routed): bool + { + if ($this === self::Url || $routed === self::Url) { + return $this === $routed; + } + + return $this->strictness() >= $routed->strictness(); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php index 71cb9ae..706e1fd 100644 --- a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Compose\ValueKind; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -100,13 +101,13 @@ final class ValueCleanerTest extends TestCase { $cleaner = ComposeFixture::cleaner(); - self::assertSame('html', $cleaner->kindOf('divi/text', 'content.innerContent')); - self::assertSame('text', $cleaner->kindOf('divi/heading', 'title.innerContent')); - self::assertSame('code', $cleaner->kindOf('divi/code', 'content.innerContent')); - self::assertSame('text', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text')); - self::assertSame('url', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl')); - self::assertSame('text', $cleaner->kindOf('divi/image', 'image.innerContent', 'alt')); - self::assertSame('url', $cleaner->kindOf('divi/image', 'image.innerContent', 'src')); + self::assertSame(ValueKind::Html, $cleaner->kindOf('divi/text', 'content.innerContent')); + self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/heading', 'title.innerContent')); + self::assertSame(ValueKind::Code, $cleaner->kindOf('divi/code', 'content.innerContent')); + self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text')); + self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl')); + self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/image', 'image.innerContent', 'alt')); + self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/image', 'image.innerContent', 'src')); } /** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */ @@ -163,19 +164,19 @@ final class ValueCleanerTest extends TestCase { $cleaner = ComposeFixture::cleaner(); - self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '

    x

    ', null, 'text')); - self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', 'x', null, 'text')); - self::assertSame('

    x

    ', $cleaner->clean('divi/code', 'content.innerContent', '

    x

    ', null, 'html')); - self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', 'Los', null, 'text')); - self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url')); + self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '

    x

    ', null, ValueKind::Text)); + self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', 'x', null, ValueKind::Text)); + self::assertSame('

    x

    ', $cleaner->clean('divi/code', 'content.innerContent', '

    x

    ', null, ValueKind::Html)); + self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', 'Los', null, ValueKind::Text)); + self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', ValueKind::Url)); $weaker = [ - 'html over text' => ['divi/heading', 'title.innerContent', null, 'html'], - 'code over text' => ['divi/heading', 'title.innerContent', null, 'code'], - 'code over html' => ['divi/text', 'content.innerContent', null, 'code'], - 'url over text' => ['divi/heading', 'title.innerContent', null, 'url'], - 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'], - 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'], + 'html over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Html], + 'code over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Code], + 'code over html' => ['divi/text', 'content.innerContent', null, ValueKind::Code], + 'url over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Url], + 'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Text], + 'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Html], ]; foreach ($weaker as $name => [$module, $path, $sub, $kind]) { try { diff --git a/tests/Unit/Integrations/Divi/Compose/ValueKindTest.php b/tests/Unit/Integrations/Divi/Compose/ValueKindTest.php new file mode 100644 index 0000000..54f4e8d --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ValueKindTest.php @@ -0,0 +1,37 @@ + ['code'], + 'html' => ['code', 'html'], + 'text' => ['code', 'html', 'text'], + 'url' => ['url'], + ]; + foreach (ValueKind::cases() as $override) { + foreach (ValueKind::cases() as $routed) { + self::assertSame( + in_array($routed->value, $allowed[$override->value], true), + $override->tightens($routed), + $override->value . ' over ' . $routed->value + ); + } + } + } + + public function testValuesAreTheCuratedKindNames(): void + { + self::assertSame(['html', 'code', 'url', 'text'], array_map(static fn (ValueKind $kind): string => $kind->value, ValueKind::cases())); + self::assertNull(ValueKind::tryFrom('richtext')); + } +} From 3eeb7eee9013e2c034fb54e32395707364f93412 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 18:14:49 +0200 Subject: [PATCH 148/185] refactor: split TextReplacer into HtmlTokens and StoredForms (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HtmlTokens (static, with the S45 docblock): SPLIT, RAW_OPEN, tokenize(), structure(), closes(), unterminated() moved unchanged; new RAW_ELEMENTS list. - StoredForms: of() (was TextReplacer::forms), mentions(), unicodeEscaped() – the DB prefilter forms; TextReplacer::mentions() delegates, the ability's candidate query uses StoredForms::of(). - TextReplacer keeps problems(), apply() and the splice/safe() verification; safe() still runs the tokenizer and HtmlContext as two independent scans, in the same order. HtmlTokensTest asserts RAW_ELEMENTS === HtmlContext::RAW and that RAW_OPEN opens exactly those elements. TextReplacerTest and HtmlContextTest unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/Compose/HtmlContext.php | 4 +- src/Integrations/Divi/Compose/HtmlTokens.php | 134 ++++++++++++++ src/Integrations/Divi/Compose/StoredForms.php | 58 ++++++ .../Divi/Compose/TextReplacer.php | 165 +----------------- .../Divi/Pages/DiviReplaceTextAbility.php | 5 +- .../Divi/Compose/HtmlTokensTest.php | 34 ++++ 6 files changed, 239 insertions(+), 161 deletions(-) create mode 100644 src/Integrations/Divi/Compose/HtmlTokens.php create mode 100644 src/Integrations/Divi/Compose/StoredForms.php create mode 100644 tests/Unit/Integrations/Divi/Compose/HtmlTokensTest.php diff --git a/src/Integrations/Divi/Compose/HtmlContext.php b/src/Integrations/Divi/Compose/HtmlContext.php index 9301494..ca581bc 100644 --- a/src/Integrations/Divi/Compose/HtmlContext.php +++ b/src/Integrations/Divi/Compose/HtmlContext.php @@ -6,7 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Compose; /** * An independent second scan of an HTML fragment (ruling S45): a small state machine after the - * HTML tokenizer rules, written separately from TextReplacer's regex tokenizer so a mistake in + * HTML tokenizer rules, written separately from the regex tokenizer HtmlTokens so a mistake in * one is caught by the other. It lists the byte spans that are NOT text: tags (with quoted * attribute values, where ">" does not end the tag), comments, bogus comments and the bodies of * raw-text elements (script, style, …). Where it is unsure it errs towards "not text": an @@ -15,7 +15,7 @@ namespace LIT\MCP\Integrations\Divi\Compose; */ final class HtmlContext { - /** Elements whose body the browser does not parse as text/markup. */ + /** Elements whose body the browser does not parse as text/markup (= HtmlTokens::RAW_ELEMENTS). */ public const RAW = ['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext']; /** @return list [start, end) byte spans that are markup or raw bodies. */ diff --git a/src/Integrations/Divi/Compose/HtmlTokens.php b/src/Integrations/Divi/Compose/HtmlTokens.php new file mode 100644 index 0000000..83a8700 --- /dev/null +++ b/src/Integrations/Divi/Compose/HtmlTokens.php @@ -0,0 +1,134 @@ +" inside a quoted attribute value does not end the tag), comments, + * entities and $variable(…)$ references ("token"), the bodies of raw-text elements such as + * ', 'summary' => 'böse Zusammenfassung']; + $table = $this->table([], [], null, $log); + $table->prepare_items(); - self::assertStringNotContainsString('