merge: Etappen 0, 1a, 1b, 2a, 2b (v0.3.0) – MCP-Anbindung, WordPress-Inhalte, Medien/Admin, Divi lesen und schreiben
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
/tests export-ignore
|
||||||
|
/docs/superpowers export-ignore
|
||||||
|
/.superpowers export-ignore
|
||||||
|
/phpunit.xml.dist export-ignore
|
||||||
|
/phpcs.xml.dist export-ignore
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/vendor/
|
||||||
|
/vendor.lit-bak/
|
||||||
|
.phpunit.result.cache
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/* 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": a core-style list table; the change is the wide primary column. */
|
||||||
|
.lit-mcp .lit_mcp_log_entries .column-object {
|
||||||
|
width: 24%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .lit_mcp_log_entries .column-user {
|
||||||
|
width: 12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .lit_mcp_log_entries .column-created_at {
|
||||||
|
width: 11em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .lit_mcp_log_entries .column-object,
|
||||||
|
.lit-mcp .lit_mcp_log_entries .column-user .lit-mcp-muted {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-summary {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Non-ok results: a notice-coloured badge and a marked row (like core's unapproved comments);
|
||||||
|
#the-list beats core's "no shadow on the last row". */
|
||||||
|
.lit-mcp-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-badge-warning {
|
||||||
|
background: #fcf9e8;
|
||||||
|
color: #614a19;
|
||||||
|
box-shadow: inset 0 0 0 1px #dba617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-badge-error {
|
||||||
|
background: #fcf0f1;
|
||||||
|
color: #8a2424;
|
||||||
|
box-shadow: inset 0 0 0 1px #d63638;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp #the-list tr.lit-mcp-row-warning > :first-child {
|
||||||
|
box-shadow: inset 4px 0 0 #dba617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp #the-list tr.lit-mcp-row-error > :first-child {
|
||||||
|
box-shadow: inset 4px 0 0 #d63638;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .tablenav .actions select {
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-date {
|
||||||
|
float: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .tablenav .lit-mcp-reset {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 2.30769231;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab "Limits". */
|
||||||
|
.lit-mcp-unit {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 499.41 107.88"><defs><style>.cls-1{fill:#2a4b9a;}</style></defs><path class="cls-1" d="M102.16,28.36A25.62,25.62,0,0,0,76.57,52.87H93.84A8.4,8.4,0,1,1,94,55.93H76.63a25.61,25.61,0,1,0,25.53-27.57Z"/><path class="cls-1" d="M76.51,55.93a22,22,0,1,1,.06-3.06H80.2a25.68,25.68,0,1,0-.06,3.06Z"/><path class="cls-1" d="M76.57,52.87H63a8.4,8.4,0,1,0-.15,3.06H76.5c.06-.64.09-1.3.09-2C76.59,53.6,76.58,53.23,76.57,52.87Z"/><path d="M166.64,72.58H153.42V29h-8V78.87h25.29V76.93A30.68,30.68,0,0,1,166.64,72.58Z"/><path d="M313,35.49V50.61h18.37v6.22H313V72.3h20.48v6.56H304.91V29h28.53v6.49Z"/><path d="M381.62,78.86h-8.05l-24.23-36.8v36.8h-8.06V29.07h8.06L373.57,66V29.07h8.05Z"/><path d="M415.8,53v6.76h-29V53Z"/><path d="M428.58,29V78.88h-8.05V29Z"/><path d="M470.4,29v6.5H456.48V78.88h-8.12V35.51H434.51V29Z"/><path d="M294.77,58a14.11,14.11,0,0,0-5.08-4.42,52.49,52.49,0,0,0-7.74-3q-3.74-1.27-5.9-2.23a9.72,9.72,0,0,1-3.56-2.61,6.2,6.2,0,0,1-1.41-4.2,6.16,6.16,0,0,1,2-5,8.18,8.18,0,0,1,5.44-1.73,8.31,8.31,0,0,1,5.9,2,8.46,8.46,0,0,1,2.65,4.84h8.69A13.87,13.87,0,0,0,290.68,32c-3-2.45-7-3.67-12.12-3.67A20.29,20.29,0,0,0,270.09,30a12.79,12.79,0,0,0-5.65,4.7A11.66,11.66,0,0,0,263,38a30.5,30.5,0,0,1,4.44,14.2,14.28,14.28,0,0,0,2.09,1.34,53.22,53.22,0,0,0,7.59,3q3.81,1.28,6,2.26a10.49,10.49,0,0,1,3.68,2.72,6.61,6.61,0,0,1,1.48,4.42,6.82,6.82,0,0,1-2,5.08,7.79,7.79,0,0,1-5.69,2A9.06,9.06,0,0,1,274,70.6,8.77,8.77,0,0,1,271.22,65h-5.8a30.9,30.9,0,0,1-1.92,4.07,14.16,14.16,0,0,0,1.85,3.42,15.93,15.93,0,0,0,6.43,5,22.08,22.08,0,0,0,9.11,1.8,18.68,18.68,0,0,0,8.83-1.91,12.83,12.83,0,0,0,5.37-5,13.65,13.65,0,0,0,1.77-6.81A12.77,12.77,0,0,0,294.77,58Z"/><path class="cls-1" d="M236.7,28.4a25.58,25.58,0,0,0-22.78,14,25.59,25.59,0,1,0,0,23.28A25.59,25.59,0,1,0,236.7,28.4ZM191.13,74a20,20,0,1,1,20-21c0,.34,0,.67,0,1s0,.67,0,1A20.06,20.06,0,0,1,191.13,74Zm45.57,0a20.06,20.06,0,0,1-20-19c0-.33,0-.67,0-1s0-.67,0-1A20,20,0,1,1,236.7,74Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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';
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
}() );
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"automattic/jetpack-autoloader": "^3.0",
|
||||||
|
"wordpress/mcp-adapter": "^0.5",
|
||||||
|
"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": {
|
||||||
|
"automattic/jetpack-autoloader": true,
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2982
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
# 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
|
||||||
|
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`; 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`.
|
||||||
|
|
||||||
|
## 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.<schlüssel>` 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
|
||||||
|
**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 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
|
||||||
|
Wunsch des Nutzers verwenden. Ein **privater** Eintrag wird durch Planen zum Zeitpunkt öffentlich (die
|
||||||
|
Antwort warnt davor).
|
||||||
|
- `lit/post-trash` legt in den Papierkorb (nie endgültig) und braucht `confirm: true` nach Rückfrage. Ist der
|
||||||
|
Eintrag Startseite, Beitragsseite oder in Menüs verlinkt, nennen Rückfrage und `warnings` das – dem Nutzer
|
||||||
|
weitergeben.
|
||||||
|
|
||||||
|
## 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` 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).
|
||||||
|
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 `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
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## 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 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Objektwert ersetzt den bisherigen Wert dieses Breakpoints ganz (Unterschlüssel werden nicht
|
||||||
|
zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType` immer mitschicken (siehe
|
||||||
|
`divi-shortform`).
|
||||||
|
|
||||||
|
Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt
|
||||||
|
(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst,
|
||||||
|
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 – 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
|
||||||
|
|
||||||
|
- `lit-divi-catalog` ohne `module` listet alle Module dieser Website; mit `module` (z. B. `"heading"`) 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. Bei einem zusammengesetzten
|
||||||
|
Pfad (ein oder mehrere benannte Unterfelder unter einem Pfad, z. B. `buttonOne.innerContent` mit `text` und
|
||||||
|
`linkUrl`) ist der Eintrag stattdessen eine Zuordnung Unterfeld → Feldart (`{"text": "text", "linkUrl":
|
||||||
|
"url"}`), soweit für die einzelnen Unterfelder ableitbar; fehlende Pfade oder Unterfelder gelten als
|
||||||
|
unbekannt, statt eine Feldart zu raten.
|
||||||
|
- Ergebnisform ohne `module`: `{"modules": [{"name": "divi/heading", "title": "Heading", "category": "module",
|
||||||
|
"children": [], "shortform": true}, …], "notes": […]}`; mit `module`: `{"module": {"name": "divi/heading",
|
||||||
|
"title": …, "children": […], "item_type": null, "shortform": {"text": {"path": "title.innerContent", …}},
|
||||||
|
"paths": […], "path_kinds": {…}}, "notes": […]}`.
|
||||||
|
- `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`).
|
||||||
@@ -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:<Name>}}`.
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) |
|
||||||
|
| `{{var:<Name>}}` | 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:<meta_key>}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` |
|
||||||
|
| `"preset": "<Name>"` | 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; ist ein Name doppelt vergeben, statt des
|
||||||
|
Namens die ID aus `lit-divi-design-system` angeben (z. B. `{{color:gcid-…}}`). Felder, deren Schlüssel mit `_`
|
||||||
|
beginnt (geschützte Meta-Daten), sind nicht erlaubt.
|
||||||
|
|
||||||
|
In Link-Feldern (`url`, `button_url` …) nimmt das Werkzeug jeden dynamischen Inhalt an, auch `{{post:title}}`;
|
||||||
|
sinnvoll sind dort nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine
|
||||||
|
Link-Variable).
|
||||||
|
|
||||||
|
## `$variable`-Werte
|
||||||
|
|
||||||
|
In `lit-divi-node-get` erscheinen die übersetzten Verweise als `$variable({…})$`. Diese Werte unverändert
|
||||||
|
lassen oder wieder durch einen Platzhalter ersetzen. `$variable`-Verweise werden geparst, geprüft und vom
|
||||||
|
Plugin neu erzeugt: erlaubt sind nur ganze Werte mit bekannten Farben, Variablen, Schriften und
|
||||||
|
dynamischen Quellen; Zusatztexte davor/danach (`before`/`after`) werden bereinigt; alles andere (z. B. ein
|
||||||
|
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 – 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.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 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, aber nicht für denselben Pfad (dann Fehler
|
||||||
|
`validation_failed` mit dem Pfad).
|
||||||
|
|
||||||
|
## 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": "<p>Seit 1987 in Köln.</p>" },
|
||||||
|
{ "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 `1_5`, `2_5`, `3_5` wie im Divi-Builder – `4_5` nicht); `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 „<Abschnitt> – <Modul>“, z. B. „Hero – Button“; Zeilen „Hero – Zeile“, eine Zeile
|
||||||
|
in einer Spalte „Hero – Zeile (innen)“. Abschnitten deshalb ein Label geben.
|
||||||
|
- `heading` ohne `level` wird `h2` (eine Seite hat meist schon eine `h1` im Titel). Auch Module mit Titel
|
||||||
|
(`blurb`, `cta`, `toggle`, Akkordeon-Eintrag, Slide, `contact-form`, `number-counter`, `team-member`) nehmen
|
||||||
|
`level` (`h1`–`h6`); ohne `level` gilt Divis Standard (beim Kontaktformular `h1` – daher meist `h2` setzen).
|
||||||
|
|
||||||
|
## 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`, `level` |
|
||||||
|
| `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`, `level` (des Namens) |
|
||||||
|
| `accordion` → `accordion-item` | Eintrag: `title`, `content`, `open`, `level` |
|
||||||
|
| `tabs` → `tab` | Eintrag: `title`, `content` |
|
||||||
|
| `toggle` | `title`, `content`, `open`, `level` |
|
||||||
|
| `gallery` | `image_ids` (Liste von Anhang-IDs) |
|
||||||
|
| `slider` → `slide` | Eintrag: `title`, `content`, `button_text`, `button_url`, `image_id`, `src`, `alt`, `level` |
|
||||||
|
| `contact-form` → `contact-field` | Formular: `title`, `level`, `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`, `level` |
|
||||||
|
| `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`.
|
||||||
|
|
||||||
|
Zwei Felder heißen wie Knoten-Schlüssel und lassen sich deshalb beim Einfügen **nicht** in Kurzform setzen:
|
||||||
|
`type` des Kontaktformular-Felds (`type` ist dort der Modultyp) und `label` des Social-Media-Netzwerks (`label`
|
||||||
|
ist dort das Admin-Label). Beim Einfügen über `attrs` (`fieldItem.advanced.type` bzw.
|
||||||
|
`socialNetwork.innerContent` mit `label`) setzen, danach auch mit `lit-divi-update` über `set`
|
||||||
|
(`{"set": {"type": "email"}}`).
|
||||||
|
|
||||||
|
## 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. Denselben
|
||||||
|
Pfad nicht zugleich über `set` und `attrs` ändern (dann zwei Änderungen senden).
|
||||||
|
- `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. Eine unbekannte Pfad-ID meldet `lit-divi-update`
|
||||||
|
mit den übrigen Eingabefehlern gesammelt als `validation_failed` (`fields.changes[i].id`), `lit-divi-move`,
|
||||||
|
`lit-divi-delete` und `lit/divi-duplicate-node` als `not_found` – in beiden Fällen mit `lit-divi-outline`
|
||||||
|
neu lesen.
|
||||||
|
|
||||||
|
## Spaltenbreiten ändern
|
||||||
|
|
||||||
|
Die Breite einer Spalte steht in `module.decoration.sizing` (Desktop: `flexType`, z. B. `8_24` = 1/3). Wer
|
||||||
|
diesen Desktop-Wert über `attrs` ersetzt, ersetzt das ganze Objekt: `flexType` immer mitschicken, sonst fällt
|
||||||
|
die Breite weg. Erlaubt sind je Breakpoint (Desktop, Tablet, Telefon, …) nur die Spaltenklassen des
|
||||||
|
Divi-Builders: `1_24` … `24_24`, `1_5`, `2_5`, `3_5`, `1_7`, `1_9`, `1_10`, `1_11` oder `none`. In einer Zeile
|
||||||
|
mit festen Breiten alle Spalten zusammen ändern (Summe 1). Die Angabe
|
||||||
|
`flexColumnStructure` der Zeile (Spaltenvorlage im Divi-Builder) passt das Werkzeug nicht an; nach einer
|
||||||
|
Breitenänderung zeigt der Builder dort evtl. noch die alte Vorlage; gespeichert sind die neuen Breiten (bei
|
||||||
|
Bedarf die Vorlage im Builder neu wählen).
|
||||||
|
|
||||||
|
## Sicherheit und Speichern
|
||||||
|
|
||||||
|
Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem
|
||||||
|
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 – 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
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## Ä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
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
`lit/divi-replace-text` ändert sie nicht, sondern nennt sie in `skipped_nodes`.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Medien
|
||||||
|
|
||||||
|
Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities, die über
|
||||||
|
`mcp-adapter-execute-ability` (Name in `ability_name`, Eingaben in `parameters`) aufgerufen werden. Welche
|
||||||
|
Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (siehe `overview`);
|
||||||
|
`lit-media-preview` und `lit-media-download` lassen sich dort nur auf „direkt“ oder „deaktiviert“ stellen,
|
||||||
|
nie auf „indirekt“ – ihr Bild- bzw. Dateiergebnis wird nur bei direktem Aufruf zu einem sichtbaren Inhalt.
|
||||||
|
|
||||||
|
## Suchen und ansehen
|
||||||
|
|
||||||
|
- `lit-media-search`: Text (Titel, Dateiname, Beschreibung), `mime` (z. B. `image` oder `application/pdf`),
|
||||||
|
Zeitraum (`date_after`/`date_before`), `missing_alt: true` (Bilder ohne Alt-Text; `mime` muss dabei leer
|
||||||
|
oder ein Bild-Typ sein) und `unused: true` (keine gefundene Verwendung). Geliefert werden nur reguläre
|
||||||
|
Mediathek-Einträge (kein Papierkorb), die der Nutzer bearbeiten darf. `unused` prüft je Aufruf höchstens 20
|
||||||
|
Medien; wird die Prüfung dadurch unterbrochen, folgt eine Warnung und mit `next_cursor` geht es weiter –
|
||||||
|
„keine Verwendung gefunden“ ist dabei keine Garantie (Details je Medium über `lit/media-get`).
|
||||||
|
- `lit-media-preview`: verkleinertes Bild ansehen (längste Kante `max_size`, Standard 512 px) – z. B. um
|
||||||
|
passende Alt-Texte zu schreiben.
|
||||||
|
- `lit/media-get`: alle Metadaten, Bildgrößen und Verwendungsorte (Beitragsbild, Inhalte inkl. Divi,
|
||||||
|
Bildfelder, Website-Icon, Logo, Header-/Hintergrundbild, Widgets). `certainty`: „sicher“ = per ID
|
||||||
|
eingebunden, „möglicherweise“ = nur die Datei-URL gefunden (kann zu einer anderen Kopie der Datei gehören).
|
||||||
|
Einträge, die der Nutzer nicht lesen darf, erscheinen als „(nicht lesbar)“. `usages_complete: false` heißt,
|
||||||
|
die Inhaltssuche wurde begrenzt – weitere Verwendungen sind möglich; `usages_truncated: true` heißt, es
|
||||||
|
gibt mehr Verwendungen als gelistet.
|
||||||
|
|
||||||
|
## Hochladen
|
||||||
|
|
||||||
|
- `lit-media-upload-from-url` (direkt): lädt eine öffentlich erreichbare `http(s)`-Datei; interne oder
|
||||||
|
lokale Adressen werden abgelehnt (auch als Ziel einer Weiterleitung). Scheitert der Abruf selbst, lautet die
|
||||||
|
Meldung immer „Die Datei konnte nicht geladen werden.“ – dann eine andere öffentliche URL versuchen oder die
|
||||||
|
Datei per `lit/media-upload` hochladen.
|
||||||
|
- `lit/media-upload` (indirekt): lädt eine Datei als Base64, höchstens 10 MB – größere Dateien per URL.
|
||||||
|
- Beide: optional `title`, `alt`, `post_id` (Eintrag, dem das Medium zugeordnet wird – braucht
|
||||||
|
Bearbeitungsrecht); erlaubte Dateitypen jpg, png, gif, webp, avif, pdf, mp4, mp3; Größenlimit siehe die
|
||||||
|
Tool-Beschreibung (Standard 5 MB, einstellbar bis 32 MB unter Einstellungen → LIT MCP → Limits). Beide legen bei
|
||||||
|
jedem Aufruf ein neues Medium an (nicht idempotent).
|
||||||
|
|
||||||
|
## Ändern, herunterladen, löschen
|
||||||
|
|
||||||
|
- `lit/media-update`: Alt-Text, Titel, Bildunterschrift, Beschreibung für 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`) als Schutz vor
|
||||||
|
parallelen Änderungen mitsenden – `modified` ändert sich bei Titel, Bildunterschrift und Beschreibung,
|
||||||
|
nicht bei einer reinen Alt-Text-Änderung. Bei Erfolg meldet `results` jeden Eintrag einzeln (mit neuem
|
||||||
|
`modified`).
|
||||||
|
- `lit-media-download`: Datei (Original oder eine Bildgröße aus `sizes` in `lit/media-get`) als eingebettete
|
||||||
|
Ressource, nur bis zum Upload-Limit und höchstens 10 MB – darüber die URL aus `lit/media-get` verwenden.
|
||||||
|
- `lit/media-delete`: nur nach Rückfrage (`confirm: true`). Wird das Medium verwendet (auch nur
|
||||||
|
„möglicherweise“), kommt `in_use` mit der Liste der Verwendungen – erst nach ausdrücklicher Zustimmung des
|
||||||
|
Nutzers mit `force: true` löschen. Medien im Papierkorb werden nicht gelöscht. Ohne aktivierten
|
||||||
|
Medien-Papierkorb (WordPress-Standard) ist das Löschen endgültig und nicht rückgängig zu machen. 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, statt
|
||||||
|
sich auf „keine Verwendung gefunden“ zu verlassen.
|
||||||
|
|
||||||
|
Alt-Texte beschreiben den Bildinhalt knapp und sachlich (keine „Bild von …“-Floskeln).
|
||||||
|
|
||||||
|
## Wenn ein Schreibvorgang fehlschlägt
|
||||||
|
|
||||||
|
Wie bei Inhalten (siehe `overview`): `code` = `write_failed` (alles wurde zurückgenommen) oder
|
||||||
|
`partial_write` (die Rücknahme war unvollständig, `hint` folgen statt zu wiederholen).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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-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-insert`,
|
||||||
|
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
|
||||||
|
|
||||||
|
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi). Jede Gruppe hat unter
|
||||||
|
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
|
||||||
|
(„wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“
|
||||||
|
schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer
|
||||||
|
direkt; `lit-media-preview`/`lit-media-download` bleiben bei Gruppe „indirekt“ direkt. Ab mehr als 25 direkten
|
||||||
|
Tools warnt die Seite (Kontextkosten in claude.ai). Änderungen gelten ab der nächsten Verbindung.
|
||||||
|
|
||||||
|
Ü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`, `lit/media-upload`,
|
||||||
|
`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.
|
||||||
|
|
||||||
|
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
|
||||||
|
`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`.
|
||||||
|
|
||||||
|
## Rückmeldungen
|
||||||
|
|
||||||
|
- 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);
|
||||||
|
`lit/divi-replace-text` nennt `tree_rev` je Seite in `results` (Probelauf: in `matches`).
|
||||||
|
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
|
||||||
|
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
|
||||||
|
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 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": <ID>, "show_on_front": "page"}}`.
|
||||||
|
5. Menü: `lit/menu-create` (optional mit `location` aus `lit/menu-list`), Einträge mit `lit/menu-set-items` – mit
|
||||||
|
`menu_id` und `menu_rev` aus der Antwort von `lit/menu-create` (kein `lit/menu-get` nötig).
|
||||||
|
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. Ersetzt wird nur
|
||||||
|
sichtbarer Text (keine Links, Stile, Code oder dynamischen Inhalte); in reinen Textfeldern (Titel,
|
||||||
|
Button-Texte) werden Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht
|
||||||
|
geändert, sondern in `skipped_nodes` genannt – dem Nutzer sagen, dass er sie in der Divi-Bibliothek ändern muss.
|
||||||
|
|
||||||
|
## Menüs pflegen
|
||||||
|
|
||||||
|
- `lit/menu-get` liefert den Baum und `menu_rev` (Stand des Menüs; für ein neues Menü schon `lit/menu-create`).
|
||||||
|
`lit/menu-set-items` verlangt diese `menu_rev`; hat sich das Menü inzwischen geändert, kommt `rev_mismatch` – dann mit `lit/menu-get` neu lesen.
|
||||||
|
Die Antwort enthält die neue `menu_rev` für weitere Änderungen.
|
||||||
|
- 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.
|
||||||
|
- `lit/menu-assign-location` mit `menu_id: 0` entfernt ein Menü von seiner Position (es verschwindet dann von
|
||||||
|
der Website) und braucht dafür `confirm: true` nach Rückfrage; die Antwort nennt das entfernte Menü.
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -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 `<exclude name="WordPress.Security.EscapeOutput.ExceptionNotEscaped"/>` 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 <id> 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/<name>, 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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Etappe 1b – Manuelle Abnahme (Browser)
|
||||||
|
|
||||||
|
Vorbereitet von Task 13 (Doku/Version/E2E). Automatisiert geprüft ist bereits: alle Unit-Tests
|
||||||
|
(`composer test`), Lint (`composer lint`) und die drei Integrationsskripte
|
||||||
|
(`tests/Integration/mcp-smoke.sh`, `tests/Integration/wp-basis-smoke.sh`,
|
||||||
|
`tests/Integration/media-admin-smoke.sh`) – Details im Report von Task 13
|
||||||
|
(`.superpowers/sdd/2026-09-25-etappe-1b-medien-admin/task-13-report.md`).
|
||||||
|
|
||||||
|
Die folgenden Punkte lassen sich nicht automatisiert oder nur mit einem echten claude.ai-Connector
|
||||||
|
prüfen; das führt Jan-Niclas selbst im Browser durch. Aktueller Stand: Version 0.2.0, Branch `etappe-1`.
|
||||||
|
|
||||||
|
## 1. Einstellungen → LIT MCP: alle Tabs
|
||||||
|
|
||||||
|
- [ ] Aufrufen: WordPress-Adminbereich → Einstellungen → LIT MCP.
|
||||||
|
- [ ] Alle fünf Tabs öffnen: Verbindung, Zugriff, Tools, Protokoll, Limits. Jeder Tab lädt ohne
|
||||||
|
PHP-Fehler/-Warnung (WP_DEBUG ist an) und zeigt sinnvolle Inhalte.
|
||||||
|
- [ ] Im Tab „Zugriff“: Rolle „Redakteur“ freigeben, speichern; ein Redakteur-Konto kann sich danach
|
||||||
|
verbinden (Access-Capability wirkt sofort, kein Cache). Freigabe wieder entfernen, speichern.
|
||||||
|
- [ ] Im Tab „Tools“: ein Tool (z. B. `lit/content-query`, Standard „direkt“) auf „indirekt“ stellen,
|
||||||
|
speichern – der Hinweistext dort weist bereits darauf hin, dass die Serveranweisungen erst ab
|
||||||
|
der nächsten Verbindung gelten. Danach wieder auf „Standard“ zurückstellen, speichern.
|
||||||
|
|
||||||
|
## 2. claude.ai-Connector verbinden und Widerruf
|
||||||
|
|
||||||
|
- [ ] Mit claude.ai verbinden (Staging-Domain oder `ddev share`), OAuth-Flow durchlaufen.
|
||||||
|
- [ ] Die neue Verbindung erscheint unter Einstellungen → LIT MCP → Zugriff („Aktive Verbindungen“)
|
||||||
|
mit Client-Namen und Zeitpunkt.
|
||||||
|
- [ ] Dort „Widerrufen“ klicken. Der nächste Tool-Aufruf in claude.ai scheitert (401) und claude.ai
|
||||||
|
verlangt eine neue Anmeldung, bevor Tools wieder nutzbar sind.
|
||||||
|
|
||||||
|
## 3. Medien-Workflow in claude.ai
|
||||||
|
|
||||||
|
- [ ] In claude.ai anfragen: „Suche Bilder ohne Alt-Text, zeig mir eins und schlag einen Alt-Text
|
||||||
|
vor.“ Erwartet: `lit-media-search` mit `missing_alt: true` liefert Treffer, `lit-media-preview`
|
||||||
|
zeigt ein sichtbares Bild in der Unterhaltung (nicht nur eine Textbeschreibung).
|
||||||
|
- [ ] Optional weiterführen: Alt-Text mit `lit/media-update` speichern lassen und in der WordPress-
|
||||||
|
Mediathek prüfen, dass er übernommen wurde.
|
||||||
|
|
||||||
|
## 4. Werkzeuge → Website-Zustand
|
||||||
|
|
||||||
|
- [ ] WordPress-Adminbereich → Werkzeuge → Website-Zustand → Reiter „Info“ bzw. die Testübersicht.
|
||||||
|
- [ ] Der Test „LIT MCP Endpunkt“ ist grün, wenn der Server erreichbar und `/.well-known/` durchgereicht
|
||||||
|
wird – sonst zeigt er verständlich, was fehlt (z. B. nginx-Sperre für Punkt-Pfade). Bei diesem
|
||||||
|
DDEV-Setup hat `mcp-smoke.sh` bereits eine WARN dazu ausgegeben (`/.well-known/` von der
|
||||||
|
nginx-Konfiguration blockiert) – das hier im Site-Health-Test bestätigen und für die
|
||||||
|
Staging-/Produktions-nginx-Konfiguration eine Ausnahme für `/.well-known/` einplanen.
|
||||||
|
|
||||||
|
## 5. Protokoll-Tab
|
||||||
|
|
||||||
|
- [ ] Einstellungen → LIT MCP → Protokoll: Einträge der eben durchgeführten Tests (Task 13 und die
|
||||||
|
manuellen Schritte hier) sind sichtbar.
|
||||||
|
- [ ] Filter nach Tool/Ability (z. B. `lit/media-update`) funktioniert und schränkt die Liste ein.
|
||||||
|
- [ ] Ein Eintrag mit Revisions-Link öffnet den WordPress-Revisionsvergleich (z. B. nach einer
|
||||||
|
`lit-content-update`- oder `lit/post-replace-with-copy`-Änderung).
|
||||||
|
- [ ] „Protokoll leeren“ leert die Liste (Bestätigungsdialog beachten) und zeigt danach die
|
||||||
|
Erfolgsmeldung „Protokoll geleert.“.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
Kurze Rückmeldung an den Ausführenden (Task 13 / Controller): welche Punkte grün sind und was ggf.
|
||||||
|
als Folgeaufgabe (Bugfix oder Doku-Korrektur) aufgenommen werden muss.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Etappe 1b – Entscheidungen (Rulings) und offene Kleinigkeiten
|
||||||
|
|
||||||
|
Aus dem SDD-Ledger übernommen, 2026-09-26.
|
||||||
|
|
||||||
|
- Q1 (B1): T1 test expects sorted order ['logo','photo'] (code sorts) — fix the test.
|
||||||
|
- Q2 (B2/B4): every task that adds/changes a direct tool updates docs/guides/overview.md "Direkt sichtbar:" in the same task; T13 keeps the exact heading "Direkt sichtbar:" (ToolNamingTest regex).
|
||||||
|
- Q3 (B3): indirect tools are always written lit/<name> in descriptions/hints (ToolNamingTest).
|
||||||
|
- Q4 (R1/C1/C3): media writes reuse 1a building blocks: ChangeLog via FollowUp (or PostChangeResult-style helper), failures after irreversible steps → WriteFailed (clean → write_failed, file kept but meta failed → partial_write with attachment id), server-side failures → Errors::writeFailed not validation_failed/unsupported; one shared helper for both upload abilities (no duplicated permission/result code).
|
||||||
|
- Q5 (R2/C4): URL import passes limit_response_size (Limits upload MB) to the download and rejects early on Content-Length; upload MIME list = intersect(filter result, get_allowed_mime_types()); SVG only if WordPress itself allows it for the user.
|
||||||
|
- Q6 (R3): lit/media-download is a DIRECT tool (embedded resource only works for direct tools); overview updated.
|
||||||
|
- Q7 (R4/C6): usage finder covers featured image, [gallery ids], Divi gallery_ids, block "ids"/"id", wp-image-<id> classes, custom_logo/site_icon, Divi image attrs, file-name URL matches in content, Pods/ACF meta ids; uncertain → "möglicherweise verwendet" (blocks delete without force); usages list only titles of posts the user can read (else "(nicht lesbar)").
|
||||||
|
- Q8 (R5): unused search caps work per call (small candidate batch, max N isUsed checks, then next_cursor) — no multi-hundred full scans per request.
|
||||||
|
- Q9 (R6): multisite: Access tab lists/revokes only users that belong to the current site; a non-super-admin cannot revoke super admins' connections.
|
||||||
|
- Q10 (R7): add Task 14 at the end: 1a deferred items (menu rev for menu-set-items, lit_mcp_guides filter per spec §9.2, warnings: trashing front page / menu-linked page, menu location unassign needs confirm, scheduling a private entry makes it public, divi hint wording; missedSchedule +60s window and zero-GMT fallback; write_failed wording mentions kept backup/revision).
|
||||||
|
- Q11 (R8): server instructions (dynamic) list the currently direct tools; hints use the neutral form "lit/<name>" plus "(direkt als lit-<name>, falls freigeschaltet, sonst über mcp-adapter-execute-ability)"; guides state that direct visibility depends on the settings.
|
||||||
|
- Q12 (C2): MediaGuard refuses trashed attachments unless opted in; delete of trashed attachment refuses (would be permanent).
|
||||||
|
- Q13 (C5): log tab links objects by object_type (post types → edit link, attachment → media, nav_menu → menus screen, settings → none).
|
||||||
|
- Q14 (C7): Site Health test async or transient-cached; (C8) readme wording per 1a (which tools are revertible how); (C9) tempnam in tests; (C10) media-update uses per-item post_modified_gmt as optimistic lock via optional `modified` in input — accepted if cheap, else ruling "no lock for media metadata" documented; (C11) no duplicate notice, refuse removing the current user's own role; (C12) add the client-name app-password test.
|
||||||
|
- Task 1: Ruling Q7b: usage search also covers widgets, header_image_data, background_image; uncovered (term/user meta, Pods tables, ACF, CSV id lists in meta, other plugins' options) documented and must be repeated in media-delete description (T5)
|
||||||
|
- Task 3: minor (deferred): failure branches (editor WP_Error, resize/save fail, missing file) untested; preview/download refuse trashed while media-get allows (intentional: trashed media not served)
|
||||||
|
- Task 4: accepted deviation: Content-Length only checked after capped download (Q5 "early" not feasible without HEAD)
|
||||||
|
- Task 4: minor (deferred): M1 filter keeps last sideload path (guard $moved === null); IPv4 pin only for cURL transport; DNS rebinding residual (core)
|
||||||
|
- Task 5: minor (deferred): docblock misplacement MediaUsageFinder constructor; guides must mention media tools (T13)
|
||||||
|
- Task 6: Ruling Q11b: remaining Q11 parts (hints naming direct-by-default tools written neutrally "lit/<name> (direkt: lit-<name>, falls freigeschaltet)", guides state that direct visibility is configurable) are carried into Task 10 (settings UI makes overrides possible) — cost if wrong: stale tool names in hints after an admin override
|
||||||
|
- Task 9: minor (deferred, final wave): nonce bind user+uuid; is_string guard for uuid; JS confirm on revoke; own-role warning only on change; cache_users before loop; heading "Verbindungen" (may include expired sessions)
|
||||||
|
- Task 10: Ruling Q11c: neutral tool naming applied to error hints only; ability descriptions/schemas keep default names (instructions list the current direct tools, so Claude can map) — cost if wrong: descriptions name a tool form that is not listed after an override
|
||||||
|
- Task 10: minor (deferred): ExposurePolicy::sanitize keeps unknown-but-wellformed ability names (dead data)
|
||||||
|
- Task 11: minor (deferred): filters()/extraFilters() split; date shape-only validation; JS-only clear confirm
|
||||||
|
- Task 12: minor (deferred): Uninstaller::removeSiteData public but internal
|
||||||
|
- Ruling Q15: Task 14 (deferred 1a items, brief written by controller) runs BEFORE Task 13 so docs/E2E cover it — cost if wrong: none
|
||||||
|
- Task 14: accepted: Errors::writeFailed ?$state + WriteFailed::$rolledBackState added (existing params couldn't replace the sentence)
|
||||||
|
- Task 14: minor (deferred, final wave): schedule test asserts 'öffentlich' substring (use 'ÖFFENTLICH'); numeric guide topics from filter dropped; Rev::ofMenu docblock; menu-get→set-items unit round trip; Pods textdomain notices noise check
|
||||||
|
- Task 13: minor (deferred): readme doesn't mention lit_mcp_upload_mimes filter
|
||||||
|
- Final review 1b: with fixes. Ruling Q16: fix wave = I1 download hard cap 10 MB + upload bound max 32 MB (tab label), I2 filename search via wp_allow_query_attachment_by_filename, M3 edit_user in manageable(), M4 mcp.public=false for DIRECT_ONLY, M5 wp_slash alt/title on upload, M6 neutral sideload error (log detail), M7 menu-assign-location destructive:true, M8 readme corrections (MEDIA_TRASH constant, site filters, super admins), M9 neutral hint in preview, deferred cheap: MediaUsageFinder docblock, Task 9 is_string uuid guard, Task 14 'ÖFFENTLICH' assertion, Rev::ofMenu docblock. Etappe-2 notes (requiresDirect flag, tree_rev, direct tool count) → Etappe 2 planning.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 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).
|
||||||
|
- 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_<key>` wird ohne `is_protected_meta`-Prüfung ausgegeben (`DynamicContentOptionPostMetaKey`). (4) Farbverweis-Namen gehen unescaped in `var(--<name>)` (`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.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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 <post_id> <name>
|
||||||
|
cd wp-content/plugins/WP-LIT-MCP && composer test
|
||||||
|
```
|
||||||
|
Erwartet: `Success: builder-<name>.html: <N> bytes, <N> blocks, globalModule: ja/nein, tree_rev <hash>.`,
|
||||||
|
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. Nur das globale Modul ist dabei „nur lesbar“ (siehe `divi-structure`-Guide);
|
||||||
|
ein Spezialabschnitt (specialty section) ist ein normaler, bearbeitbarer `divi/section`-Block –
|
||||||
|
Outline zeigt ihn lediglich mit dem Zusatz `[specialty]`, nicht als „nur lesbar“.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Etappe 2a – Entscheidungen (Rulings) und offene Kleinigkeiten
|
||||||
|
|
||||||
|
Aus dem SDD-Ledger übernommen, 2026-09-26. Weiterführende Rulings für 2b stehen im 2b-Ledger bzw. später in 2026-09-26-etappe-2b-rulings.md.
|
||||||
|
|
||||||
|
## Pre-flight rulings
|
||||||
|
- R1 (blocking B1/B2): kses bypass on Divi saves is only acceptable if NO client-supplied string reaches post_content unsanitized. `$variable(...)$` values from input (placeholders AND raw values echoed back from divi-node-get) are never written as-is: parse the JSON payload, validate strictly (type ∈ {color, content}; name ∈ existing gcid-*/gvid-*/known dynamic-content source names/custom_meta_<key> with key [a-zA-Z0-9_-]; settings keys whitelisted; before/after/other string settings sanitized with wp_kses_post for users without unfiltered_html, sanitize_text_field for plain keys), then RE-SERIALIZE by the plugin. Anything else → validation error. D7 text in 2a Task 1 (spec amendment + decisions note) must describe exactly this ("werden geparst, geprüft und vom Plugin neu erzeugt"), not "alle Werte bereinigt" in general. Implementation happens in 2b (Placeholders/AttrWriter/ShortformWriter/TextReplacer) — carried into 2b pre-flight; 2b adds tests with hand-crafted malicious $variable payloads (script in before/after, unknown type, bogus name) and a DDEV check as a user without unfiltered_html.
|
||||||
|
- R2 (B3 assumption): 2b verifies in Divi source whether dynamic-content before/after are escaped on output; regardless, R1's sanitization applies (defense in depth). Record the finding in the decisions note.
|
||||||
|
- R3 (risky, URL routing): 2b routes URL sanitization by the catalog's attribute metadata (field type url/link/image/upload) when available, and by key name (url|src|link|href|image) otherwise; background-image and link hrefs covered; tests.
|
||||||
|
- R4 (cosmetic): PostPresenter → DiviModules static dependency documented in a docblock (layering exception) — fine.
|
||||||
|
- R5: security-sensitive tasks (2a Task 2 BlockTree, 2b writers) get opus reviewers.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- Task 1: fix round 1/5 (4 addressed: §5.6 tree_rev, §10 cross-ref D7, §11.3, §14; commits 040a60f..c9b31a6) — Ruling: 4-line docs-only fix verified by controller directly against the diff instead of a re-review agent — cost if wrong: none (docs)
|
||||||
|
- Task 1: complete (commits 703fecb..c9b31a6, review clean)
|
||||||
|
- Task 2: minor (deferred, MUST fix in 2a final wave before 2b writers): Json top-level list-like object → opaque; Block::create validates block name pattern; setAttrs/insertChild/removeChild guard on opaque blocks; copy() deep-clones stdClass or doc narrowed; Json::encode invalid UTF-8 handling documented/caught
|
||||||
|
- Task 2: complete (commits c9b31a6..8222240, review clean; fuzzed 200k inputs, 0 diffs)
|
||||||
|
- Task 3: minor (deferred, 2a final wave): AttrMap set/remove reject empty path segments
|
||||||
|
- Task 3: complete (commits 8222240..08d70ab, review clean)
|
||||||
|
- Task 4: note for 2b pre-flight: only ~564/4358 attr paths get a metadata kind; unknown kind MUST default to the strictest sanitizer (sanitize_text_field; URL only by key-name match), never raw
|
||||||
|
- Task 4: fix round 1/5 (composite paths unknown, report example, flatten reuse, shortName; commits 2f4dcf9..02ae76d)
|
||||||
|
- Task 4: complete (commits 08d70ab..02ae76d, review clean)
|
||||||
|
- Task 5: minor (deferred): design-system response unbounded for sites with hundreds of presets (no filter/detail param)
|
||||||
|
- Task 5: complete (commits 02ae76d..a54a337, review clean)
|
||||||
|
- Task 6: minor (deferred; 2a final wave): outline unbounded breadth (add limit/truncated flag); node-get exposes globalModule id for global refs; node-get description notes $variable values are re-validated on write (R1); outline outputSchema required list
|
||||||
|
- Task 6: complete (commits a54a337..d27504a, review clean)
|
||||||
|
- Task 7: fix round 1/5 (Text::shorten shared, data URIs, lazy src, ob drain, globals, oEmbed note, text extraction, tests; commits 2e9acd6..b3cd0ee)
|
||||||
|
- Task 7: complete (commits d27504a..b3cd0ee, review clean)
|
||||||
|
- Task 8: minor (deferred): divi-attrs guide should mention catalog without module lists modules
|
||||||
|
- Task 8: complete (commits b3cd0ee..f433fef, review clean)
|
||||||
|
- Final review (opus): ready with fixes. Ruling F1: final fix wave = #1 per-subName path kinds (+guide), #2 opaque guards, #3 list-like top-level attrs → opaque, #4 Block::create name validation, #7 AttrMap empty segments + object-ness, #8 copy deep-clone, #9 Json::encode UTF-8 → documented/mapped, #10 Container::append @internal/touched, #11 node-get rev + type short name, #12 outline label normalize/shorten + id pattern, #13 docblock/abnahme text, node-get R1 description note, outline outputSchema required, divi-attrs guide catalog-without-module — spec §5.3 + load-bearing for 2b writers — cost if wrong: small rework
|
||||||
|
- Ruling F2: outline limit/truncated, design-system size/filter, globalModule id on node-get → moved to 2b pre-flight (write results embed outline) — cost if wrong: none, tracked
|
||||||
|
- Ruling F3: #5 (R1/R3/embedded $variable plan amendments, R2 finding: Divi kses on before/after only when author lacks unfiltered_html, OptionLoopPostMetaKey raw), #6 (real builder fixtures), #14 (ambiguous design-system labels) → blocking items for 2b pre-flight — cost if wrong: none
|
||||||
|
- Final fix wave: 11/11 addressed (commits 4137882..4356e69 after trailer normalization; 722 tests, smoke green); per-subName path kinds chosen (path_kinds values string|object); re-review running
|
||||||
|
- Re-review: 10/11 clean; residuals (Serializer docblock, /D anchors on NAME/ID patterns, number-advanced → unknown, doc wording 'ein oder mehrere') fixed by controller inline (c24b91d, 723 tests, lint ok). Parked: AttrMap::remove() doesn't traverse stdClass (harmless, no caller); children of opaque blocks editable via direct object access (unreachable: NodeIndex doesn't index below opaque)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Etappe 2b – Manuelle Abnahme (Divi schreiben)
|
||||||
|
|
||||||
|
Vorbereitet von Task 10 (Hinweise, Guides, Version 0.3.0, Ende-zu-Ende-Test). Automatisch geprüft sind
|
||||||
|
bereits:
|
||||||
|
|
||||||
|
- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
|
||||||
|
- die fünf Integrationsskripte `divi-write-smoke.sh`, `divi-read-smoke.sh`, `mcp-smoke.sh`,
|
||||||
|
`wp-basis-smoke.sh` und `media-admin-smoke.sh` (alle in `tests/Integration/`).
|
||||||
|
|
||||||
|
`divi-write-smoke.sh` prüft über den OAuth-Endpunkt:
|
||||||
|
|
||||||
|
- Einfügen, Ändern, Verschieben, Löschen und Duplizieren;
|
||||||
|
- Ersetzen mit Probelauf;
|
||||||
|
- `lit-content-create` mit Divi-Inhalt;
|
||||||
|
- den ersten Abschnitt auf einer leeren Seite;
|
||||||
|
- einen Redakteur ohne `unfiltered_html`: unberührte Bytes bleiben gleich, und es wird weder Skript,
|
||||||
|
Event-Handler, `javascript:` noch ein eingeschleustes Attribut gespeichert oder gerendert;
|
||||||
|
- Divis eigene Speicherfilter (Seite mit eigenen HTML-Attributen);
|
||||||
|
- keine Wege am Divi-Pfad vorbei: Rohtext-`content` mit Divi-Markup wird abgelehnt, und bei Divi
|
||||||
|
„nur lesen“ lehnt `lit-content-create` `divi` ab und `lit/divi-insert` ist auch über
|
||||||
|
`mcp-adapter-execute-ability` nicht erreichbar.
|
||||||
|
|
||||||
|
Details stehen im Report von Task 10 (`.superpowers/sdd/2026-09-26-etappe-2b-divi-schreiben/task-10-report.md`).
|
||||||
|
|
||||||
|
Die folgenden Punkte lassen sich nicht automatisch prüfen, weil sie den echten Divi Visual Builder und den
|
||||||
|
claude.ai-Connector brauchen. Jan-Niclas prüft sie selbst (Spec §1.3 Nr. 2, §13 „Bearbeitbarkeit im
|
||||||
|
Visual Builder“). Abweichungen als kurze Notiz zurückmelden; sie werden Folgeaufgaben (z. B. anders
|
||||||
|
gespeicherte Spaltenbreiten, D8).
|
||||||
|
|
||||||
|
## Vorbereitung: Testdaten des Probelaufs entfernen
|
||||||
|
|
||||||
|
Der Probelauf „Tischlerei Holzwerk Berger“ (trial-feedback.md) hat Daten auf der DDEV-Seite hinterlassen.
|
||||||
|
Vor der Abnahme von Hand zurücksetzen, sonst laufen §1–§3 auf einer veränderten Seite und das Testmenü
|
||||||
|
ist live. Nicht automatisch gelöscht – bitte selbst prüfen und ausführen
|
||||||
|
(`cd /home/janniclas/Projekte/WordPress && ddev wp …`):
|
||||||
|
|
||||||
|
- [ ] Website-Titel und Untertitel zurücksetzen:
|
||||||
|
`ddev wp option update blogname "Breva Map Dev"` und `ddev wp option update blogdescription ""`
|
||||||
|
(vorher leer).
|
||||||
|
- [ ] Die „[Test]“-Entwürfe löschen: Seiten 831, 834, 837, 839 und Beiträge 854, 856, 858
|
||||||
|
(vorher mit `ddev wp post list --post_type=page,post --post_status=draft --fields=ID,post_title`
|
||||||
|
prüfen, dass es genau diese sind), dann `ddev wp post delete 831 834 837 839 854 856 858 --force`.
|
||||||
|
- [ ] Das Menü „[Test] Hauptmenü“ (ID 22) aus der Position `primary-menu` nehmen und löschen:
|
||||||
|
`ddev wp menu location remove 22 primary-menu` und `ddev wp menu delete 22`; falls vorher ein
|
||||||
|
anderes Menü dort zugewiesen war, dieses wieder zuweisen (`ddev wp menu location assign <id>
|
||||||
|
primary-menu`).
|
||||||
|
- [ ] Die Platzhalter-Medien 828, 829, 830 löschen: `ddev wp post delete 828 829 830 --force`
|
||||||
|
(vorher in der Mediathek prüfen, dass sie nirgends mehr verwendet werden).
|
||||||
|
- [ ] Optional: Einträge des Probelaufs im Protokoll-Tab über „Protokoll leeren“ entfernen.
|
||||||
|
|
||||||
|
## 0. Verbindung, Zugriff, Tools- und Protokoll-Tab
|
||||||
|
|
||||||
|
- [ ] Discovery ohne Weiterleitung (Fix DiscoveryCanonical, bb85868):
|
||||||
|
`curl -si https://wordpress.ddev.site:8443/.well-known/oauth-protected-resource | head -1` und
|
||||||
|
`curl -si https://wordpress.ddev.site:8443/.well-known/oauth-authorization-server | head -1`
|
||||||
|
antworten beide mit `200` (kein `301` auf eine URL mit Schrägstrich) und JSON.
|
||||||
|
- [ ] Den Connector in claude.ai neu verbinden (Einstellungen → Connectors → LIT MCP → Verbinden):
|
||||||
|
Login, Zustimmung, danach sind die Tools in einer neuen Unterhaltung sichtbar.
|
||||||
|
- [ ] Mit einem Konto verbinden, dessen Rolle unter Zugriff **nicht** freigegeben ist (z. B. ein
|
||||||
|
Abonnent): die Autorisierung bricht mit „Dein Benutzer hat keinen Zugriff auf LIT MCP.“ ab; es
|
||||||
|
entsteht keine Verbindung (kein Anwendungspasswort im Profil).
|
||||||
|
- [ ] Einstellungen → LIT MCP → Zugriff: die Spalte „Zuletzt genutzt“ zeigt für die eben genutzte
|
||||||
|
Verbindung ein Datum (Aktualisierung höchstens einmal am Tag).
|
||||||
|
- [ ] Tools-Tab: je Gruppe Modus und „nur lesen“; die Warnung zur Zahl direkter Tools erscheint erst,
|
||||||
|
wenn mehr als 25 Tools direkt sichtbar sind (z. B. testweise eine Gruppe auf „direkt“ stellen,
|
||||||
|
danach zurücksetzen).
|
||||||
|
- [ ] Protokoll-Tab: Liste wie die WordPress-Listen (Filter nach Tool, Benutzer und Ergebnis links über
|
||||||
|
der Tabelle, Blättern, „Einträge pro Seite“ in den Ansichtsoptionen oben rechts); Links zu
|
||||||
|
Bearbeiten/Revision je Eintrag.
|
||||||
|
|
||||||
|
## 1. Visual Builder: von Claude gebaute Seite
|
||||||
|
|
||||||
|
- [ ] Dieser Auftrag in claude.ai:
|
||||||
|
„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.“
|
||||||
|
Erwartet:
|
||||||
|
- Claude liest vorher `lit-divi-design-system` und `lit-divi-catalog`;
|
||||||
|
- dann `lit-content-create` mit `divi` (oder eine leere Seite plus `lit-divi-insert`);
|
||||||
|
- die Antwort nennt `preview_url` und `edit_url` (bei `divi` der Link in den Visual Builder,
|
||||||
|
`…&et_fb=1`).
|
||||||
|
- [ ] Die Seite im Divi Visual Builder öffnen (`edit_url`) und prüfen:
|
||||||
|
- [ ] Alle Module erscheinen als **native Module**, nicht als Code-Modul.
|
||||||
|
- [ ] Die Admin-Labels folgen dem Muster „<Abschnitt> – <Modul>“, z. B. „Hero – Button“.
|
||||||
|
- [ ] Jedes Modul lässt sich anklicken, ändern und speichern, ohne Fehlermeldung des Builders.
|
||||||
|
- [ ] Die Spaltenbreiten stimmen (1/3 + 2/3), auch in der Spalteneinstellung des Builders.
|
||||||
|
- [ ] Farben aus `{{color:…}}` sind als **globale Farbe** verknüpft: der Farbwähler zeigt die globale
|
||||||
|
Farbe, nicht einen festen Hex-Wert.
|
||||||
|
- [ ] Das Button-Preset ist im Builder als Preset ausgewählt.
|
||||||
|
- [ ] Akkordeon-Einträge und Kontaktformular-Felder sind einzeln bearbeitbar.
|
||||||
|
- [ ] Eine zweite Seite nur mit `lit-divi-insert` aufbauen lassen: leere Seite anlegen, dann
|
||||||
|
„Füge einen Abschnitt ‚Start‘ mit Überschrift und Text ein“. Im Builder öffnen: der Abschnitt
|
||||||
|
erscheint normal und ist bearbeitbar (Ruling S9: Hülle `divi/placeholder` wie beim Builder).
|
||||||
|
|
||||||
|
## 2. Visual Builder: von Claude geänderte Seite
|
||||||
|
|
||||||
|
- [ ] Eine bestehende, im Builder gebaute Seite kopieren (`lit/post-duplicate`) und an der Kopie per
|
||||||
|
claude.ai einen Button-Text ändern, ein Modul verschieben und einen Abschnitt duplizieren lassen.
|
||||||
|
- [ ] Die Kopie im Builder öffnen und prüfen:
|
||||||
|
- [ ] Alle unveränderten Module sehen aus wie vorher (Einstellungen, Presets, globale Farben).
|
||||||
|
- [ ] Die geänderten Module sind bearbeitbar, und die Änderung ist sichtbar.
|
||||||
|
- [ ] Die Kopie eines Abschnitts heißt „<Label> (Kopie)“.
|
||||||
|
|
||||||
|
## 3. Zurück zu Claude nach einer Builder-Änderung
|
||||||
|
|
||||||
|
- [ ] Die Seite aus Schritt 1 im Builder ändern und speichern.
|
||||||
|
- [ ] Danach per claude.ai eine weitere Änderung verlangen. Erwartet:
|
||||||
|
- [ ] `rev_mismatch` erscheint nur, wenn Claude eine alte `tree_rev` benutzt.
|
||||||
|
- [ ] Nach erneutem Lesen (`lit-divi-outline`) gelingt die Änderung.
|
||||||
|
- [ ] Die im Builder gemachten Einstellungen bleiben erhalten.
|
||||||
|
|
||||||
|
## 4. Rückgängig
|
||||||
|
|
||||||
|
- [ ] Einen Abschnitt löschen lassen. Claude fragt nach (`confirm_required`) und löscht erst nach der
|
||||||
|
Bestätigung.
|
||||||
|
- [ ] Den Abschnitt über `lit/revision-restore` mit der gemeldeten `revision_id` zurückholen und im Builder
|
||||||
|
prüfen, dass er wieder vollständig da ist.
|
||||||
|
|
||||||
|
## 5. Text auf mehreren Seiten ersetzen
|
||||||
|
|
||||||
|
- [ ] „Ersetze auf allen Seiten ‚Termin‘ durch ‚Beratungstermin‘“. Erwartet:
|
||||||
|
- [ ] Claude zeigt erst den Probelauf (Treffer je Seite) und fragt nach.
|
||||||
|
- [ ] Erst danach ersetzt es.
|
||||||
|
- [ ] Im Protokoll-Tab (Einstellungen → LIT MCP → Protokoll) steht je geänderter Seite ein Eintrag mit
|
||||||
|
eigener Revision.
|
||||||
|
- [ ] Liegt ein Treffer in einem **globalen Modul**, ändert Claude ihn nicht, sondern nennt ihn
|
||||||
|
(`skipped_nodes`, „in der Divi-Bibliothek bearbeiten“).
|
||||||
|
|
||||||
|
## 6. Redakteur ohne `unfiltered_html`
|
||||||
|
|
||||||
|
- [ ] Mit einem Redakteurs-Konto verbinden; unter Einstellungen → LIT MCP → Zugriff muss die Rolle
|
||||||
|
freigegeben sein.
|
||||||
|
- [ ] Auf einer Seite eines Administrators einen Text ändern lassen.
|
||||||
|
- [ ] Die Seite im Builder öffnen. Erwartet:
|
||||||
|
- [ ] Eingebettete Inhalte des Administrators (z. B. iframe, eigenes HTML im Code-Modul) sind
|
||||||
|
unverändert.
|
||||||
|
- [ ] Die Änderung des Redakteurs ist da.
|
||||||
|
- [ ] Den Redakteur eigenes HTML/CSS/Attribute eines Moduls setzen lassen, z. B. „füge dem Button das
|
||||||
|
HTML-Attribut data-x hinzu“. Erwartet: Ablehnung mit „nur im Divi-Builder änderbar“.
|
||||||
|
|
||||||
|
## 7. Tool-Gruppen und „nur lesen“
|
||||||
|
|
||||||
|
- [ ] Unter Einstellungen → LIT MCP → Tools die Gruppe Divi auf „nur lesen“ stellen.
|
||||||
|
- [ ] Neu verbinden. Die Divi-Schreibwerkzeuge (`lit-divi-insert` usw.) fehlen; die Lesewerkzeuge sind
|
||||||
|
noch da.
|
||||||
|
- [ ] „Lege eine Entwurfsseite ‚Nur lesen‘ mit einem Abschnitt und einer Überschrift an“: `lit-content-create`
|
||||||
|
mit `divi` → `unsupported` mit Hinweis auf den Tools-Tab; ohne `divi` (einfacher Entwurf) gelingt
|
||||||
|
das Anlegen weiterhin. Dasselbe gilt, wenn nur `lit-divi-insert` deaktiviert oder die Gruppe Divi
|
||||||
|
ganz deaktiviert ist.
|
||||||
|
- [ ] `lit-content-create` bzw. `lit-content-update` mit Divi-Markup im Feld `content`
|
||||||
|
(`<!-- wp:divi/section -->…`) → `validation_failed` mit Hinweis auf `divi` bzw. die lit-divi-*-Tools
|
||||||
|
(unabhängig von „nur lesen“).
|
||||||
|
- [ ] Hinweis: `lit/post-duplicate`, `lit/post-replace-with-copy` und `lit/revision-restore` gehören zur
|
||||||
|
Gruppe WordPress und kopieren bzw. stellen gespeicherte Divi-Inhalte auch bei Divi „nur lesen“ her;
|
||||||
|
sie schreiben keine neuen Divi-Werte vom Client.
|
||||||
|
- [ ] Die Einstellung zurücksetzen.
|
||||||
|
|
||||||
|
## 8. Builder-Fixtures aus 2a (falls noch offen; Ruling S20)
|
||||||
|
|
||||||
|
- [ ] Seiten mit **globalem Modul**, Preset, Variablen und Dynamic Content exportieren:
|
||||||
|
`tests/Integration/divi-export-page.php` (Anleitung in `2026-09-26-etappe-2a-abnahme.md` §1).
|
||||||
|
`composer test` bleibt grün.
|
||||||
|
- [ ] Auf einer Kopie einer solchen Seite `lit-divi-update` an einem normalen Modul ausführen. Das
|
||||||
|
globale Modul bleibt byte-identisch: Export vorher und nachher vergleichen.
|
||||||
|
- [ ] Hat die Seite Module mit eigenen HTML-Attributen oder einem eigenen HTML-Element (Erweitert →
|
||||||
|
Attribute/HTML), meldet die Antwort evtl. „Inhalt beim Speichern verändert“. Das ist dokumentiert
|
||||||
|
(Ruling S7: Divi normalisiert solche Seiten selbst). Prüfen, dass die Seite im Builder danach
|
||||||
|
einwandfrei ist.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# WP-LIT-MCP Trial: „Tischlerei Holzwerk Berger“
|
||||||
|
|
||||||
|
Hands-on test of the LIT MCP server as a real MCP client would use it (`initialize` → `tools/list` →
|
||||||
|
`lit-guide` → build), against the local DDEV site `https://wordpress.ddev.site:8443` (Divi 5.2.1,
|
||||||
|
WordPress 7.1.2, Pods inactive). Site was "Breva Map Dev" before this trial. All calls went through
|
||||||
|
`tests/Integration/lib.sh`'s `connect`/`tool`/`ability` helpers, run inside the DDEV web container via
|
||||||
|
throwaway scripts under `.superpowers/trial/` (deleted at the end; git-excluded). Nothing published.
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
All titled with the `[Test] ` prefix, all left as drafts, as requested.
|
||||||
|
|
||||||
|
| Type | ID | Title | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Page | 839 | [Test] Startseite | Hero (heading+text+button+image), 3-Leistungen-Übersicht (blurbs), Kontakt-CTA |
|
||||||
|
| Page | 834 | [Test] Leistungen | Hero, 3 service blurbs, CTA section |
|
||||||
|
| Page | 837 | [Test] Über uns | Hero, Geschichte (image+text), 3 Werte (icon-lists; one duplicated to a 4th value) |
|
||||||
|
| Page | 831 | [Test] Kontakt | Hero, Adresse+Kontaktformular, Öffnungszeiten (added afterwards via `lit-divi-insert`) |
|
||||||
|
| Post | 854 | [Test] Drei Tipps zur Pflege von Massivholzmöbeln | |
|
||||||
|
| Post | 856 | [Test] Einblick in unsere Werkstatt | text+image |
|
||||||
|
| Post | 858 | [Test] Nachhaltigkeit beginnt beim Holz | |
|
||||||
|
| Menu | 22 | [Test] Hauptmenü | assigned to `primary-menu` location in the same `lit/menu-create` call; 4 items (Startseite, Leistungen, Über uns, Kontakt) via `lit/menu-set-items` with `allow_unpublished: true` |
|
||||||
|
| Media | 828, 829, 830 | Werkstatt / Team / Massivholzmöbel (Platzhalter) | imported via `lit-media-upload-from-url` from `picsum.photos` (public hotlink-friendly placeholder service — network egress from the container worked fine, no fallback to base64 needed) |
|
||||||
|
| Site settings | – | `blogname` → "Tischlerei Holzwerk Berger", `blogdescription` → "Massivholzmöbel, Innenausbau und Restaurierung aus Berlin" | previous values (`Breva Map Dev` / empty) are in the tool's own `previous` response field if this needs reverting |
|
||||||
|
|
||||||
|
Front page: **not set**. `lit/wp-settings-update` correctly refused `page_on_front: 839` with
|
||||||
|
`"839 ist keine veröffentlichte Seite."` — consistent with the server's own "Neue Website aufsetzen"
|
||||||
|
workflow guide, which says to publish the homepage first. That step is fundamentally at odds with
|
||||||
|
"don't publish anything," so I left `show_on_front` on `posts` and flagged it here instead of guessing
|
||||||
|
which instruction should win.
|
||||||
|
|
||||||
|
Edits demonstrated with the Divi write tools (all on top of the pages above): a heading text change
|
||||||
|
(`lit-divi-update`/`set`), a whole new section inserted (`lit-divi-insert`), a module moved across
|
||||||
|
columns (`lit-divi-move`), a node duplicated and re-labelled (`lit/divi-duplicate-node`), and a
|
||||||
|
site-wide text fix via `lit/divi-replace-text` with a dry run first, then a confirmed run (see friction
|
||||||
|
log for what it was replacing).
|
||||||
|
|
||||||
|
## Chronological friction log
|
||||||
|
|
||||||
|
1. **Guide reading is thorough but not consolidated.** `lit-guide` overview lists 8 further topics
|
||||||
|
(`content-types`, `divi-attrs`, `divi-design-system`, `divi-placeholders`, `divi-shortform`,
|
||||||
|
`divi-structure`, `media`, `workflows`); getting the full picture before writing anything took 8
|
||||||
|
separate `lit-guide` calls. The content itself is excellent (dense, example-driven, in German
|
||||||
|
matching the server's own error/hint language) — no complaints about quality, only that there's no
|
||||||
|
single "give me everything" call for a first-time client that wants to front-load context once
|
||||||
|
rather than discover topics interactively.
|
||||||
|
|
||||||
|
2. **Contact-form field `type` collision (real friction, not just reading fatigue).** The
|
||||||
|
`divi-shortform` field table lists `contact-field`'s shortform fields as `title, id, type, required`
|
||||||
|
— but a paragraph *below* the table says `type` can't actually be set this way at insert time because
|
||||||
|
it collides with the node-schema key `type` (the module type). I read the table, used `type: "email"`
|
||||||
|
in a contact-field item, and got:
|
||||||
|
`"unbekanntes Modul \"divi/input\""` / `"divi/contact-form erlaubt nur divi/contact-field als Kind"`.
|
||||||
|
The error is technically correct (it really did try to parse `type` as a child module name) but it
|
||||||
|
doesn't say *why* — a reader has no way to connect "unknown module divi/input" back to the
|
||||||
|
already-read caveat about reserved field names. Fixed by omitting `type` on insert and setting it
|
||||||
|
afterwards with `lit-divi-update`'s `set` (which *is* documented and worked first try). **Suggestion:**
|
||||||
|
detect this specific case (a `contact-field`/`social-media-follow-network` item with a `type`/`label`
|
||||||
|
key that isn't a valid child module name) and return a dedicated hint, e.g. "`type` on a contact
|
||||||
|
field can't be set at insert — omit it here and set it afterwards with lit-divi-update, or use attrs
|
||||||
|
(fieldItem.advanced.type)."
|
||||||
|
|
||||||
|
3. **A section can't mix `rows` and `modules`.** Tried a section with a top-level `modules` (for a
|
||||||
|
heading) *and* `rows` (for the 3-column blurbs) — got `"divi[1]": "entweder rows oder modules
|
||||||
|
angeben"`. Fully actionable, fixed in one edit by wrapping the heading in its own single-column row.
|
||||||
|
Minor cost (one extra round trip), good error.
|
||||||
|
|
||||||
|
4. **No shortform way to link one page to another by ID.** Draft pages get an **empty** `post_name`
|
||||||
|
until published (confirmed via direct DB read — this is normal WordPress behaviour for
|
||||||
|
draft/pending posts, not a plugin bug), so there's no stable pretty permalink to put in a button's
|
||||||
|
`url` field yet. The placeholder table (`divi-placeholders`) only offers `{{post:url}}` etc. for the
|
||||||
|
*currently displayed* post, not a way to point at an arbitrary other post/page by ID. I fell back to
|
||||||
|
raw `/?page_id=<ID>` query links for the CTA/blurb/menu targets. That works for an admin/preview
|
||||||
|
session but is fragile (changes once published) and dead for anonymous visitors before publish.
|
||||||
|
**Suggestion:** a placeholder like `{{link:<post_id>}}` (or reusing the `related`/attachment-ID idiom
|
||||||
|
already used for images) that resolves through `get_permalink()` at render/save time — draft-safe now,
|
||||||
|
automatically correct after publishing. This would remove the single biggest gap between the
|
||||||
|
shortform and how a real multi-page site actually gets built.
|
||||||
|
|
||||||
|
5. **`contact-form`'s own title has no shortform heading-level control.** After building the Kontakt
|
||||||
|
page, `lit-page-render-summary` flagged `"2 Überschriften der Ebene h1 im Inhalt."` — the
|
||||||
|
contact-form module renders its `title` as a hard-coded `h1`, and unlike `heading`/`blurb` (which
|
||||||
|
both expose a `level` shortform field), `contact-form` has none. Fixed by falling back to the full
|
||||||
|
form: `lit-divi-catalog` with `module: "divi/contact-form"` to find the path, then
|
||||||
|
`attrs: {"title.decoration.font.font": {"desktop": {"value": {"headingLevel": "h2"}}}}` — which
|
||||||
|
worked and cleared the warning. This was the one case where the shortform genuinely wasn't
|
||||||
|
expressive enough for a normal homepage pattern (a contact form title next to a page's own h1).
|
||||||
|
**Suggestion:** add `level` to `contact-form`'s shortform fields (and check other title-bearing
|
||||||
|
modules — e.g. `cta`, `map`, `slider` — for the same gap) so this doesn't require discovering the
|
||||||
|
full-form path by trial and error.
|
||||||
|
|
||||||
|
6. **`preview_url` uses `http://`, `edit_url` uses `https://`, in the same response — bug.** Every
|
||||||
|
mutating tool response on this (https-only, port-8443) site shows this mismatch, e.g. the Kontakt
|
||||||
|
page creation returned
|
||||||
|
`"preview_url":"http://wordpress.ddev.site:8443/?page_id=831&preview=true"` next to
|
||||||
|
`"edit_url":"https://wordpress.ddev.site:8443/wp-admin/post.php?post=831&action=edit"`. See Bugs
|
||||||
|
below for repro.
|
||||||
|
|
||||||
|
7. **Positive: error messages throughout were excellent.** `validation_failed` always came with a
|
||||||
|
`fields` map keyed by the exact dotted input path (e.g. `divi[1].rows[0].modules[1][0].items[0].type`);
|
||||||
|
`rev_mismatch`, `confirm_required`, and `unsupported` (tried to overwrite a Divi page's `content` via
|
||||||
|
`lit-content-update`) all had a `hint` that named the exact next tool to call, and following it
|
||||||
|
worked first try every time — no case where the hint was misleading or where I had to guess.
|
||||||
|
|
||||||
|
8. **Positive: `tree_rev` chaining removed round trips.** Every Divi write response carries the fresh
|
||||||
|
`tree_rev`, so a sequence of edits on the same page (change heading → move a module → insert a
|
||||||
|
section) never needed an extra `lit-divi-outline` read in between. This is the detail I'd worry the
|
||||||
|
most about regressing — it's what makes multi-step page building on Divi tolerable turn-count-wise.
|
||||||
|
|
||||||
|
9. **`lit/menu-create` accepting `location` in the same call was a nice shortcut** (no separate
|
||||||
|
`lit/menu-assign-location` round trip needed for a fresh menu). The one remaining unavoidable extra
|
||||||
|
round trip in the menu flow was calling `lit/menu-get` immediately after `lit/menu-create` just to
|
||||||
|
obtain `menu_rev` for the following `lit/menu-set-items` — `lit/menu-create`'s own response could
|
||||||
|
include the initial `menu_rev` and save that call.
|
||||||
|
|
||||||
|
10. **`lit-divi-catalog`'s result envelope (`{modules: [...], notes: [...]}`) isn't shown anywhere** in
|
||||||
|
the tool description or the `divi-attrs`/`divi-shortform` guides (which do embed inline JSON
|
||||||
|
examples for almost everything else). Self-inflicted (my first `jq` filter assumed a bare array and
|
||||||
|
errored), but a one-line example would remove the ambiguity the same way the other guides do.
|
||||||
|
|
||||||
|
## Prioritized improvement suggestions
|
||||||
|
|
||||||
|
1. **(High)** Add an internal-link placeholder for Divi shortform/full-form URL fields that resolves an
|
||||||
|
arbitrary post/page ID to its current permalink (draft-safe), e.g. `{{link:<post_id>}}`. This is the
|
||||||
|
most concrete gap: right now there is no supported way to cross-link draft pages that survives
|
||||||
|
publishing, and `/?page_id=N` is the only fallback.
|
||||||
|
2. **(High)** Give the `contact-form` module's shortform a `level` field (matching `heading`/`blurb`),
|
||||||
|
and audit other title-bearing modules (`cta`, `map`, `slider`, …) for the same missing control —
|
||||||
|
this is exactly the kind of heading-hierarchy mistake `lit-page-render-summary` is built to catch,
|
||||||
|
but the shortform currently can't fix it without discovering a full-form attrs path.
|
||||||
|
3. **(Medium)** Fix the `http://` vs `https://` scheme mismatch between `preview_url` and `edit_url` in
|
||||||
|
write-tool responses (see Bugs).
|
||||||
|
4. **(Medium)** When a shortform field name collides with a node-schema key (`type` on contact-field
|
||||||
|
items, `label` on social-media-follow-network items — both already called out in prose in the
|
||||||
|
`divi-shortform` guide), have the resulting `validation_failed` error say so directly instead of
|
||||||
|
surfacing as "unknown module divi/<field-value>". The current message is technically accurate but
|
||||||
|
doesn't point back to the documented workaround.
|
||||||
|
5. **(Low)** Let `lit/menu-create`'s response include the new menu's initial `menu_rev`, so a
|
||||||
|
first-time `lit/menu-set-items` doesn't need a preceding `lit/menu-get` purely to fetch it.
|
||||||
|
6. **(Low)** Show a one-line example of `lit-divi-catalog`'s result shape (module list vs. single-module
|
||||||
|
detail) in its tool description or the `divi-attrs` guide, consistent with how other tools' guides
|
||||||
|
already embed inline JSON examples.
|
||||||
|
7. **(Nice-to-have)** Consider a `lit-guide` mode that returns all topics concatenated in one call for
|
||||||
|
first-connection priming, alongside the existing per-topic calls (which remain useful for targeted
|
||||||
|
lookups later in a session).
|
||||||
|
|
||||||
|
## Bugs (with repro)
|
||||||
|
|
||||||
|
**Bug: `preview_url` scheme mismatch (`http://` vs. the site's actual `https://`).**
|
||||||
|
- Repro: on this DDEV site (`https://wordpress.ddev.site:8443`, HTTPS-only), call any tool that returns
|
||||||
|
both URLs for a draft, e.g. `lit-content-create` for a page, or any `lit-divi-*` write. Example
|
||||||
|
(Kontakt page, ID 831): response includes
|
||||||
|
`"preview_url":"http://wordpress.ddev.site:8443/?page_id=831&preview=true"` while the same response's
|
||||||
|
`"edit_url":"https://wordpress.ddev.site:8443/wp-admin/post.php?post=831&action=edit"` is correctly
|
||||||
|
`https`.
|
||||||
|
- Impact: a client that opens `preview_url` as given may hit a mixed-content warning, an unwanted
|
||||||
|
http→https redirect, or fail outright depending on the site's HSTS/redirect configuration.
|
||||||
|
|
||||||
|
## Divi shortform expressiveness verdict
|
||||||
|
|
||||||
|
For a typical small-business homepage (hero, services grid, testimonials/values, contact form,
|
||||||
|
CTA, blog teaser text) the shortform covered essentially everything needed — `section`/`row`/`column`
|
||||||
|
nesting, `heading`, `text`, `button`, `image`, `blurb`, `cta`, `icon-list`, `contact-form`, placeholders
|
||||||
|
for the design system's colors — with only two genuine full-form fallbacks in this whole build: the
|
||||||
|
contact-form title heading level (§5 above), and (not really a fallback, just a two-step shortform
|
||||||
|
sequence) setting a contact-field's `type` after insert instead of at insert time. Column-width
|
||||||
|
changes, presets, and `$variable` placeholders were never needed for this content but are documented
|
||||||
|
clearly enough that I'd expect them to work the same way.
|
||||||
|
|
||||||
|
## Cleanup performed
|
||||||
|
|
||||||
|
- All 36 throwaway `lit-mcp-integration-test` application passwords created by repeated `connect()`
|
||||||
|
calls during this trial were removed via `cleanup_app_passwords_for` (verified count 36 → 0).
|
||||||
|
- `.superpowers/trial/` (the scratch scripts used to drive this session) was deleted from the container.
|
||||||
|
- No plugin source code was modified. No existing content (the 4 pre-existing pages, 1 existing post) was
|
||||||
|
touched. Nothing was published.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Etappe 2b – Entscheidungen (Rulings), Befunde und offene Punkte
|
||||||
|
|
||||||
|
Aus dem SDD-Ledger übernommen, 2026-09-26. Security-Audit und Praxistest-Feedback liegen als eigene Notizen daneben (2026-09-26-etappe-2b-security-audit.md, 2026-09-26-etappe-2b-praxistest.md).
|
||||||
|
|
||||||
|
## Offen für spätere Etappen
|
||||||
|
- Etappe 3: Link-Platzhalter {{link:ID}} für interne Links (Praxistest), Guide „alle Themen“, S19 (Designsystem-Größe/Filter, globalModule-ID in node-get).
|
||||||
|
- Etappe U: DNS-Rebinding-Pinning beim URL-Import, Race zwischen tree_rev-Prüfung und Schreiben (L3), Build-Hygiene (Dev-Abhängigkeiten, Test-Namespace im Autoloader, installed.json), „alle Verbindungen trennen“, Sitzungen nach Deaktivierung.
|
||||||
|
- Manuell (Benutzer): Abnahme laut 2026-09-26-etappe-2b-abnahme.md inkl. Visual-Builder-Prüfung und Builder-Fixtures (S20); Testdaten der Versuchswebsite zurücksetzen („Vorbereitung“).
|
||||||
|
|
||||||
|
## Pre-flight rulings (adopted; override the plan text; full wording in preflight.md §6)
|
||||||
|
- Ruling S1–S17 adopted as proposed in preflight.md §6 — spec §5.6/§10/D7 + 2a R1–R3 binding; PoCs show stored XSS against plan code — cost if wrong: stricter input than needed (visible validation_failed, fixable)
|
||||||
|
- Ruling S3a: S3 routing must use the per-subName path kinds from the 2a final fix wave (composite innerContent: e.g. buttonOne.innerContent linkUrl → url, text → text; image alt → text). If the fix wave marked composite paths `unknown`, route sub-keys by key-name URL fallback, else text — 2a final review #1 — cost if wrong: javascript: in link sub-fields
|
||||||
|
- Ruling S13a: design-system label placeholders: ambiguous (duplicate) labels → validation_failed listing the ids; ids (gcid/gvid) accepted directly — 2a final review #14 — cost if wrong: wrong color silently applied
|
||||||
|
- Ruling S18: outline breadth limit/truncated (moved from 2a F2) goes into Task 4 (write results embed the outline) — cost if wrong: large outputs on big pages
|
||||||
|
- Ruling S19: design-system size/filter and globalModule id on node-get → Etappe 3/4 (library/design-system work) — cost if wrong: none
|
||||||
|
- Ruling S20: real builder fixtures (2a abnahme §1, globalModule) stay a manual user step before 2b ships; E2E gates on synthetic fixtures plus S7 fixture — cost if wrong: behaviour on real global modules only verified at acceptance
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- Note S3a: 2a fix wave chose per-subName maps — ModuleCatalog path_kinds values are string|object(subName→kind); AttrWriter routing must branch on that
|
||||||
|
- Ruling S15a: 2a final wave merged on branch (HEAD 395e964). DiviIntegration.php unchanged since f433fef (T6 whole-file replace OK). T10 must EDIT (not replace) DiviOutlineAbility.php, DiviCatalogAbility.php, docs/guides/divi-attrs.md (changed by fix wave). Plan code must respect new 2a guards: Block::create name pattern, LogicException on opaque setAttrs/insertChild/removeChild, AttrMap rejects empty segments, node-get type is short name + rev — cost if wrong: failing tests surface it in task
|
||||||
|
- Task 1: dispatched (opus implementer, BASE 395e964)
|
||||||
|
- Task 1: review (opus): spec ✅ except I-1; changes requested. Rulings:
|
||||||
|
- Ruling S21 (concern a): before/after/date_format settings are CLEANED and the sanitized value is re-serialized (not rejected when html()/text() changes them); enable_html, unknown keys, embedded refs and `)$` still rejected — D7 says "String-Settings bereinigt"; every other path cleans — cost if wrong: none security-wise (output is re-serialized from sanitized strings)
|
||||||
|
- Ruling S22 (concern b): VariableRef takes an injectable allow-map name → allowed settings (key → field type); Task 1 keeps the current fixed list as default; Task 5 wires Divi's live registry DynamicContentOptions::get_options($post_id,'edit') (filter divi_module_dynamic_content_options) into it, routing settings by field type; custom_meta_ rule, enable_html rejection, loop_* only inside loops stay — S1's fixed list breaks round-trips of valid Divi pages — cost if wrong: broader accepted settings, each still sanitized per type
|
||||||
|
- Ruling S23 (M-4): unknown kind on an attribute named `content` (`content.innerContent`) → richtext (html(), kses for editors) instead of text — Divi defines those fields only in JS settings; silent loss of formatting otherwise; html() is still kses-safe — cost if wrong: an editor could store kses-allowed tags in a field Divi renders as text (escaped/harmless)
|
||||||
|
- Ruling S24 (c): string keys below a scalar-kind path are treated as unknown so the URL key-name fallback applies; key-name fallback also tests the last path segment (…Url/…url suffix, e.g. redirectUrl) — don't rely on Divi output escaping — cost if wrong: a few legit URL-ish texts get esc_url_raw
|
||||||
|
- Ruling S25 (M-3): Placeholders::isVariable removed from src (or delegates to VariableRef); Task 2 must use VariableRef — cost if wrong: none
|
||||||
|
- Note for Task 8 (forward risk): replace-text must never run cleanValue on existing unchanged $variable strings; only changed plain-text segments are re-cleaned
|
||||||
|
- Task 1: fix round 1/5 dispatched: I-1, M-1, M-2 (anchor (\.|$) + css.advanced.customCss under S7 gate), S21–S25
|
||||||
|
- Task 1: fix round 1/5 (I-1, M-1, M-2, S21–S25 addressed; commit 7180690; 831 tests)
|
||||||
|
- Ruling S26: all free-form custom CSS paths (`*.css.*`, i.e. every module's css.advanced.* / customCss) go behind the S7 gate ("nur im Divi-Builder") for users without unfiltered_html — core requires edit_css (normally with unfiltered_html) for custom CSS; plain CSS injection (body{display:none}) otherwise possible — cost if wrong: editors can't set per-module custom CSS via MCP
|
||||||
|
- Ruling S27: global-variable and font references keep before/after only if Divi's registry/usage supports it; otherwise reject settings on gvid/font refs — implementer to verify in Divi source (DynamicData / GlobalData) — cost if wrong: harmless extra text around a variable
|
||||||
|
- Task 1: fix round 2/5 (S26, S27; commit eb99b36; 836 tests)
|
||||||
|
- Task 1: re-review (opus): all round-1/2 items ✅; NEW Critical-1 (sanitizer re-creates `$variable(` marker after the check: `$variable<x>(`, NUL, `%41` → unvalidated ref stored, stored XSS on admin-authored pages), Important-1 (S22 map bypasses custom_meta_ rule; post_meta_key meta_key/select_meta_key allow protected meta), Minor-1 (settings re-check after cleaning), Minor-2 (malformed map → TypeError)
|
||||||
|
- Ruling S28: invariant "checks run on the FINAL string": every sanitized output (cleanValue results and every cleaned setting) is re-checked; if it contains the $variable marker (and it did not come out of VariableRef::canonical as the whole value) → validation_failed. Test rows `<x>`, NUL, `%41` with a fixture sanitizer that strips tags/NUL/octets like WP — cost if wrong: none (reject is safe)
|
||||||
|
- Ruling S29: allow-map names starting `custom_meta_` obey the META_KEY rule; map values for meta-key settings (`meta_key`, `select_meta_key`, loop meta keys) are validated with the META_KEY rule (no leading `_`, after stripping `custom_meta_` prefix); map shape validated in constructor (InvalidArgumentException, never TypeError) — S12 must not widen via S22 — cost if wrong: none
|
||||||
|
- Task 1: fix round 3/5 dispatched (Critical-1, Important-1, Minor-1, Minor-2 via S28/S29)
|
||||||
|
- Task 1: fix round 3/5 (S28, S29, Minor-2; commit 665e603; 854 tests)
|
||||||
|
- Task 1: re-review round 3: Critical-1/Minor-1/Important-1/Minor-2 ✅; invariant held against esc_url_raw/nesting/keys/entities/multibyte; NEW Important-A (doubled custom_meta_ prefix → Divi str_replace strips all → `_edit_lock` readable, default map), Important-B (loop_*_meta_key_ prefixes not stripped), Minor-A (stale docblock VariableRef.php:88)
|
||||||
|
- Ruling S30: meta-key check applies Divi's own derivation before the META_KEY rule: str_replace of every `custom_meta_` occurrence (DynamicContentOptionPostMetaKey.php:232,275) and of `loop_post_meta_key_`/`loop_user_meta_key_`/`loop_term_meta_key_` (DynamicContentOptionLoopPostMetaKey.php:366-373); additionally reject if a prefix still occurs after one strip (defensive) — mirror Divi exactly — cost if wrong: none
|
||||||
|
- Task 1: fix round 4/5 dispatched (fresh opus implementer)
|
||||||
|
- Task 1: fix round 4/5 (S30; commit 4832ec1; 870 tests)
|
||||||
|
- Task 1: re-review round 4: clean. Note for Task 5 handoff + Divi upgrades: S29/S30 match meta-key settings by suffix `meta_key` — re-check on every Divi version bump
|
||||||
|
- Task 1: complete (commits 395e964..4832ec1, review clean after 4 fix rounds)
|
||||||
|
- Task 2: dispatched (opus implementer, BASE 4832ec1)
|
||||||
|
- Task 2: review (opus): spec ✅, quality Approved; 59 PoCs live, none stored script/handler/javascript:/CSS breakout
|
||||||
|
- Ruling S31 (T2 Important-1): plan text for T3 (ComposeFixture::shortform anchor) and T5 (DiviComposition ShortformWriter wiring) is stale → dispatches carry: ShortformWriter(Placeholders, MediaResolver, AttrWriter, ?array $modules=null); ONE Placeholders instance (with S22 live map) shared by AttrWriter and ShortformWriter; fixture shortform(bool $unfiltered=false, ?array $modules=null) — cost if wrong: TypeError at wiring (caught by tests)
|
||||||
|
- Ruling S32 (T2 Minor-1): control characters (C0 except \t\n\r, and DEL) are stripped in AttrWriter's final-string path BEFORE the marker check (single choke point), and the fixture text() mirrors WP (keeps NUL) so the test is honest — carried into Task 3 as carry-in fix — cost if wrong: none
|
||||||
|
- Ruling S33 (T2 Minor-2/3/5): carry-in to Task 3: cleanValue kind override may only tighten (reject weaker override than routed kind); attachment alt containing the marker is dropped (treated as empty alt), never canonicalized; color-reference check via a VariableRef type accessor instead of string prefix — cost if wrong: none
|
||||||
|
- Task 2: minor (parked): $modules test seam in ShortformWriter constructor (trusted wiring only); {{post:title}} accepted in URL fields (Task 1 canonical url mode) — reconsider in T10 docs
|
||||||
|
- Task 2: complete (commits 4832ec1..f86cd5b, review clean)
|
||||||
|
- Task 3: dispatched (opus implementer, BASE f86cd5b, carry-ins S32/S33)
|
||||||
|
- Task 3: implementer done (commit 4b72c2b; 1060 tests); note for T10 docs: short-form fields type (contact-field) and label (social network) clash with node keys → only via set/full form
|
||||||
|
- Task 3: review (opus): Critical-1 (S32 strip placed AFTER sanitizer: `<\x0Bimg onerror>` passes sanitize_text_field, strip then creates tag → stored XSS confirmed live); minors 1–5
|
||||||
|
- Ruling S32a: control-char strip runs on the RAW input BEFORE the sanitizer and before the raw marker check (cleanNode + VariableRef::setting); S28 final-string marker check stays after the sanitizer; nothing may transform the string after the sanitizer except rejection — cost if wrong: none
|
||||||
|
- Ruling S34 (T3 minors): VALUE_KEY gets /D; column widths checked with exact fraction arithmetic; StructureRules: allow divi/row inside divi/column (Divi 5 row nestable:true) but refuse plain modules in specialty columns (specialtyColumns set or row-inner children); preset ambiguity rule moved into DesignSystem::modulePresetId (shared) and Composer uses it; C1/LS/PS no change — cost if wrong: small
|
||||||
|
- Task 3: fix round 1/5 dispatched
|
||||||
|
- Task 3: fix round 1/5 (S32a, S34; commit 8688a02; 1070 tests); specialty columns refuse every child (conservative: specialty treated read-only)
|
||||||
|
- Task 3: re-review clean (Critical-1 fixed live; S34 ✅)
|
||||||
|
- Ruling S35 (T3 Minor-A): carry into Task 5: strip control chars once at entry of AttrWriter::apply and ShortformWriter::apply (before placeholder resolution / trim) so `{\0{post:title}}` can't bypass placeholder checks — cost if wrong: none
|
||||||
|
- Task 3: minor (parked, T10 cosmetic): nested row auto-label equals outer row label
|
||||||
|
- Task 3: complete (commits f86cd5b..8688a02, review clean)
|
||||||
|
- Task 4: dispatched (sonnet implementer, BASE 8688a02; S9, S14, S18)
|
||||||
|
- Task 4: minor (deferred, final wave): placeholder check duplicated as string literal (TreeEditor:68,146, NodeIndex:39,105) → Block::isPlaceholder(); move() StructureRules violation lacks a dedicated test
|
||||||
|
- Task 4: complete (commits 8688a02..ea1fd60, review clean)
|
||||||
|
- Task 5: dispatched (opus implementer, BASE ea1fd60; S4, S5, S7, S11, S22, S31, S35)
|
||||||
|
- Task 5: implementer done (commit 7762b76; 1114 tests; DDEV byte-identity as editor verified, cleanup done)
|
||||||
|
- Ruling S36 (T5 concerns): (1) loop_* options never accepted (stricter than Divi; tools can't know loop context) — accepted; (2) before/after cleaned as html (kses for editors) instead of text, to keep meaningful spaces — accepted, marker/meta checks still apply; (3) {{post:categories/tags}} filled into the map for pages although Divi's page registry omits them — accepted (S22 requires placeholders keep working) — cost if wrong: minor UX
|
||||||
|
- Note: Tasks 6–9 must obtain DiviComposition via DiviWriter::composition(DiviPage) (per-post live map)
|
||||||
|
- Task 5: review (opus): spec ✅, changes requested. I-1: RevisionPoint::capture runs before the bypass → restore-point revision kses-filtered for editors (live: iframe stripped, re-serialized) → undo path loses admin embeds
|
||||||
|
- Ruling S37: restore-point capture runs inside the same kses-off window (revision copies stored content, not client data); DDEV assertion revision === original; M-1 map field type url → url(), unknown/other types (not text/richtext/html/select/yes_no_button/select_post/number) rejected; M-3 select_post post_id numeric + current_user_can('read_post'); M-4 log getMessage(); M-5 comment documenting bypass window + hooks (no MCP input reaches them); M-7 DiviFlags also sets _et_pb_show_page_creation=off if Divi's own save does (verify); M-2 documented (suffix note exists); M-6 accepted (1a pattern) — cost if wrong: none
|
||||||
|
- Ruling S38 (for Task 6): a single update touching the same attribute path via both `set` (short form) and `attrs` (full form) is rejected with validation_failed (composite values are replaced whole per breakpoint; combining silently loses sub-keys) — cost if wrong: client must split into two changes
|
||||||
|
- Task 5: fix round 1/5 dispatched
|
||||||
|
- Task 5: fix round 1/5 (S37; commit b8cb018; 1117 tests; ValueSanitizer::canReadPost added)
|
||||||
|
- Task 5: re-review clean (I-1 live byte-identical revision; 162 core option/settings combos accepted, 0 refused; flags on/on/off)
|
||||||
|
- Task 5: minor (parked): select_post slugs no longer accepted (stricter than Divi; untouched refs unaffected); partial_write reported conservatively when rollback after capture failure fails
|
||||||
|
- Task 5: complete (commits ea1fd60..b8cb018, review clean)
|
||||||
|
- Ruling S39 (T6–T9 abilities): all-or-nothing is enforced at ability level (any validation error → nothing saved, all errors returned); abilities never call setAttrs/insertChild with client values except through Composer/AttrWriter/ShortformWriter/TreeEditor; composition only via DiviWriter::composition(DiviPage) — Task 5 review ⚠️ — cost if wrong: none
|
||||||
|
- Task 6: dispatched (opus implementer, BASE b8cb018; S7, S9, S14, S38, S39)
|
||||||
|
- Task 6: implementer done (commit 110beb7; 1128 tests)
|
||||||
|
- Ruling S40: S38 overlap rule also applies to insert/compose (Composer::module/compose nodes with set + attrs on the same path → error) — same silent-loss risk — cost if wrong: none
|
||||||
|
- Note T10: guide topic "divi-shortform" referenced by lit/divi-insert description must exist after T10 (check GuideRepository test)
|
||||||
|
- Task 6: S40 addendum (commit 22bb152; 1130 tests)
|
||||||
|
- Task 6: review (opus): spec ✅; live E2E ok (placeholder wrap, byte-exact update, conflict, perms); no structure bypass on live catalog. Important-1: admin label lost when attrs sets module.meta.adminLabel (auto label/label overwrites)
|
||||||
|
- Ruling S41: `label` counts as short-form path module.meta.adminLabel in overlaps() (compose + update); auto label skipped when attrs sets it; heading default level merged into font value instead of replaced; structure deny-list in AttrWriter (section module.advanced.type, column module.advanced.specialtyColumns) regardless of catalog; column flexType on update checked with Composer::widths rules — cost if wrong: none
|
||||||
|
- Task 6: fix round 1/5 dispatched
|
||||||
|
- Ruling S42 (user request 2026-09-26): after Task 10, before the final review: (1) clean-code review of the whole plugin (opus: file size/responsibilities, cross-stage duplication, readability of security logic, test helpers) → refactor wave (behaviour-preserving, suite green, security code only restructured); (2) THEN a dedicated whole-plugin security audit (opus, adversarial: OAuth/role gate/RequestContext, capabilities per object, kses bypass + all value paths, media import/SSRF, change log/admin UI (nonces, escaping), info leaks, uninstall) on the final code, with live DDEV PoCs; its findings go into the final fix wave together with the final review — user: "security check wäre am ende wichtig" — cost if wrong: extra review time
|
||||||
|
- Task 6: fix round 1/5 (S41; commit 909ac82; 1136 tests). Controller commit (user request, outside plan): settings page logo
|
||||||
|
- Task 6: re-review round 1: Important-1/Minor-1/Minor-2 ✅; NEW Important-A: row-width sum check refuses legit builder edits (none columns, flexWrap wrap, omitted flexType default 24_24)
|
||||||
|
- Ruling S43: on update check only the FORMAT of each new desktop flexType (the 32 builder values from field-library.js incl. `none`, or null removal); sum check only if the row's current widths already pass rowWidthsError (no none/omitted/desktop flexWrap wrap), otherwise accept; Minor-A: unknown-path error wins over overlap message for the same key; Minor-B: separate structure error text for columns; Minor-C (flexColumnStructure stale after width update) → Task 10 guide note — cost if wrong: a VB row can end up with odd widths (visible, fixable in builder)
|
||||||
|
- Task 6: fix round 2/5 dispatched
|
||||||
|
- Task 6: fix round 2/5 (S43 + grid rows skip sum; commit 0fddece; 1140 tests)
|
||||||
|
- Task 6: re-review round 2: clean
|
||||||
|
- Task 6: minor (deferred, final wave): drop 4_5 from insert SPECIAL_WIDTHS (builder list parity); format-check tablet/phone flexType via Composer::isColumnClass; T10 guide/hint: replacing desktop module.decoration.sizing drops flexType (send flexType along); flexColumnStructure stale after width update
|
||||||
|
- Task 6: complete (commits b8cb018..0fddece, review clean)
|
||||||
|
- Task 7: dispatched (sonnet implementer, BASE 0fddece)
|
||||||
|
- Task 7: implementer done (commit amended for trailer; only 1 new test for 3 abilities → test addendum requested before review)
|
||||||
|
- Task 7: test addendum (commit 1740254; 1153 tests; fixed DiviWriter::outline dropping outline for delete-only changes)
|
||||||
|
- Task 7: review (sonnet): spec ✅; live E2E byte-exact; Important-1 duplicate label built from NodeSummary::label (truncated/whitespace-collapsed display value) → use raw stored adminLabel; fix round 1/5 dispatched
|
||||||
|
- Task 7: fix round 1/5 (commit ede0221; 1155 tests) — Ruling: one-leaf fix verified by controller against the diff instead of a re-review agent — cost if wrong: none
|
||||||
|
- Task 7: complete (commits 0fddece..ede0221, review clean)
|
||||||
|
- Task 8: dispatched (opus implementer, BASE ede0221; S2, S6, S8, S10, S28, forward-risk no re-clean of unchanged)
|
||||||
|
- Ruling S44 (user request 2026-09-26, parallel): tool groups + inheritance + 'nur lesen' per group built in worktree .superpowers/worktrees/tools-gruppen (branch tools-gruppen, base ede0221) by a separate implementer; must not touch DiviIntegration/overview.md/readme; merged into etappe-2 after its review, before T10 (T10 adds the docs text); parallel implementers justified by user request + worktree isolation — cost if wrong: merge conflict in Plugin.php
|
||||||
|
- Task 8: implementer done (commit d67cd0e; 1222 tests); deviations: clean callback gets the replacement only (not whole string) + structural-equality guard (tags/entities/refs of result = original + inserted), AttrWriter::kindOf added, url/code fields never touched
|
||||||
|
- Env (user-authorized exception to plugin-dir rule, 2026-09-26): .ddev/nginx/well-known.conf added (^~ /.well-known/ → index.php), ddev restarted; discovery reachable, dotfiles still 403. Finding: WP redirect_canonical 301s /.well-known/oauth-* to trailing slash → plugin fix planned (redirect_canonical false for mcp_oauth_discovery)
|
||||||
|
- Task 8: review (opus, partial live): Critical: S8 split `<[^>]*>` fooled by `>` inside quoted attribute (title="A > B") → following attrs treated as text → replacement with quote+event handler stored & rendered (live stored XSS as editor); also rewrites href/src (D10). Important: opaque/global modules silently skipped (report claimed otherwise) → user thinks text gone site-wide
|
||||||
|
- Ruling S45: TextReplacer tokenizer must be quote-aware and cover comments: tag token `<(?:[^>"']|"[^"]*"|'[^']*')*>`, comments `<!--.*?-->` (s), unterminated `<…`/unbalanced quotes → string with hits refused; the structural guard compares FULL tag tokens (not just positions), so any change inside a tag is refused; independent final context assertion: every hit position lies in text context per a second scan (e.g. WP_HTML_Tag_Processor if available, else own state machine); malicious rows both quote styles + comment + unterminated tag, with mutation check; dry run + result list `skipped_nodes` [{post_id,id,reason:"globales Modul – in der Bibliothek bearbeiten"}] for opaque/global nodes containing the search; pages/blocks without effective change are not saved (no revision/log); tool description documents trimming of replacement in text fields — cost if wrong: none
|
||||||
|
- Task 8: fix round 1/5 dispatched
|
||||||
|
- Controller commit (user-requested OAuth local test): DiscoveryCanonical (no trailing-slash 301 on /.well-known/oauth-*), smoke /.well-known now a hard check (200, no redirect); local OAuth via Claude Code (CIMD) → lit-local ✔ Connected (user-scope MCP entry)
|
||||||
|
- Tool groups branch: review clean (sonnet); minors: comment why ToolsTab re-applies withGroups; ExposurePolicy::$groups docblock null vs [] → final wave. Merge into etappe-2 after Task 8 fix round commits
|
||||||
|
- Tool groups merged into etappe-2 (merge commit 3efcfaa; 1321 tests, lint ok); worktree + branch removed
|
||||||
|
- Task 8: fix round 1/5 (S45; commit 93d2bde; HtmlContext state machine; unquoted '<' ends tag candidate)
|
||||||
|
- Task 8: re-review agent stopped live testing (incomplete; read-only assessment positive); fresh opus re-reviewer dispatched with explicit defensive-test framing
|
||||||
|
- Task 8: re-review agents stopped live payload testing twice (classifier); Ruling S46: controller verified S45 directly — read-only DDEV script with real kses as editor, benign probe marker (data-probe attr): quoted '>' dq/sq, href-only hit, unquoted attr, comment, bare '<', textarea → all hits in text only, no attribute change; unterminated tag refused; 20k adversarial input refused in 1 ms; remaining cases covered by named unit tests (141 green: control chars/empty search, no-op no save, S10, cursor/limit, byte identity next to unknown dynamic content, skipped global nodes) — cost if wrong: residual edge case found in final security audit (S42)
|
||||||
|
- Task 8: complete (commits ede0221..93d2bde, review clean via S46)
|
||||||
|
- Parallel (user request): admin UI/UX pass dispatched (opus, brief .superpowers/sdd/admin-ux/brief.md; only src/Admin, assets, tests/Unit/Admin)
|
||||||
|
- Task 9: dispatched (opus implementer, BASE 3efcfaa; S9, S11, D7 single save path, S40/S41)
|
||||||
|
- Task 9: implementer done (commit 5965e32; 1339 tests; live DDEV ok); deviations: check/write instead of compose/afterInsert, DiviSaver::saveNew, combined errors; draft deleted if page map refuses
|
||||||
|
- Task 9: review (opus): spec ✅, Approved; live DDEV: valid create byte-exact, flags on/on/off, no script; refusal path leaves 0 rows
|
||||||
|
- Task 9: minor (deferred, final wave): discard() reports REASON_EXTRAS when the layout write fails (pass reason); DiviContentComposer::layout duplicates DiviInsertAbility compose+insert error mapping → shared helper; import order WordPressIntegrationTest
|
||||||
|
- Task 9: complete (commits 3efcfaa..5965e32, review clean)
|
||||||
|
- Task 10: waits for the admin UI agent (both touch Plugin.php/readme)
|
||||||
|
- Admin UI/UX pass committed (843f45a; 1364 tests); ConnectionUsage (last-use tracking via WP app-password record_application_password_usage, daily) added; review pending
|
||||||
|
- Task 10: dispatched (opus implementer, BASE 843f45a; S6 benign probes, S7 fixture+E2E §8, S15a, S16, divi-shortform guide, collected T10 notes, version 0.3.0, abnahme)
|
||||||
|
- Admin UI review (sonnet): spec ✅, changes requested: Art column maps destructive→"löscht" (wrong for replace/restore/menus/replace-text/trash); ConnectionUsage gates on is_wp_error but real 401s are WP_REST_Response
|
||||||
|
- Ruling S47: Art labels: explicit deletes (lit/media-delete, lit/divi-delete) → „löscht“; lit/post-trash → „Papierkorb“; any other destructive → „überschreibt“; non-readonly → „schreibt“; readonly → „liest“ (icons accordingly); ConnectionUsage records only when response status < 400 (plus existing sub===user check), test with WP_REST_Response(401); LogListTable default result → translated fallback — cost if wrong: none
|
||||||
|
- Admin UI fix round 1 (S47; b044cac; 1380 tests) verified by controller against the diff — admin UI pass complete
|
||||||
|
- Task 10: implementer committed 10c98cd then hit session limit during final checks; controller re-ran suite (1380) + lint ok; D11 superseding note added (e0ab6f5); smokes running
|
||||||
|
- Task 10: all five DDEV smokes pass (divi-write, mcp, wp-basis, media-admin, divi-read); review running
|
||||||
|
- Task 10: review (sonnet): spec ✅; Important: S7 exception text overstated (only pages not yet in Divi's serialize_blocks form) → fixed by controller in guides×3, readme, spec §1.3/§5.2, decisions note (e005d83)
|
||||||
|
- Task 10: complete (commits 843f45a..e005d83, review clean)
|
||||||
|
- S42 step 1: whole-plugin clean-code review dispatched (opus)
|
||||||
|
- S42 step 1 done: cleancode-plan.md (22 items + T1–T5). Ruling S48: refactor in 3 sequential waves following the plan's commit order — W1 foundation (M6, L4, H6, M5, M7, T1, T2, T3), W2 security restructure (H2, H3, H1, L1, M1, M4 + VariableRef META_KEY/NUMBER /D fix as a deliberate behaviour fix with tests), W3 rest (H5, H4, M3, M2, T5, M8, M9, L2, L3, L5 + all ledger "deferred, final wave" minors); behaviour-preserving except named fixes; every wave: full suite + lint + all 5 DDEV smokes; W2 gets an opus review of the check order diff — cost if wrong: refactor regressions (caught by 1380 tests + smokes)
|
||||||
|
- Ruling S49 (ledger items needing a decision): drop 4_5 from insert widths (builder parity) and format-check tablet/phone flexType with the builder list — both in W3 — cost if wrong: a client can't use 4_5 (not offered by the builder anyway)
|
||||||
|
- W1 dispatched (opus)
|
||||||
|
- W1 done: b6984d3..60290c8 (M6, L4, H6, M5, M7, T1, T2, T3); 1409 tests/6838 assertions; 5 smokes pass; report refactor-w1-report.md
|
||||||
|
- Ruling S50: W1 findings → W3: MenuWriter::fail guard the new-item delete (rollback continues, partial_write instead of internal_error; test); content-create layout-write failure passes its own reason to NewDraft::discard (T9 minor) — cost if wrong: none
|
||||||
|
- W2 dispatched (opus)
|
||||||
|
- W2 done: e420145 (/D fix: VariableRef META_KEY/NUMBER/WHOLE, Cursor, GuideRepository, ExposurePolicy×2, LogTab×2), 874a92c H2, 203d14a H3, 62ce99e H1, 570c1e6 L1, 3eeb7ee M1, 6cf0a56 M4; 1421 tests; malicious providers byte-identical (sha256); 5 smokes pass; ValueKind enum phpcs compat sniff disabled locally
|
||||||
|
- W2 opus review dispatched (check-order diff); W3 dispatched in parallel (disjoint scope; W2 review fixes applied afterwards)
|
||||||
|
- W2 review (opus): Approved, no Critical/Important. Differential harness old vs new tree: 4,383 probes over 6 paths; 4,266 byte-identical, 117 differ only by trailing "\n" (accept→reject or earlier reject); 0 reject→accept
|
||||||
|
- W2 minors → final wave: Composer gets the cleaner from AttrWriter or assertSame(cleaner) in DiviCompositionTest (S31 identity); DynamicContentRules↔VariableRef static error factories to a small shared place; HtmlTokens RAW_OPEN built from RAW_ELEMENTS; DiviSaver docblock (in W3)
|
||||||
|
- W3 done: 9da236b..3a5f658 (16 commits; H5, H4, M3, M2, T5, M8, M9, L2, L3, L5, T4 + S49/S50 behaviour fixes + ledger minors); 1449 tests; T5 partial_write wording kept (not misleading)
|
||||||
|
- Controller: smoke guard (finish/guard_exit — exit 0 without reaching the end fails) after W3 reported a silent mcp-smoke stop with exit 0; all 5 smokes pass (314b3d0)
|
||||||
|
- S42 step 2: whole-plugin security audit dispatched (opus)
|
||||||
|
- Parallel (user request): trial-website agent (sonnet) builds '[Test] …' drafts via the MCP endpoint (lib.sh JSON-RPC) and collects interface feedback → trial-feedback.md; its findings feed the final fix wave or a follow-up plan
|
||||||
|
- Trial site (sonnet via MCP): 4 Divi pages (839/834/837/831), 3 posts, menu 22 on primary-menu, 3 images; site title/tagline changed to „Tischlerei Holzwerk Berger“ (user informed). Feedback trial-feedback.md: High: internal-link placeholder by post ID ({{link:ID}}); contact-form title level in short form; Medium: preview_url http vs edit_url https (bug); shortform key collision → clearer error; Low: menu-create returns menu_rev; catalog result shape example; guide all-topics mode. → final fix wave (bug + error hint + level + menu_rev + docs); link placeholder = small feature (ruling at fix wave)
|
||||||
|
- Controller: Tools tab without code-module note, counter only as warning > 25 (22c4cc1)
|
||||||
|
- S42 step 2 done: security audit (opus) — no Critical/High; Medium: M1 raw `content` bypasses S7/S26 builder-only gates (content-create, content-update on non-Divi page; live-verified); M2 'nur lesen'/disabled Divi group doesn't stop content-create divi; M3 URL import SSRF residue (DNS rebinding, error oracle, WP 6.9 ranges); M4 Pods field restrictions ignored via PHP API; Low: L1 any logged-in user completes OAuth, L2 build hygiene (dev deps loaded, tests namespace in autoload, path disclosure, installed.json), L3 check-then-write race, L4 sessions survive deactivate; Info items → security-audit.md
|
||||||
|
- Final whole-branch review (opus) dispatched: 395e964..HEAD
|
||||||
|
- Protokoll redesign committed (0f23192; 1464 tests): columns Änderung|Objekt|Wer|Zeit, non-ok rows marked, grouped tool filter, screen options; controller removed 2 audit-probe log rows (users 66/67)
|
||||||
|
- Final review (opus): ready with fixes; §5 invariants hold end to end; must: audit M1, M2, I-1 (content-create edit_url → builder), I-2 (abnahme note); triage written to final-review.md
|
||||||
|
- Ruling S51: one final fix wave per final-fix-brief.md (must 1–4 + cheap 5–15); deferred list per triage (link placeholder → Etappe 3, DNS pinning/L3/build hygiene → Etappe U) — cost if wrong: none
|
||||||
|
- Final fix wave done: 7d0837b..63093be (14 commits; 1522 tests; 5 smokes incl. new §9 execute-ability path); deviations: item 6 only contact-field type, item 9 not reproducible over https (artifact), item 12 AuthorizeGate before mcp-oauth handler, m-9 deferred; scoped re-review (opus) dispatched
|
||||||
|
- Final fix re-review (opus): 14/15 ✅ (M2, M3 IP tricks 25 probes, M4, AuthorizeGate live, ABSPATH 182 files); ❌ M1 NUL-byte bypass (check before kses' wp_kses_no_null; live-stored divi/text as Author); minors: MediaImporter logs full URL incl. userinfo/query; uninstall docblock; media-update description can hold Divi block markup
|
||||||
|
- Ruling S52: M1 check runs on the content normalized exactly like storage will see it — strip NUL/C0 controls (wp_kses_no_null + ControlChars) BEFORE the Divi-markup check, and refuse if the normalized form contains Divi markup; same guard for every raw-content path incl. lit-media-update description; tests with \0 and other control variants inside the delimiter and shortcode; MediaImporter logs URL without userinfo/query; uninstall docblock — cost if wrong: none
|
||||||
|
- Controller: admin UI neutral wording (no claude.ai branding; 'KI-Assistent'/MCP-Server (Connector)) per user request — docs/readme still mention claude.ai as the primary client
|
||||||
|
- S52 fix committed by implementer before session limit (99eda25: DiviMarkup shared check raw+normalized, media-update description, MediaImporter log redaction, uninstall docblock); controller: 1597 tests, lint ok, smokes running
|
||||||
|
- S52 verified by controller live (NUL/VT/backslash-0/shortcode variants refused, plain block markup allowed); 1597 tests; 5 smokes pass — Etappe 2b final wave complete
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
# WP-LIT-MCP: whole-plugin security audit (S42 step 2)
|
||||||
|
|
||||||
|
- **Scope:** branch etappe-2, HEAD 314b3d0.
|
||||||
|
- **Environment:** WordPress 7.1.2, Divi 5.2.1 on DDEV.
|
||||||
|
- **Method:**
|
||||||
|
- Read all of `src/`, plus the relevant parts of `vendor/wp-media/mcp-oauth` and `vendor/wordpress/mcp-adapter`.
|
||||||
|
- Ran read-only DDEV `wp eval-file` probes.
|
||||||
|
- Ran one live, self-cleaning run of the real `lit/content-create` ability as a temporary Author:
|
||||||
|
- user 67 and post 861 were created, then deleted;
|
||||||
|
- the deletion was verified afterwards: no `lit_audit_*` user and no probe post remain.
|
||||||
|
- No code was changed. The option `lit_mcp_allowed_roles` was widened in-process only, through the filter, never in the database.
|
||||||
|
|
||||||
|
No Critical or High findings. The Divi value pipeline itself held.
|
||||||
|
The two most relevant problems are ways *around* it:
|
||||||
|
- the raw `content` field;
|
||||||
|
- the tool-group switch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings (most severe first)
|
||||||
|
|
||||||
|
### M1 – Medium: raw `content` lets users without unfiltered_html write the "builder-only" Divi values (the S7/S26 gates don't apply)
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/WordPress/Content/PostWriter.php:28` (content → post_content) and `:58`.
|
||||||
|
- `ContentCreateAbility.php:127-133`.
|
||||||
|
- `ContentUpdateAbility.php:124` refuses `content` only when the post *already* uses Divi. Nothing refuses content that *introduces* Divi markup.
|
||||||
|
- Compare `src/Integrations/Divi/Compose/ValueCleaner.php:57` (`BUILDER_ONLY`).
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. An Author, or an Editor on multisite or with `DISALLOW_UNFILTERED_HTML`, calls `lit/content-create` (or `lit/content-update` on a non-Divi post).
|
||||||
|
2. The `content` is Divi 5 block markup that sets:
|
||||||
|
- a module's `css.desktop.value.freeForm` (e.g. `body{…}`);
|
||||||
|
- `module.decoration.attributes` (custom HTML attributes).
|
||||||
|
3. kses (`filter_block_content`) and Divi's own `wp_insert_post_data` security filters keep both. Only `on*` attributes are stripped, by Divi.
|
||||||
|
4. The page is now a Divi layout. From then on every `lit-divi-*` tool works on it inside the kses window.
|
||||||
|
|
||||||
|
The same values are refused with "nur im Divi-Builder" (only in the Divi builder) when sent through `lit-divi-insert`/`-update` (rulings S7/S26).
|
||||||
|
|
||||||
|
A prompt-injected Claude session can therefore do CSS injection or attribute injection on the live site. This is exactly what S26 was meant to prevent.
|
||||||
|
|
||||||
|
**Evidence**
|
||||||
|
- Live run of `wp_get_ability('lit/content-create')->execute()` as a temporary Author (unfiltered_html=false):
|
||||||
|
- `freeForm CSS stored: true`;
|
||||||
|
- `data-probe stored: true`;
|
||||||
|
- rendered `<div class="et_pb_text_0 … et_pb_module et_flex_module" data-probe="1">`.
|
||||||
|
- Read-only kses probe (`kses6.php`): `onmouseover` is removed by Divi and never rendered. No script execution found. Divi renders a `javascript:` `linkUrl` as `href=""`.
|
||||||
|
|
||||||
|
**Parity note**
|
||||||
|
The same user can store the same markup through core REST (`/wp/v2/posts`) or the code editor. So this is no privilege escalation beyond WordPress core. It is a bypass of the plugin's own, explicitly decided gate. The pipeline guarantee "every path from ability input to post_content goes through ValueCleaner/Composer/TreeEditor" does not hold for `content`.
|
||||||
|
|
||||||
|
**Fix proposal**
|
||||||
|
- In `PostWriter::prepare()` (create and update), refuse a `content` that contains `<!-- wp:divi/` (and `[et_pb_`), with a hint to use `divi` / the Divi tools.
|
||||||
|
- Alternative: parse it with `Parser` and run every Divi block through `Composer`/`ValueCleaner` (heavier).
|
||||||
|
- At minimum, do this for users without `unfiltered_html`.
|
||||||
|
|
||||||
|
### M2 – Medium: "nur lesen" / disabled Divi group does not stop Divi writes (content-create `divi`)
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/WordPress/WordPressIntegration.php:83` passes `$this->divi` (the ContentComposer) unconditionally.
|
||||||
|
- `ContentCreateAbility.php:113-119, 145` write the Divi layout whenever Divi 5 is loaded.
|
||||||
|
- `ExposurePolicy::disabledByReadonly()` and `enabled()` only look at the ability's own group ("wordpress").
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
1. The admin sets group Divi to "nur lesen", or disables `lit-divi-insert`, or disables the whole group.
|
||||||
|
2. The tools tab, readme ("schaltet alle schreibenden Tools einer Gruppe … ab", switches off all writing tools of a group) and acceptance note §7 all promise that no Divi writes happen.
|
||||||
|
3. Claude can still create complete Divi layouts via `lit-content-create` with `divi: [...]`. That goes through the same kses window.
|
||||||
|
|
||||||
|
Related cross-group writes that still work with Divi "nur lesen":
|
||||||
|
- `lit/post-duplicate`, `lit/post-replace-with-copy`, `lit/revision-restore` on Divi pages. These use stored content, so the impact is lower.
|
||||||
|
|
||||||
|
**Evidence:** by reading. The wiring is unconditional; ExposurePolicy has no cross-group notion.
|
||||||
|
|
||||||
|
**Fix proposal**
|
||||||
|
- Give `ContentCreateAbility` a callable `diviAllowed()` that is true only when `ExposurePolicy::mode()` of `lit/divi-insert` is not `disabled`. That mode already includes the Divi group's read-only switch.
|
||||||
|
- If Divi writes are not allowed, answer `divi` with `unsupported`, and drop the `divi` property from the input schema.
|
||||||
|
- Document in the Tools tab that duplicate/replace/restore copy stored Divi content regardless of the Divi group.
|
||||||
|
|
||||||
|
### M3 – Medium: URL import SSRF – DNS rebinding TOCTOU, core-version-dependent blocklist, error oracle
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Media/MediaImporter.php:55-62` (`wp_http_validate_url` pre-check).
|
||||||
|
- `:119-139` (`wp_safe_remote_get` + `CURLOPT_IPRESOLVE_V4`).
|
||||||
|
- `:142` / `:339-346` (the raw `WP_Error` message is returned).
|
||||||
|
|
||||||
|
**Scenario 1: DNS rebinding**
|
||||||
|
- A prompt-injected Claude (e.g. a web page Claude read says "import this image") calls `lit-media-upload-from-url` with a hostname.
|
||||||
|
- `wp_http_validate_url()` resolves the host with `gethostbyname()` and sees a public IP. cURL then resolves again; with TTL 0 it gets 127.0.0.1 or another internal host.
|
||||||
|
- Redirects are re-validated by core (`WP_Http::validate_redirects`, verified in 7.1.2), but each redirect has the same TOCTOU gap.
|
||||||
|
- Impact:
|
||||||
|
- blind GET requests to internal services on ports 80/443/8080;
|
||||||
|
- internal *images/PDFs* (e.g. internal dashboards rendering PNG) get copied into the public media library.
|
||||||
|
|
||||||
|
**Scenario 2: version-dependent blocklist**
|
||||||
|
- On DDEV (core 7.1.2), `wp_http_validate_url()` blocks 169.254/16, 100.64/10, TEST-NETs, multicast etc. Verified: `ssrf1.php`, all 17 internal probes return false.
|
||||||
|
- The plugin declares "Requires at least: 6.9". Older cores only block 0/8, 10/8, 127/8, 172.16/12 and 192.168/16.
|
||||||
|
- If 6.9 lacks the newer ranges, `http://169.254.169.254/` (cloud metadata) is reachable. Not verified on 6.9.
|
||||||
|
|
||||||
|
**Scenario 3: error oracle**
|
||||||
|
- `Download fehlgeschlagen: <cURL/WP message>` and `HTTP-Status %d` let the caller tell apart:
|
||||||
|
- refused;
|
||||||
|
- timeout;
|
||||||
|
- 404;
|
||||||
|
- "wrong file type".
|
||||||
|
|
||||||
|
**Evidence:** by reading, plus the DDEV validation probe.
|
||||||
|
|
||||||
|
**Fix proposal**
|
||||||
|
- In the existing `http_api_curl` hook:
|
||||||
|
- resolve the host once;
|
||||||
|
- check the IP with the plugin's own full private/reserved list (`filter_var(..., FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)`, plus 100.64/10);
|
||||||
|
- pin it with `CURLOPT_RESOLVE` (`host:port:ip`).
|
||||||
|
- Set `'redirection' => 0` and follow at most 3 redirects manually, applying the same resolve, check and pin to each.
|
||||||
|
- Return a generic "Download fehlgeschlagen" message and log the detail.
|
||||||
|
|
||||||
|
### M4 – Medium (only when Pods field restrictions are used): Pods field access restrictions are ignored
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `src/Integrations/Pods/PodsFieldSource.php:73-114` (read/write through `pods()->field()` and `pods()->save()`).
|
||||||
|
- `PostWriter.php:226` (`META_SOURCES` excludes pods, so there is no per-field capability check).
|
||||||
|
- `PostPresenter::isHiddenField()` only hides the `password` type.
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
- Pods fields can be restricted per field:
|
||||||
|
- `admin_only`;
|
||||||
|
- `restrict_role`;
|
||||||
|
- `restrict_capability`;
|
||||||
|
- `read_only`;
|
||||||
|
- `hidden`.
|
||||||
|
- Pods enforces these only in its forms (`PodsForm::permission` → `pods_permission`).
|
||||||
|
- Its PHP API does not: `PodsAPI::save_pod_item` defaults `from = 'save'`, and `'save' === $params->from` skips `pods_permission` (pods/classes/PodsAPI.php:4868, 4994).
|
||||||
|
- An Editor or Author with `edit_post` on the entry can therefore read (`lit-content-get`, `lit-content-query` field filter) and write (`lit-content-update/create`) fields the site owner restricted to admins or made read-only.
|
||||||
|
|
||||||
|
**Evidence:** by reading the plugin and the Pods source in `wp-content/plugins/pods`. Pods is not active on DDEV, so there was no live test.
|
||||||
|
|
||||||
|
**Fix proposal**
|
||||||
|
- In `PodsFieldSource::definition()`:
|
||||||
|
- mark fields `READONLY` when `pods_has_permissions($field) && ! pods_permission($field)` or when `read_only` is set;
|
||||||
|
- hide them (as `isHiddenField` does) when `hidden` is set or permission fails without `read_only_restricted`.
|
||||||
|
- Re-check in `write()` before `save()`.
|
||||||
|
|
||||||
|
### L1 – Low: any logged-in user can complete OAuth and get a connection (application password + tokens)
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- The vendor flow (`mcp-oauth` `AuthorizeEndpoint`/`ConsentEndpoint`: only `is_user_logged_in()`).
|
||||||
|
- The plugin adds no gate: `Plugin.php:110` boots it as-is.
|
||||||
|
- The `lit_mcp_use` gate only applies at tool execution (`AbilityRegistrar.php:56-63`) and tool listing (`VisibilityFilter`).
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
- On sites with customer or subscriber accounts (WooCommerce, memberships), any customer can connect Claude.
|
||||||
|
- mcp-oauth then creates an application password for them, even where core would not offer app passwords to that user.
|
||||||
|
- It also issues access and refresh tokens, and mcp-adapter sessions in user meta.
|
||||||
|
- The customers can't run tools. But the connections appear in "Zugriff", and the site accumulates credentials and sessions for users who were never meant to have them.
|
||||||
|
|
||||||
|
**Evidence:** by reading.
|
||||||
|
|
||||||
|
**Fix proposal:** before mcp-oauth's authorize/consent handler, refuse with a clear message when `! current_user_can('lit_mcp_use')`. A hook such as `template_redirect` on its rewrite query var would work, and so would a filter if mcp-oauth offers one.
|
||||||
|
|
||||||
|
### L2 – Low: release/build hygiene – dev vendor, tests namespace, direct file access
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `vendor/` (dev install).
|
||||||
|
- `composer.json` `autoload-dev`.
|
||||||
|
- All `src/**/*.php` (no `ABSPATH` guard).
|
||||||
|
- `tests/Integration/divi-convert-fixture.php` (the only integration script without a `WP_CLI` guard).
|
||||||
|
- `.gitattributes` (only relevant if the zip is made with `git archive`, and `vendor/` is git-ignored).
|
||||||
|
|
||||||
|
**Scenario and evidence**
|
||||||
|
|
||||||
|
1. **Dev vendor loaded at runtime.** The current tree's Jetpack autoloader loads these files on every WP request:
|
||||||
|
- `mockery/library/helpers.php`;
|
||||||
|
- `Mockery.php`;
|
||||||
|
- `brain/monkey/inc/api.php`;
|
||||||
|
- `phpunit/.../Assert/Functions.php`.
|
||||||
|
|
||||||
|
Source: `vendor/composer/jetpack_autoload_filemap.php`. No release script exists (spec §U is still open).
|
||||||
|
|
||||||
|
2. **Tests namespace survives `--no-dev`.** Reproduced: `composer install --no-dev` in a temp dir still gives `LIT\MCP\Tests\ → tests/` in `vendor/composer/jetpack_autoload_psr4.php:21`. The Jetpack autoloader keeps root `autoload-dev`. If `tests/` ships, test classes are autoloadable in production.
|
||||||
|
|
||||||
|
3. **Direct HTTP access leaks paths and versions.**
|
||||||
|
- `curl https://…/wp-content/plugins/WP-LIT-MCP/src/Admin/AccessTab.php` returns a PHP fatal error with the full server path (display_errors on in DDEV).
|
||||||
|
- `…/tests/Integration/divi-convert-fixture.php` → HTTP 200.
|
||||||
|
- `…/vendor/composer/installed.json` → HTTP 200 (full dependency and version list).
|
||||||
|
|
||||||
|
**Fix proposal**
|
||||||
|
- Add a build script:
|
||||||
|
- `composer install --no-dev -o`;
|
||||||
|
- copy only `wp-lit-mcp.php`, `uninstall.php`, `readme.txt`, `src/`, `assets/`, `docs/guides/`, `vendor/`;
|
||||||
|
- no `tests/`, `docs/superpowers`, `.superpowers`, `composer.*`, `phpunit/phpcs` configs.
|
||||||
|
- Add a CI check that the zip contains no `vendor/phpunit`, `vendor/mockery` or `tests/`.
|
||||||
|
- Add `defined('ABSPATH') || exit;` to the src files (Plugin Check flags its absence), and the `WP_CLI` guard to `divi-convert-fixture.php`.
|
||||||
|
|
||||||
|
### L3 – Low: `tree_rev` / `rev` lock is check-then-write (lost update)
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `DiviWriter.php:116` (check) → `:161` (save).
|
||||||
|
- `ContentUpdateAbility.php:115` → `:156`.
|
||||||
|
- `MediaUpdateAbility` `modified`.
|
||||||
|
|
||||||
|
**Scenario:** two parallel writes on the same page (two Claude sessions, or Claude plus the Visual Builder) both pass the rev check. The later `wp_update_post()` silently overwrites the earlier change. The restore-point revision limits the damage.
|
||||||
|
|
||||||
|
**Evidence:** by reading. There is no lock or conditional update.
|
||||||
|
|
||||||
|
**Fix proposal:**
|
||||||
|
- Take a short per-post lock around check and save (`wp_cache_add`/`add_option('lit_mcp_lock_<id>')` with expiry, or MySQL `GET_LOCK`).
|
||||||
|
- Re-read `post_content` inside the lock and compare `Rev` again right before `wp_update_post()`.
|
||||||
|
|
||||||
|
### L4 – Low: deactivation and uninstall leave OAuth sessions valid
|
||||||
|
|
||||||
|
**Where**
|
||||||
|
- `Lifecycle.php:93-101`.
|
||||||
|
- `Uninstaller.php:28-61`. This is documented as intentional.
|
||||||
|
|
||||||
|
**Scenario**
|
||||||
|
- Deactivating or deleting the plugin keeps these:
|
||||||
|
- the mcp-oauth application passwords;
|
||||||
|
- `mcp_refresh_jti_*` meta;
|
||||||
|
- `mcp_jwt_secret`.
|
||||||
|
- Reactivating it within 30 days revives every refresh token. The admin may believe that removing the plugin cut Claude off.
|
||||||
|
- Plugin-owned post meta `_lit_mcp_copy_of` / `_lit_mcp_backup_of` also remains.
|
||||||
|
|
||||||
|
**Evidence:** by reading.
|
||||||
|
|
||||||
|
**Fix proposal:**
|
||||||
|
- On uninstall, delete the connections `ConnectionRepository` identifies (marker meta plus https `app_id`).
|
||||||
|
- Either delete `_lit_mcp_*` meta, or mention in readme and deactivation notice that connections survive.
|
||||||
|
- Offer "alle Verbindungen widerrufen" (revoke all connections) in the Access tab.
|
||||||
|
|
||||||
|
### I1 – Info: mcp-oauth logs MCP request bodies when `WP_DEBUG` and `WP_DEBUG_LOG` are on
|
||||||
|
|
||||||
|
**Where:** `vendor/wp-media/mcp-oauth/inc/Transport/OAuthHttpTransport.php:128-137` (`McpLogger::safe_request_body()`, 8 KB).
|
||||||
|
|
||||||
|
Tool arguments (page content, private drafts, Base64 uploads) go to `wp-content/debug.log`, which is often web-reachable on customer staging sites.
|
||||||
|
|
||||||
|
**Fix proposal:** mention this in the readme, or turn it off (e.g. filter or constant, if mcp-oauth offers one; otherwise ask upstream).
|
||||||
|
|
||||||
|
### I2 – Info: non-Divi writes re-kses Divi pages for users without unfiltered_html
|
||||||
|
|
||||||
|
These tools save through plain `wp_update_post`/`wp_insert_post`, so `content_save_pre` kses re-serializes the whole Divi layout:
|
||||||
|
- `lit/content-update` (title/excerpt/fields);
|
||||||
|
- publish/unpublish/schedule;
|
||||||
|
- `revision-restore`;
|
||||||
|
- `post-duplicate`;
|
||||||
|
- `post-replace-with-copy`.
|
||||||
|
|
||||||
|
The byte-identity promise of spec §1.3/§5.2 does not hold for these tools. There is no security impact: it only ever sanitizes more.
|
||||||
|
|
||||||
|
**Evidence:** read-only probe `kses2.php`. All 7 stored Divi pages changed under editor-kses (e.g. an empty block becomes `/-->`).
|
||||||
|
|
||||||
|
### I3 – Info: smaller observations
|
||||||
|
|
||||||
|
1. **Multisite super admin bypasses the role gate.** A super admin always has `lit_mcp_use` (`WP_User::has_cap`), even if "Zugriff" allows no role. `AccessTab::currentUserLosesAccess` knows this; the UI text doesn't say it.
|
||||||
|
2. **Media usage lists unreadable posts.** `MediaUsageFinder.php:455-461` returns post ID and post type (not the title) for posts the user can't read. This lets a user learn that private posts exist. It is intentional, to block deletes.
|
||||||
|
3. **All post types with a UI are exposed.** Every `show_ui` post type, including non-public ones (on DDEV: `path`), is exposed; only core, Divi internals and `lit_mcp_excluded_post_types` are excluded. Consider documenting the filter for data-protection purposes (form submissions, personal data as CPTs).
|
||||||
|
4. **Nested REST requests can block later calls.** A `rest_do_request()` to another route during a tool call makes `RequestContext` false for the rest of that HTTP request (`RequestContext.php:281`). This fails closed, so later JSON-RPC calls in the same batch get `forbidden`. Functional only.
|
||||||
|
5. **`confirm: true` is not a human confirmation.** Claude sets it itself; under prompt injection it is no safeguard. Keep the destructive tools indirect by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checked and found sound
|
||||||
|
|
||||||
|
- **RequestContext:** recomputed on every matched REST request, cleared after our route, and required by every ability. WP-CLI/STDIO and other routes fail closed.
|
||||||
|
- **Default mcp-adapter server:** disabled; the only MCP route is `/mcp/mcp-oauth-server` (verified via `rest_get_server()->get_routes()`).
|
||||||
|
- **Core Abilities REST:** our abilities have `show_in_rest=false` (only the 3 `core/*` abilities are in REST).
|
||||||
|
- **Transport and JWT:** `OAuthHttpTransport` requires a Bearer JWT (HS256 pinned, exp, aud = our route, iss = `home_url()`, app password must still exist). The current user is set only from the JWT; cookie or Basic auth can't reach the tools.
|
||||||
|
- **Revocation:**
|
||||||
|
- AccessTab revoke deletes the app password, which the next request's check honours;
|
||||||
|
- refresh-token rotation and reuse detection live in mcp-oauth;
|
||||||
|
- revoke is limited to `edit_user`-able users and, on multisite, to site members.
|
||||||
|
- **ConnectionUsage:** records only after a <400 response on our route, and only for `sub === current user`.
|
||||||
|
- **`lit_mcp_use` gate:** dynamic cap via `user_has_cap` (no role DB changes); the option is sanitized to existing roles; the filter output is type-checked; the meta-tools' capability filters are set to `lit_mcp_use`.
|
||||||
|
- **Exposure:**
|
||||||
|
- disabled and "nur lesen" abilities are never registered, so `execute-ability` answers "not found";
|
||||||
|
- DIRECT_ONLY abilities are `mcp.public=false`, so `execute-ability` refuses them;
|
||||||
|
- `lit/guide` is always direct.
|
||||||
|
- **Annotations:** every tool annotated `readonly` really doesn't write (render-summary runs `the_content` side effects only).
|
||||||
|
- **Per-object checks:**
|
||||||
|
- `PostGuard::editable` (`edit_post`, managed type, trash rules) on every content, status, revision, render and Divi ability, in both permission and execute;
|
||||||
|
- revisions are checked via the parent and `post_type === 'revision'`;
|
||||||
|
- attachments via `MediaGuard` (`edit_post`/`delete_post`, type check);
|
||||||
|
- menu items via item ID membership in the menu.
|
||||||
|
- **Nested IDs:**
|
||||||
|
- featured image, parent and relation fields: `read_post` plus type checks;
|
||||||
|
- menu `object_id`: `read_post`, no private, unpublished only with a flag, taxonomy must be viewable;
|
||||||
|
- Divi media/gallery and `select_post`: `read_post`;
|
||||||
|
- terms: `assign_terms`/`edit_terms`;
|
||||||
|
- meta: `edit_post_meta`/`add_post_meta` per key.
|
||||||
|
- **Query and search:**
|
||||||
|
- `content-query`/`media-search`/`replace-text` limit the query to the user's own posts without `edit_others_posts` and gate statuses;
|
||||||
|
- hidden fields can't be used as a query filter.
|
||||||
|
- **Settings:**
|
||||||
|
- `manage_options`;
|
||||||
|
- strict allow-list;
|
||||||
|
- `admin_email`/`users_can_register`/`default_role`/`siteurl`/`home` explicitly blocked;
|
||||||
|
- per-key validation (installed languages, valid timezones, published pages, image attachment).
|
||||||
|
- **Meta writes:**
|
||||||
|
- `update_post_meta` double-serializes a serialized string, so there is no object injection;
|
||||||
|
- protected keys are refused through the meta caps;
|
||||||
|
- clone and replace copy raw stored meta via `$wpdb` (no unserialize).
|
||||||
|
- **Menu URLs:** only http/https/mailto/tel-style schemes, `/path` (not `//` or `/\`) or `#`; control characters and spaces are refused; `esc_url_raw` is applied.
|
||||||
|
- **Media files:**
|
||||||
|
- MIME allow-list intersected with the core-allowed types for the user, checked against the real content (`wp_check_filetype_and_ext`);
|
||||||
|
- no SVG by default;
|
||||||
|
- `sanitize_file_name` (no traversal, double extensions defused);
|
||||||
|
- streamed size cap;
|
||||||
|
- Base64 size checked before decoding;
|
||||||
|
- temp files removed on every path;
|
||||||
|
- download and preview only read files `realpath`-inside uploads;
|
||||||
|
- caption and description go through `wp_kses_post`.
|
||||||
|
- **Error handling:**
|
||||||
|
- Throwables are mapped to `internal_error`, details only go to the PHP log;
|
||||||
|
- the sideload error is hidden;
|
||||||
|
- no SQL or path text reaches the client (apart from the M3 download messages).
|
||||||
|
- **SQL:** every query is `$wpdb->prepare()`d. The only interpolations are table names and the constant status list; the log filters are whitelisted.
|
||||||
|
- **Change log:** holds titles and references only, no content. Admin list output is escaped; revision links are gated by `edit_post` on the parent.
|
||||||
|
- **Admin UI:**
|
||||||
|
- every admin-post action has `manage_options` plus `check_admin_referer` (revoke nonce per UUID, clear-log);
|
||||||
|
- settings go through `options.php` with `settings_fields` nonces and sanitize callbacks;
|
||||||
|
- `closedpostboxes` is core AJAX with its nonce;
|
||||||
|
- notices and tabs come from fixed maps;
|
||||||
|
- everything is escaped (`esc_js`+`esc_attr` for confirm handlers).
|
||||||
|
- **Site Health and admin notices:** static text only, escaped; the loopback check is cached and only runs on demand.
|
||||||
|
- **Guides:** topic slug regex plus the glob'd file list (no traversal); filtered guides are site-owned.
|
||||||
|
- **Divi pipeline integration:**
|
||||||
|
- the kses window covers only `RevisionPoint::capture` plus the one `wp_update_post` and is restored in `finally`, only when kses was active;
|
||||||
|
- Divi's own `wp_insert_post_data` security filters stay (verified: they strip `on*` attributes);
|
||||||
|
- the insert, update, move, delete and duplicate-node tools only write through Composer/TreeEditor;
|
||||||
|
- duplicate-node adds only the stored label plus a constant;
|
||||||
|
- replace-text re-checks targets per post;
|
||||||
|
- DynamicContentMap drops `loop_*` options and types come from the registry; the fixed rules (enable_html, meta keys, markers) remain.
|
||||||
|
- **Uninstall and deactivation:** both remove their own options, transient, cron and table on every site of a network, never posts.
|
||||||
|
- **Limits and DoS:**
|
||||||
|
- cursor offset capped at 9 digits, page size ≤100;
|
||||||
|
- batch caps (media-update 50, divi-update 50, insert/delete 20, replace-text 20 targets / dry run ≤100 pages, content-create `divi` 30 sections);
|
||||||
|
- render summary strings bounded;
|
||||||
|
- text search limited to 200 characters.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
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).
|
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.
|
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.
|
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).
|
5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot).
|
||||||
|
|
||||||
@@ -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:
|
- **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`.
|
`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`).
|
- **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.
|
- In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind.
|
||||||
- Ability-Namen: `lit/<bereich>-<aktion>`. Der Adapter bildet daraus Tool-Namen (`lit-<bereich>-<aktion>`).
|
- Ability-Namen: `lit/<bereich>-<aktion>`. Der Adapter bildet daraus Tool-Namen (`lit-<bereich>-<aktion>`).
|
||||||
@@ -138,6 +139,7 @@ src/
|
|||||||
Admin/ Einstellungsseite, Protokoll-Ansicht (Settings API, WP_List_Table)
|
Admin/ Einstellungsseite, Protokoll-Ansicht (Settings API, WP_List_Table)
|
||||||
Integrations/
|
Integrations/
|
||||||
Integration.php Interface: isAvailable(), abilities(), prompts()
|
Integration.php Interface: isAvailable(), abilities(), prompts()
|
||||||
|
(Sonderfälle innerhalb einer Integration: abilities() lässt nicht nutzbare Tools einfach weg)
|
||||||
WordPress/ Content, Schema, Status/Kopie, Revisionen, Menüs, Einstellungen
|
WordPress/ Content, Schema, Status/Kopie, Revisionen, Menüs, Einstellungen
|
||||||
Media/ Medien-Tools, Verwendungssuche
|
Media/ Medien-Tools, Verwendungssuche
|
||||||
Divi/
|
Divi/
|
||||||
@@ -169,15 +171,17 @@ 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).
|
- 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.
|
- 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.
|
- **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).
|
- Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar).
|
||||||
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).
|
- 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
|
### 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.
|
- `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`.
|
- 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 Schreib-Tool verlangt `rev`. Weicht er ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einem `rev`.
|
- 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
|
### 5.4 Modulbeschreibung
|
||||||
|
|
||||||
@@ -224,15 +228,15 @@ Jede Schreib-Rückmeldung enthält Warnungen zu:
|
|||||||
- leeren Modulen,
|
- leeren Modulen,
|
||||||
- Sprüngen in der Überschriftenhierarchie,
|
- Sprüngen in der Überschriftenhierarchie,
|
||||||
- Verwendung des Code-Moduls,
|
- 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 (`<Abschnitt> – <Modul>`), sofern nicht angegeben.
|
Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nicht angegeben.
|
||||||
|
|
||||||
### 5.6 Speicher-Pipeline
|
### 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).
|
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.
|
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')`.
|
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.
|
6. Protokolleintrag, `ToolResult` zurückgeben.
|
||||||
@@ -241,18 +245,18 @@ Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nich
|
|||||||
|
|
||||||
| Ability | Art | Beschreibung |
|
| 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-node-get` | lesen | vollständige Attribute eines Knotens (Vollform) |
|
||||||
| `lit/divi-catalog` | lesen | Module, Kurzform-Felder, wichtige Attributpfade; Detail pro Modul |
|
| `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-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/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) |
|
| `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-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-update` | schreiben | Werte setzen/entfernen (Kurz- oder Vollform) |
|
||||||
| `lit/divi-move` | schreiben | Knoten verschieben (auch zwischen Zeilen/Abschnitten) |
|
| `lit/divi-move` | schreiben | Knoten verschieben (auch zwischen Zeilen/Abschnitten) |
|
||||||
| `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen |
|
| `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen |
|
||||||
| `lit/divi-duplicate-node` | schreiben | Knoten duplizieren |
|
| `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-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-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global) |
|
||||||
| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz einfügen |
|
| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz einfügen |
|
||||||
@@ -360,7 +364,7 @@ Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): Claude liest das CD i
|
|||||||
"summary": "Button in 'Hero' aktualisiert",
|
"summary": "Button in 'Hero' aktualisiert",
|
||||||
"changed": [{ "id": "s0.r0.c0.m2", "type": "divi/button", "action": "updated", "label": "Hero – Button" }],
|
"changed": [{ "id": "s0.r0.c0.m2", "type": "divi/button", "action": "updated", "label": "Hero – Button" }],
|
||||||
"outline": "…betroffener Abschnitt…",
|
"outline": "…betroffener Abschnitt…",
|
||||||
"rev": "a1b2c3",
|
"rev": "a1b2c3", "tree_rev": "d4e5f6",
|
||||||
"warnings": ["Feste Farbe #ff0000 – Variable 'Primär' ist ähnlich"],
|
"warnings": ["Feste Farbe #ff0000 – Variable 'Primär' ist ähnlich"],
|
||||||
"revision_id": 812, "snapshot_id": null, "log_id": 4711,
|
"revision_id": 812, "snapshot_id": null, "log_id": 4711,
|
||||||
"preview_url": "…", "edit_url": "…&et_fb=1" }
|
"preview_url": "…", "edit_url": "…&et_fb=1" }
|
||||||
@@ -385,7 +389,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`).
|
- **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).
|
- **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.
|
- **Limits:** Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots.
|
||||||
- Umsetzung mit Settings API, ohne JS-Framework.
|
- Umsetzung mit Settings API, ohne JS-Framework.
|
||||||
|
|
||||||
@@ -395,7 +399,7 @@ Rückgabe als `structuredContent` plus kurze Text-Zusammenfassung. Listen immer
|
|||||||
|
|
||||||
Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zeilen:
|
Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zeilen:
|
||||||
- aktive Integrationen und Post-Typen,
|
- 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`.
|
- Verweis auf `lit/guide`.
|
||||||
|
|
||||||
### 9.2 `lit/guide(topic)`
|
### 9.2 `lit/guide(topic)`
|
||||||
@@ -415,7 +419,7 @@ Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-plac
|
|||||||
- Whitelists für WP-Einstellungen und Divi-Theme-Optionen.
|
- Whitelists für WP-Einstellungen und Divi-Theme-Optionen.
|
||||||
- Uploads: `wp_safe_remote_get`, MIME-Whitelist, Größenlimits.
|
- Uploads: `wp_safe_remote_get`, MIME-Whitelist, Größenlimits.
|
||||||
- Destruktive Aktionen: `confirm: true` erforderlich; kein endgültiges Löschen von Posts.
|
- 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).
|
- HTTPS erforderlich (Hinweis im Admin, wenn nicht aktiv).
|
||||||
|
|
||||||
## 11. MCP-Konformität
|
## 11. MCP-Konformität
|
||||||
@@ -440,7 +444,7 @@ Referenz: https://modelcontextprotocol.io/specification
|
|||||||
- `inputSchema` für jedes Tool, `outputSchema` wo strukturiert (JSON Schema 2020-12); Ergebnisse als `structuredContent` + Text.
|
- `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.
|
- Fachliche Fehler als `isError: true`, Protokollfehler als JSON-RPC-Fehler.
|
||||||
- Annotations korrekt: `readOnlyHint` (Lese-Tools), `destructiveHint` (Löschen, Ersetzen, Austausch), `idempotentHint`.
|
- 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.
|
- Deterministische Reihenfolge der Tool-Liste.
|
||||||
- Cursor-Paginierung für Listen.
|
- Cursor-Paginierung für Listen.
|
||||||
- Tool-Namen spezifikationskonform und eindeutig (`lit-…`).
|
- Tool-Namen spezifikationskonform und eindeutig (`lit-…`).
|
||||||
@@ -458,9 +462,10 @@ 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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 | – |
|
||||||
|
|
||||||
Kann-Kriterien nach Etappe 4 oder bei freier Kapazität.
|
Kann-Kriterien nach Etappe 4 oder bei freier Kapazität.
|
||||||
|
|
||||||
@@ -482,12 +487,12 @@ 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 |
|
| 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 |
|
| `.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 |
|
| 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 |
|
| 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)
|
## 15. Offene Punkte (in Etappen zu klären)
|
||||||
|
|
||||||
- Etappe 0: Funktioniert der Workaround für direkte Tools mit mcp-oauth stabil? Erfüllt mcp-oauth `iss`/`resource`-Anforderungen?
|
- 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 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.
|
- Etappe 4: Format der Presets/Variablen an echten Daten verifizieren.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="wp-lit-mcp">
|
||||||
|
<file>src</file>
|
||||||
|
<file>wp-lit-mcp.php</file>
|
||||||
|
<file>uninstall.php</file>
|
||||||
|
<exclude-pattern>/vendor/*</exclude-pattern>
|
||||||
|
<arg value="sp"/>
|
||||||
|
<arg name="extensions" value="php"/>
|
||||||
|
<arg name="basepath" value="."/>
|
||||||
|
<rule ref="PSR12">
|
||||||
|
<!-- Every file starts with defined('ABSPATH') || exit; (no direct HTTP access, security audit L2). -->
|
||||||
|
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
|
||||||
|
</rule>
|
||||||
|
<rule ref="WordPress.Security">
|
||||||
|
<exclude name="WordPress.Security.EscapeOutput.ExceptionNotEscaped"/>
|
||||||
|
</rule>
|
||||||
|
<rule ref="WordPress.DB.PreparedSQL"/>
|
||||||
|
<rule ref="WordPress.DB.PreparedSQLPlaceholders"/>
|
||||||
|
<rule ref="WordPress.WP.AlternativeFunctions"/>
|
||||||
|
<rule ref="WordPress.WP.I18n">
|
||||||
|
<properties>
|
||||||
|
<property name="text_domain" type="array">
|
||||||
|
<element value="wp-lit-mcp"/>
|
||||||
|
</property>
|
||||||
|
</properties>
|
||||||
|
</rule>
|
||||||
|
<rule ref="PHPCompatibilityWP"/>
|
||||||
|
<config name="testVersion" value="8.1-"/>
|
||||||
|
</ruleset>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
=== WP-LIT-MCP ===
|
||||||
|
Contributors: loosenit
|
||||||
|
Tags: mcp, claude, divi, pods, ai
|
||||||
|
Requires at least: 6.9
|
||||||
|
Tested up to: 7.1
|
||||||
|
Requires PHP: 8.1
|
||||||
|
Stable tag: 0.3.0
|
||||||
|
License: GPL-2.0-or-later
|
||||||
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
|
MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
|
||||||
|
|
||||||
|
== Description ==
|
||||||
|
|
||||||
|
WP-LIT-MCP stellt einen MCP-Server bereit, über den Claude (claude.ai-Connector) diese Website pflegt:
|
||||||
|
Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene
|
||||||
|
WordPress-Einstellungen und die Mediathek. Jede Änderung läuft mit den Rechten des verbundenen
|
||||||
|
WordPress-Benutzers und wird protokolliert.
|
||||||
|
|
||||||
|
Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits).
|
||||||
|
|
||||||
|
== Auswirkungen auf die gesamte Website ==
|
||||||
|
|
||||||
|
* Der Standard-MCP-Server von wordpress/mcp-adapter (REST-Route /wp-json/mcp/mcp-adapter-default-server)
|
||||||
|
wird abgeschaltet (Filter mcp_adapter_create_default_server). Andere Plugins, die diesen ungeschützten
|
||||||
|
Server erwarten, finden ihn nicht mehr.
|
||||||
|
* Es gibt genau einen MCP-Endpunkt: /wp-json/mcp/mcp-oauth-server (OAuth 2.1 über die eingebundene
|
||||||
|
Bibliothek wp-media/mcp-oauth). Anfragen ohne gültiges Token erhalten 401 mit WWW-Authenticate.
|
||||||
|
* mcp-oauth registriert Rewrite-Regeln für /oauth/… und /.well-known/oauth-… sowie einen
|
||||||
|
Website-Zustand-Test. Der Webserver muss /.well-known/ an WordPress durchreichen (bei nginx-Regeln,
|
||||||
|
die Punkt-Pfade sperren, eine Ausnahme für /.well-known/ anlegen).
|
||||||
|
* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im
|
||||||
|
Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar.
|
||||||
|
* Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der
|
||||||
|
Datenbank werden nicht verändert. Nicht freigegebene Benutzer können keine Verbindung herstellen: die
|
||||||
|
Anmeldung beim Verbinden endet mit „Dein Benutzer hat keinen Zugriff auf LIT MCP.“, es entsteht kein
|
||||||
|
Anwendungspasswort. Verlieren bereits verbundene Benutzer die Freigabe, sehen sie weder die Werkzeugliste
|
||||||
|
noch Angaben zur Website – nur den Hinweis, dass ihr Konto nicht freigegeben ist.
|
||||||
|
* Welche Werkzeuge direkt sichtbar, nur über die Meta-Werkzeuge erreichbar oder abgeschaltet sind, lässt
|
||||||
|
sich unter Einstellungen → LIT MCP → Tools ändern.
|
||||||
|
* Eigene Daten: Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
|
||||||
|
Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup,
|
||||||
|
Transient lit_mcp_endpoint_check, Benutzer-Meta lit_mcp_log_per_page (Ansichtsoption „Einträge pro Seite“
|
||||||
|
im Protokoll).
|
||||||
|
* Divi-Änderungen über LIT MCP speichern den Seiteninhalt für Benutzer ohne die Berechtigung
|
||||||
|
unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu
|
||||||
|
schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post,
|
||||||
|
sanitize_text_field, esc_url_raw); $variable-Verweise werden geparst, geprüft und vom Plugin neu erzeugt.
|
||||||
|
Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS und HTML-Attribute
|
||||||
|
von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben
|
||||||
|
aktiv.
|
||||||
|
* Das Feld content von lit-content-create und lit-content-update nimmt kein Divi-Markup an (Divi-Blöcke
|
||||||
|
<!-- wp:divi/… und Divi-4-Shortcodes [et_pb_…]); Divi-Inhalte entstehen nur über divi bzw. die
|
||||||
|
lit-divi-*-Tools und damit über dieselbe Bereinigung. Anderes Block-Markup bleibt erlaubt und läuft wie
|
||||||
|
im WordPress-Editor durch die kses-Filterung von WordPress.
|
||||||
|
* Byte-genau gilt das nur für die lit-divi-*-Werkzeuge: Werkzeuge, die eine Divi-Seite als Ganzes speichern
|
||||||
|
(Titel, Auszug oder Felder über lit-content-update, Veröffentlichen/Planen/Zurückziehen, lit/revision-restore,
|
||||||
|
lit/post-duplicate, lit/post-replace-with-copy), laufen für Benutzer ohne unfiltered_html wie in WordPress
|
||||||
|
durch die kses-Filterung; die kann das Markup unveränderter Divi-Blöcke umschreiben und dabei – wie im
|
||||||
|
WordPress-Editor – Werte entfernen, die der Benutzer nicht speichern darf.
|
||||||
|
* Unberührte Teile einer Divi-Seite bleiben beim Speichern byte-identisch. Ausnahme: Divi selbst normalisiert
|
||||||
|
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.
|
||||||
|
* Pods-Felder: Die Feldrechte von Pods gelten auch über LIT MCP. „Verborgen“ und für den Benutzer gesperrte
|
||||||
|
Felder (nur Admins, Rollen- oder Capability-Beschränkung) werden weder angezeigt noch geschrieben;
|
||||||
|
„schreibgeschützt“ (bzw. gesperrt mit „schreibgeschützt statt verborgen“) sind nur lesbar.
|
||||||
|
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
|
||||||
|
(Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden
|
||||||
|
abgelehnt).
|
||||||
|
|
||||||
|
* Debug-Log: Sind WP_DEBUG und WP_DEBUG_LOG gleichzeitig an, schreibt die mitgelieferte Bibliothek
|
||||||
|
mcp-oauth jede MCP-Anfrage (bis 8 KB, Tokens und Passwörter geschwärzt) samt Tool-Argumenten – also
|
||||||
|
z. B. Seiteninhalte, Entwürfe und Base64-Uploads – nach wp-content/debug.log. Auf Live- und
|
||||||
|
Staging-Seiten WP_DEBUG_LOG deshalb aus lassen oder das Log vor Webzugriff schützen.
|
||||||
|
|
||||||
|
== Filter für Entwickler ==
|
||||||
|
|
||||||
|
* lit_mcp_allowed_roles – Liste der freigegebenen Rollen (nach der Einstellung unter Zugriff).
|
||||||
|
* lit_mcp_upload_mimes – erlaubte Dateitypen für Uploads (Endungen => MIME-Typ); wirksam ist nur, was
|
||||||
|
WordPress dem Benutzer ohnehin erlaubt.
|
||||||
|
* lit_mcp_guides – eigene Anleitungen für lit-guide (Thema => Markdown).
|
||||||
|
* lit_mcp_excluded_post_types – Inhaltstypen, die LIT MCP nicht anzeigt und nicht bearbeitet.
|
||||||
|
|
||||||
|
== Rückgängig machen ==
|
||||||
|
|
||||||
|
Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lässt, hängt vom Werkzeug ab:
|
||||||
|
|
||||||
|
* Inhalte (Titel, Text, Auszug): über WordPress-Revisionen (Werkzeuge lit/revisions-list,
|
||||||
|
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
|
||||||
|
und eine vollständige Sicherung des bisherigen Originals im Papierkorb an (backup_id in der Antwort) –
|
||||||
|
von dort in WordPress wiederherstellbar.
|
||||||
|
* Medien löschen (lit/media-delete): ohne aktivierten Medien-Papierkorb (WordPress-Standard, Konstante
|
||||||
|
MEDIA_TRASH in wp-config.php) endgültig und nicht rückgängig zu machen; mit aktiviertem Medien-Papierkorb landet das
|
||||||
|
Medium dort.
|
||||||
|
* Menüs (lit/menu-set-items): die Antwort enthält previous_items (Stand vorher), mit dem sich der
|
||||||
|
vorherige Baum nachbauen lässt – neu angelegte Einträge bekommen dabei eine neue ID, CSS-Klassen,
|
||||||
|
Linkziel und Beschreibung gehen verloren.
|
||||||
|
* WordPress-Einstellungen (lit/wp-settings-update): die Antwort enthält previous mit den alten Werten.
|
||||||
|
* Alle genannten Änderungen stehen zusätzlich im Änderungsprotokoll (Einstellungen → LIT MCP →
|
||||||
|
Protokoll), das bei Inhalten, Medien und Menüs auf den passenden Bearbeiten-/Revisions-/Mediathek-Link
|
||||||
|
verweist.
|
||||||
|
* Alles andere (z. B. neu angelegte Entwürfe, hochgeladene Medien, neue Menüs) lässt sich nur über die
|
||||||
|
üblichen WordPress-Wege rückgängig machen (Papierkorb, manuelles Löschen) – nicht über ein eigenes
|
||||||
|
Undo-Werkzeug von LIT MCP.
|
||||||
|
|
||||||
|
== Deaktivieren und Löschen ==
|
||||||
|
|
||||||
|
* Deaktivieren entfernt den Cron-Job und lässt WordPress die Rewrite-Regeln neu erzeugen; der
|
||||||
|
MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll
|
||||||
|
bleiben erhalten. Verbindungen werden dabei nicht widerrufen: Wird das Plugin wieder aktiviert, gelten
|
||||||
|
sie weiter (Zugriffstoken eine Stunde, Refresh-Token 30 Tage, solange das Anwendungspasswort besteht).
|
||||||
|
Wer Claude sicher trennen will, widerruft die Verbindungen vorher unter Zugriff.
|
||||||
|
* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website)
|
||||||
|
sowie die Ansichtsoption „Einträge pro Seite“ des Protokolls bei allen Benutzern.
|
||||||
|
* Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option
|
||||||
|
wpmedia_mcp_oauth_rewrite_version, Benutzer-Meta mcp_refresh_jti_*, Anwendungspasswörter der
|
||||||
|
Verbindungen), weil diese Bibliothek auch von anderen Plugins genutzt werden kann. Verbindungen
|
||||||
|
deshalb vor dem Löschen unter Einstellungen → LIT MCP → Zugriff widerrufen; nicht mehr benötigte
|
||||||
|
Anwendungspasswörter lassen sich im Benutzerprofil entfernen.
|
||||||
|
|
||||||
|
== Multisite ==
|
||||||
|
|
||||||
|
Das Plugin wird je Website aktiviert und konfiguriert; Rollenfreigaben, Tool-Einstellungen und
|
||||||
|
Protokoll gelten pro Website. Super-Admins haben (wie jede Capability in WordPress) immer lit_mcp_use,
|
||||||
|
unabhängig von den freigegebenen Rollen. Der Standard-MCP-Server von mcp-adapter wird ebenfalls website-weit
|
||||||
|
abgeschaltet.
|
||||||
|
|
||||||
|
== Frequently Asked Questions ==
|
||||||
|
|
||||||
|
= Kann ich Tools abschalten oder nur lesend freigeben? =
|
||||||
|
|
||||||
|
Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden
|
||||||
|
Tools einer Gruppe auf einmal ab.
|
||||||
|
|
||||||
|
= Bleiben mit Claude bearbeitete Divi-Seiten im Visual Builder bearbeitbar? =
|
||||||
|
|
||||||
|
Ja. LIT MCP erzeugt native Divi-5-Module mit Admin-Labels und verknüpft Farben, Variablen und Presets mit dem
|
||||||
|
Designsystem. Nach einer Änderung im Builder liest Claude die Seite neu, bevor es weiter ändert.
|
||||||
|
|
||||||
|
== Changelog ==
|
||||||
|
|
||||||
|
= 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.
|
||||||
|
* Tools nach Gruppen einstellbar (direkt/indirekt/deaktiviert, „nur lesen“ je Gruppe; gilt auch für
|
||||||
|
Divi-Inhalt über lit-content-create); überarbeitete Einstellungsseite, Protokoll als WordPress-Liste mit
|
||||||
|
Filtern, Zugriff mit „Zuletzt genutzt“ je Verbindung.
|
||||||
|
* OAuth-Discovery: /.well-known/oauth-* antwortet direkt statt per Weiterleitung (wichtig bei Permalinks mit
|
||||||
|
Schrägstrich am Ende); nicht freigegebene Benutzer können keine Verbindung mehr herstellen.
|
||||||
|
* Sicherheit: kein Divi-Markup über content, eigene Prüfung privater Adressbereiche beim Import per URL
|
||||||
|
(auch bei Weiterleitungen) mit neutraler Fehlermeldung, Pods-Feldrechte, kein direkter Aufruf der
|
||||||
|
PHP-Dateien.
|
||||||
|
* Kurzform: level auch für Kontaktformular, CTA, Toggle, Akkordeon, Slide, Zähler und Teammitglied;
|
||||||
|
lit/menu-create liefert menu_rev.
|
||||||
|
|
||||||
|
= 0.2.0 =
|
||||||
|
* WordPress-Basis: Schema-Erkennung inkl. Pods, Inhalte lesen/anlegen/ändern, Status, Kopie und
|
||||||
|
Austausch, Revisionen, Menüs, WordPress-Einstellungen, Mediathek (Suche, Vorschau, Metadaten,
|
||||||
|
Hochladen, Herunterladen, Löschen mit Verwendungsprüfung), Änderungsprotokoll, Einstellungsseite
|
||||||
|
(Verbindung, Zugriff, Tools, Protokoll, Limits), konfigurierbare Tool-Exposition, Aufräumen bei
|
||||||
|
Deaktivierung/Deinstallation.
|
||||||
|
|
||||||
|
= 0.1.0 =
|
||||||
|
* Anbindung: OAuth-geschützter MCP-Server, Rollen-Gate, lit-guide, lit-site-status.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One capability of the MCP server, registered with the WordPress Abilities API.
|
||||||
|
*/
|
||||||
|
interface Ability
|
||||||
|
{
|
||||||
|
/** Ability name, e.g. "lit/guide". */
|
||||||
|
public function name(): string;
|
||||||
|
|
||||||
|
public function label(): string;
|
||||||
|
|
||||||
|
public function description(): string;
|
||||||
|
|
||||||
|
/** @return array<string, mixed> JSON Schema of the input. */
|
||||||
|
public function inputSchema(): array;
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|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<string, mixed> $input */
|
||||||
|
public function permission(array $input): bool|WP_Error;
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $input */
|
||||||
|
public function execute(array $input): mixed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers all abilities of available integrations with the Abilities API and
|
||||||
|
* wraps them with the role gate and neutral error handling.
|
||||||
|
*/
|
||||||
|
final class AbilityRegistrar
|
||||||
|
{
|
||||||
|
public const CATEGORY = 'lit-mcp';
|
||||||
|
|
||||||
|
public function __construct(private IntegrationRegistry $registry, private ?ExposurePolicy $policy = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']);
|
||||||
|
add_action('wp_abilities_api_init', [$this, 'registerAbilities']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerCategory(): void
|
||||||
|
{
|
||||||
|
wp_register_ability_category(self::CATEGORY, [
|
||||||
|
'label' => 'LIT MCP',
|
||||||
|
'description' => 'Website-Pflege für WordPress, Divi und Pods über MCP.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerAbilities(): void
|
||||||
|
{
|
||||||
|
$policy = $this->policy ?? new ExposurePolicy();
|
||||||
|
foreach ($policy->enabled($this->registry->abilities()) as $ability) {
|
||||||
|
wp_register_ability($ability->name(), $this->argsFor($ability));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! RequestContext::isOAuthServerRequest()) {
|
||||||
|
return Errors::forbidden('LIT MCP ist nur über den OAuth-geschützten MCP-Server nutzbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $ability->permission(is_array($input) ? $input : []);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::failure($ability->name(), $e);
|
||||||
|
return Errors::internal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'execute_callback' => static function (mixed $input = null) use ($ability): mixed {
|
||||||
|
try {
|
||||||
|
return $ability->execute(is_array($input) ? $input : []);
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
Log::failure($ability->name(), $e);
|
||||||
|
return $e->toError();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::failure($ability->name(), $e);
|
||||||
|
return Errors::internal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'meta' => [
|
||||||
|
'annotations' => $ability->annotations(),
|
||||||
|
'show_in_rest' => false,
|
||||||
|
// mcp.public only governs the adapter's meta-tools (discover/get-info/execute);
|
||||||
|
// direct tools are listed on the server explicitly. Direct-only abilities
|
||||||
|
// (image/resource results) would not work through execute, so hide them there.
|
||||||
|
'mcp' => [
|
||||||
|
'public' => ! in_array($ability->name(), ExposurePolicy::DIRECT_ONLY, true),
|
||||||
|
'type' => 'tool',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$output = $ability->outputSchema();
|
||||||
|
if ($output !== null) {
|
||||||
|
$args['output_schema'] = $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP annotations of an ability (Ability::annotations()), in three presets. "readonly"
|
||||||
|
* drives the "nur lesen" group switch (ExposurePolicy::disabledByReadonly()) and, with
|
||||||
|
* "destructive", the Art column of the Tools tab (ruling S47).
|
||||||
|
*/
|
||||||
|
final class Annotations
|
||||||
|
{
|
||||||
|
/** @return array{readonly: bool, destructive: bool, idempotent: bool} Reads only. */
|
||||||
|
public static function read(): array
|
||||||
|
{
|
||||||
|
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{readonly: bool, destructive: bool, idempotent: bool} Writes, removes or overwrites nothing. */
|
||||||
|
public static function write(bool $idempotent): array
|
||||||
|
{
|
||||||
|
return ['readonly' => false, 'destructive' => false, 'idempotent' => $idempotent];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{readonly: bool, destructive: bool, idempotent: bool} Removes or overwrites something. */
|
||||||
|
public static function destructive(bool $idempotent): array
|
||||||
|
{
|
||||||
|
return ['readonly' => false, 'destructive' => true, 'idempotent' => $idempotent];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque offset cursor for list tools (spec §8.1, §11.3).
|
||||||
|
*/
|
||||||
|
final class Cursor
|
||||||
|
{
|
||||||
|
public const DEFAULT_LIMIT = 20;
|
||||||
|
public const MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
public static function encode(int $offset): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode('o:' . $offset), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int|null Offset, 0 for a missing cursor, null for an invalid one. */
|
||||||
|
public static function decode(?string $cursor): ?int
|
||||||
|
{
|
||||||
|
if ($cursor === null || $cursor === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||||||
|
if ($raw === false || preg_match('/^o:(\d{1,9})$/D', $raw, $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function limit(mixed $limit, int $default = self::DEFAULT_LIMIT): int
|
||||||
|
{
|
||||||
|
if ($limit === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(1, min(self::MAX_LIMIT, (int) $limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<mixed> $rows Rows fetched with $limit + 1.
|
||||||
|
* @return array{items: list<mixed>, next_cursor: ?string}
|
||||||
|
*/
|
||||||
|
public static function page(array $rows, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'items' => array_slice($rows, 0, $limit),
|
||||||
|
'next_cursor' => count($rows) > $limit ? self::encode($offset + $limit) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalid(): WP_Error
|
||||||
|
{
|
||||||
|
return Errors::invalidInput(
|
||||||
|
'Ungültiger cursor.',
|
||||||
|
['cursor' => 'unbekanntes Format'],
|
||||||
|
'cursor unverändert aus next_cursor der letzten Antwort übernehmen oder weglassen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<string, mixed>> */
|
||||||
|
public static function inputProperties(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'limit' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'minimum' => 1,
|
||||||
|
'maximum' => self::MAX_LIMIT,
|
||||||
|
'description' => 'Einträge pro Seite (Standard 20).',
|
||||||
|
],
|
||||||
|
'cursor' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'next_cursor der vorherigen Antwort, um weiterzublättern.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divi markup in raw content (security audit M1, ruling S52): the single check for every path
|
||||||
|
* that stores client text as post_content without the Divi pipeline (lit/content-create and
|
||||||
|
* lit/content-update `content`, lit/media-update `description`). Such content would bypass the
|
||||||
|
* builder-only gates of the Divi tools (rulings S7/S26).
|
||||||
|
*
|
||||||
|
* Checked is the form storage will see: kses (wp_kses_no_null) removes C0 control characters and
|
||||||
|
* "\0" sequences only AFTER any check on the raw value, so a NUL inside "<!-- wp:divi/" would
|
||||||
|
* otherwise hide a real block. normalized() applies those removals plus DEL (like
|
||||||
|
* Divi\Compose\ControlChars::strip()); contains() refuses when either the raw or the normalized
|
||||||
|
* form holds a delimiter WordPress' block parser accepts for divi/* blocks ("<!--", whitespace,
|
||||||
|
* optional "/", lower-case "wp:divi/" – Divi's BlockParser extends it) or a Divi 4 shortcode.
|
||||||
|
* Other block markup stays allowed and goes through WordPress' kses. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class DiviMarkup
|
||||||
|
{
|
||||||
|
private const PATTERN = '~<!--\s+/?wp:divi/|\[/?et_pb_~';
|
||||||
|
|
||||||
|
public const HINT = 'Divi-Inhalte über `divi` (lit-content-create) bzw. die lit-divi-*-Tools '
|
||||||
|
. '(lit-divi-insert, lit-divi-update, lit-divi-move, lit-divi-delete) ändern.';
|
||||||
|
|
||||||
|
public static function contains(string $content): bool
|
||||||
|
{
|
||||||
|
return preg_match(self::PATTERN, $content) === 1 || preg_match(self::PATTERN, self::normalized($content)) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** $content without what kses (wp_kses_no_null) and ControlChars remove before it is stored. */
|
||||||
|
public static function normalized(string $content): string
|
||||||
|
{
|
||||||
|
$content = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $content);
|
||||||
|
|
||||||
|
return (string) preg_replace('/\\\\+0+/', '', $content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for the error objects abilities return.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $details Extra keys for the client, e.g. fields or usages.
|
||||||
|
*/
|
||||||
|
public static function make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error
|
||||||
|
{
|
||||||
|
$payload = array_merge(['code' => $code, 'message' => $message, 'hint' => $hint], $details);
|
||||||
|
$json = wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return new WP_Error(
|
||||||
|
'lit_' . $code,
|
||||||
|
is_string($json) ? $json : $message,
|
||||||
|
array_merge(['status' => $status, 'hint' => $hint], $details)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forbidden(
|
||||||
|
string $message,
|
||||||
|
string $hint = 'Mit den Rechten des verbundenen WordPress-Kontos nicht erlaubt; ggf. Administrator fragen.'
|
||||||
|
): WP_Error {
|
||||||
|
return self::make('forbidden', $message, $hint, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $fields Input path => problem.
|
||||||
|
*/
|
||||||
|
public static function invalidInput(
|
||||||
|
string $message,
|
||||||
|
array $fields = [],
|
||||||
|
string $hint = 'Die unter fields genannten Eingaben korrigieren und erneut senden.'
|
||||||
|
): WP_Error {
|
||||||
|
return self::make('validation_failed', $message, $hint, 400, $fields === [] ? [] : ['fields' => $fields]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFound(
|
||||||
|
string $message,
|
||||||
|
string $hint = 'ID prüfen, z. B. über lit/content-query (direkt: lit-content-query) oder lit/media-search '
|
||||||
|
. '(direkt: lit-media-search) suchen.'
|
||||||
|
): WP_Error {
|
||||||
|
return self::make('not_found', $message, $hint, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** $message/$hint replace the post-oriented defaults (e.g. for the menu lock menu_rev). */
|
||||||
|
public static function revMismatch(?string $message = null, ?string $hint = null): WP_Error
|
||||||
|
{
|
||||||
|
return self::make(
|
||||||
|
'rev_mismatch',
|
||||||
|
$message ?? 'Der Inhalt wurde seit dem letzten Lesen geändert; nichts wurde geschrieben.',
|
||||||
|
$hint ?? 'Neu lesen (z. B. lit/content-get, direkt: lit-content-get) und die Änderung auf dem aktuellen '
|
||||||
|
. 'Stand wiederholen.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $usages
|
||||||
|
*/
|
||||||
|
public static function inUse(string $message, array $usages): WP_Error
|
||||||
|
{
|
||||||
|
return self::make(
|
||||||
|
'in_use',
|
||||||
|
$message,
|
||||||
|
'Verwendungen prüfen; nur mit force: true trotzdem ausführen.',
|
||||||
|
409,
|
||||||
|
['usages' => $usages]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $details
|
||||||
|
*/
|
||||||
|
public static function confirmRequired(string $message, array $details = []): WP_Error
|
||||||
|
{
|
||||||
|
return self::make(
|
||||||
|
'confirm_required',
|
||||||
|
$message,
|
||||||
|
'Beim Nutzer nachfragen und dann mit confirm: true erneut aufrufen.',
|
||||||
|
409,
|
||||||
|
$details
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unsupported(string $message, string $hint): WP_Error
|
||||||
|
{
|
||||||
|
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<string, string> $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.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $details Extra error details (e.g. a backup that stays).
|
||||||
|
* @param string|null $hint Replaces the default hint (e.g. "re-read before retrying").
|
||||||
|
* @param string|null $state Replaces "Nichts wurde gespeichert." when the rollback left
|
||||||
|
* something behind on purpose (a backup or revision).
|
||||||
|
*/
|
||||||
|
public static function writeFailed(
|
||||||
|
string $reason,
|
||||||
|
array $details = [],
|
||||||
|
?string $hint = null,
|
||||||
|
?string $state = null
|
||||||
|
): WP_Error {
|
||||||
|
return self::make(
|
||||||
|
'write_failed',
|
||||||
|
trim('Speichern fehlgeschlagen: ' . $reason) . ' ' . ($state ?? 'Nichts wurde gespeichert.'),
|
||||||
|
$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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The $state for writeFailed() when a clean rollback deliberately keeps something, e.g.
|
||||||
|
* "Revision 811" or "Sicherung 88": the change itself is not saved, but "Nichts wurde
|
||||||
|
* gespeichert" would be untrue.
|
||||||
|
*/
|
||||||
|
public static function notSavedKeeping(string $kept): string
|
||||||
|
{
|
||||||
|
return sprintf('Die Änderung wurde nicht gespeichert (%s bleibt erhalten).', $kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A write failed and could not be rolled back completely – something may have been saved.
|
||||||
|
* $state and $hint replace the post-oriented defaults for writes that are not about a post
|
||||||
|
* (e.g. menus); $extra is merged into the error details.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $extra
|
||||||
|
*/
|
||||||
|
public static function partialWrite(
|
||||||
|
string $reason,
|
||||||
|
?int $postId = null,
|
||||||
|
?int $revisionId = null,
|
||||||
|
?string $state = null,
|
||||||
|
?string $hint = null,
|
||||||
|
array $extra = []
|
||||||
|
): WP_Error {
|
||||||
|
$state ??= $postId === null
|
||||||
|
? 'Möglicherweise wurde trotzdem ein Entwurf angelegt.'
|
||||||
|
: sprintf('Eintrag %d ist möglicherweise teilweise geändert bzw. angelegt worden', $postId)
|
||||||
|
. ($revisionId === null ? '.' : sprintf(' (Stand vorher: Revision %d).', $revisionId));
|
||||||
|
$details = array_merge($extra, array_filter(
|
||||||
|
['post_id' => $postId, 'revision_id' => $revisionId],
|
||||||
|
static fn (?int $id): bool => $id !== null
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::make(
|
||||||
|
'partial_write',
|
||||||
|
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, '
|
||||||
|
. 'direkt: lit-content-get; bei einem neuen Entwurf lit/content-query, direkt: 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string, string>
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
'internal_error',
|
||||||
|
'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.',
|
||||||
|
'Später erneut versuchen oder den Administrator der Website informieren.',
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
public const OPTION = 'lit_mcp_exposure';
|
||||||
|
public const DIRECT = 'direct';
|
||||||
|
public const INDIRECT = 'indirect';
|
||||||
|
public const DISABLED = 'disabled';
|
||||||
|
public const MODES = [self::DIRECT, self::INDIRECT, self::DISABLED];
|
||||||
|
|
||||||
|
/** Option key prefix of a group's mode: "@group:<integration id>" => mode. */
|
||||||
|
public const GROUP_PREFIX = '@group:';
|
||||||
|
|
||||||
|
/** Option key prefix of a group's read-only switch: "@readonly:<integration id>" => 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
|
||||||
|
* resource) can be direct or disabled, but never indirect.
|
||||||
|
*/
|
||||||
|
public const DIRECT_ONLY = ['lit/media-preview', 'lit/media-download'];
|
||||||
|
|
||||||
|
/** Entry point for every conversation – must always stay a direct tool. */
|
||||||
|
public const REQUIRED = ['lit/guide'];
|
||||||
|
|
||||||
|
/** @var array<string, string> Ability name => mode. */
|
||||||
|
private array $tools = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> Group id => mode. */
|
||||||
|
private array $groupModes = [];
|
||||||
|
|
||||||
|
/** @var array<string, true> Group ids switched to "nur lesen". */
|
||||||
|
private array $readonlyGroups = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ability name => group id. [] = no groups known (withGroups() not called: group entries have
|
||||||
|
* no effect); null = a lazy $groupSource is set and not resolved yet (groupOf() resolves it).
|
||||||
|
*
|
||||||
|
* @var array<string, string>|null
|
||||||
|
*/
|
||||||
|
private ?array $groups = [];
|
||||||
|
|
||||||
|
/** @var (callable(): array<string, string>)|null Lazy source of $groups. */
|
||||||
|
private $groupSource = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string|bool> $overrides Option content: ability name => mode, plus
|
||||||
|
* "@group:<id>" => mode and "@readonly:<id>" => true.
|
||||||
|
*/
|
||||||
|
public function __construct(array $overrides = [])
|
||||||
|
{
|
||||||
|
foreach ($overrides as $key => $value) {
|
||||||
|
['kind' => $kind, 'id' => $id] = self::parseKey((string) $key);
|
||||||
|
if ($kind === 'group') {
|
||||||
|
if (is_string($value) && in_array($value, self::MODES, true)) {
|
||||||
|
$this->groupModes[$id] = $value;
|
||||||
|
}
|
||||||
|
} elseif ($kind === 'readonly') {
|
||||||
|
if ($value === true) {
|
||||||
|
$this->readonlyGroups[$id] = true;
|
||||||
|
}
|
||||||
|
} elseif (is_string($value)) {
|
||||||
|
$this->tools[$id] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromOption(): self
|
||||||
|
{
|
||||||
|
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<string, string>|(callable(): array<string, string>) $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
|
||||||
|
{
|
||||||
|
if (self::isRequired($ability->name())) {
|
||||||
|
return $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 || self::isRequired($name)) {
|
||||||
|
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
|
||||||
|
&& ! self::isRequired($name)
|
||||||
|
&& ! 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 (self::isRequired($ability->name())) {
|
||||||
|
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
|
||||||
|
{
|
||||||
|
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Ability> $abilities
|
||||||
|
* @return list<Ability>
|
||||||
|
*/
|
||||||
|
public function enabled(array $abilities): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$abilities,
|
||||||
|
fn (Ability $ability): bool => $this->mode($ability) !== self::DISABLED
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-group gate: whether the ability named $name is among $abilities and not disabled.
|
||||||
|
* lit/content-create's divi input follows lit/divi-insert this way (security M2), so the Divi
|
||||||
|
* group's "nur lesen"/disabled switch also stops Divi layouts written through another group.
|
||||||
|
*
|
||||||
|
* @param list<Ability> $abilities
|
||||||
|
*/
|
||||||
|
public function allows(string $name, array $abilities): bool
|
||||||
|
{
|
||||||
|
foreach ($abilities as $ability) {
|
||||||
|
if ($ability->name() === $name) {
|
||||||
|
return $this->mode($ability) !== self::DISABLED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Ability> $abilities
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
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 (self::isRequired($name)) {
|
||||||
|
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. Keeps valid ability entries (name => allowed
|
||||||
|
* mode), "@group:<id>" => mode and "@readonly:<id>" => true; drops everything else. With
|
||||||
|
* $groupIds, only those group ids are accepted.
|
||||||
|
*
|
||||||
|
* @param list<string>|null $groupIds Known group (integration) ids; null = any well-formed id.
|
||||||
|
* @return array<string, string|true>
|
||||||
|
*/
|
||||||
|
public static function sanitize(mixed $input, ?array $groupIds = null): array
|
||||||
|
{
|
||||||
|
if (! is_array($input)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean = [];
|
||||||
|
foreach ($input as $key => $value) {
|
||||||
|
if (! is_string($key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
['kind' => $kind, 'id' => $id] = self::parseKey($key);
|
||||||
|
if ($kind === 'group') {
|
||||||
|
if (
|
||||||
|
self::isGroupId($id, $groupIds)
|
||||||
|
&& is_string($value)
|
||||||
|
&& in_array($value, self::MODES, true)
|
||||||
|
) {
|
||||||
|
$clean[$key] = $value;
|
||||||
|
}
|
||||||
|
} elseif ($kind === 'readonly') {
|
||||||
|
if (
|
||||||
|
self::isGroupId($id, $groupIds)
|
||||||
|
&& ($value === true || $value === '1' || $value === 1)
|
||||||
|
) {
|
||||||
|
$clean[$key] = true;
|
||||||
|
}
|
||||||
|
} elseif (
|
||||||
|
is_string($value)
|
||||||
|
&& preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $key) === 1
|
||||||
|
&& self::isAllowed($key, $value)
|
||||||
|
) {
|
||||||
|
$clean[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($clean);
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an option key names: "@group:<id>" a group's mode, "@readonly:<id>" a group's
|
||||||
|
* read-only switch, anything else an ability (id = the whole key).
|
||||||
|
*
|
||||||
|
* @return array{kind: 'group'|'readonly'|'tool', id: string}
|
||||||
|
*/
|
||||||
|
private static function parseKey(string $key): array
|
||||||
|
{
|
||||||
|
foreach (['group' => self::GROUP_PREFIX, 'readonly' => self::READONLY_PREFIX] as $kind => $prefix) {
|
||||||
|
if (str_starts_with($key, $prefix)) {
|
||||||
|
return ['kind' => $kind, 'id' => substr($key, strlen($prefix))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['kind' => 'tool', 'id' => $key];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isRequired(string $name): bool
|
||||||
|
{
|
||||||
|
return in_array($name, self::REQUIRED, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string>|null $groupIds */
|
||||||
|
private static function isGroupId(string $id, ?array $groupIds): bool
|
||||||
|
{
|
||||||
|
if (preg_match('#^[a-z0-9-]+$#D', $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use LIT\MCP\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a step after a write has already succeeded (listeners, change log): a failure
|
||||||
|
* there must not turn the saved change into an error. It is logged and reported as warning.
|
||||||
|
*/
|
||||||
|
final class FollowUp
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $warnings Receives $warning when the step fails.
|
||||||
|
*/
|
||||||
|
public static function run(callable $step, string $context, string $warning, array &$warnings): mixed
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $step();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::failure($context, $e);
|
||||||
|
$warnings[] = $warning;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP encodes an empty array as JSON [] – for schema type "object" the client
|
||||||
|
* must see {}. WordPress' output validation accepts stdClass for objects.
|
||||||
|
*/
|
||||||
|
final class JsonMap
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $map
|
||||||
|
* @return array<string, mixed>|stdClass
|
||||||
|
*/
|
||||||
|
public static function of(array $map): array|stdClass
|
||||||
|
{
|
||||||
|
return $map === [] ? new stdClass() : $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short hash used as optimistic-lock handle ("rev", spec §5.3).
|
||||||
|
*/
|
||||||
|
final class Rev
|
||||||
|
{
|
||||||
|
/** Hash of a single string, e.g. post_content (Divi layouts). */
|
||||||
|
public static function of(string $content): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', $content), 0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rev of an entry for the content tools: changes when content, title, excerpt or
|
||||||
|
* status change, or when anything else touched the post row (post_modified_gmt),
|
||||||
|
* so parallel edits beyond post_content are detected as well.
|
||||||
|
*/
|
||||||
|
public static function ofPost(WP_Post $post): string
|
||||||
|
{
|
||||||
|
return self::of(implode("\0", [
|
||||||
|
(string) $post->post_content,
|
||||||
|
(string) $post->post_title,
|
||||||
|
(string) $post->post_excerpt,
|
||||||
|
(string) $post->post_status,
|
||||||
|
(string) $post->post_modified_gmt,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rev of a menu ("menu_rev", optimistic lock of lit/menu-set-items): covers, per item in
|
||||||
|
* menu order, exactly what lit/menu-set-items compares and writes – id, parent, position,
|
||||||
|
* title, url and linked object. Other item data (classes, target, …) is never rewritten
|
||||||
|
* by the tool and therefore not part of the lock.
|
||||||
|
*
|
||||||
|
* @param array<int|string, object> $items wp_get_nav_menu_items() result – may have gaps in
|
||||||
|
* its keys (core filters it), only the order counts.
|
||||||
|
*/
|
||||||
|
public static function ofMenu(array $items): string
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$rows[] = implode("\0", [
|
||||||
|
(string) (int) ($item->ID ?? 0),
|
||||||
|
(string) (int) ($item->menu_item_parent ?? 0),
|
||||||
|
(string) (int) ($item->menu_order ?? 0),
|
||||||
|
(string) ($item->title ?? ''),
|
||||||
|
(string) ($item->url ?? ''),
|
||||||
|
(string) ($item->object ?? ''),
|
||||||
|
(string) (int) ($item->object_id ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::of('menu' . "\1" . implode("\1", $rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether $input carries the current rev of $post (optimistic lock of the post tools).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
*/
|
||||||
|
public static function matches(array $input, WP_Post $post): bool
|
||||||
|
{
|
||||||
|
return isset($input['rev']) && $input['rev'] === self::ofPost($post);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the undo steps of a failed write. Every step runs, even after an earlier one failed;
|
||||||
|
* a step fails by throwing. The caller turns the collected messages into the log detail and
|
||||||
|
* picks WriteFailed::rolledBack() (no failures) or WriteFailed::partial().
|
||||||
|
*/
|
||||||
|
final class Rollback
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param callable(): mixed ...$steps In the order they must run.
|
||||||
|
* @return list<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small text helper shared by every summary that must stay bounded in size (outlines, node
|
||||||
|
* summaries, the rendered-page excerpt): extracts are a fixed-size preview, never an unbounded
|
||||||
|
* copy of user/plugin content. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class Text
|
||||||
|
{
|
||||||
|
/** Shortens to at most $max characters (multi-byte aware), appending an ellipsis. */
|
||||||
|
public static function shorten(string $text, int $max): string
|
||||||
|
{
|
||||||
|
return mb_strlen($text) > $max ? rtrim(mb_substr($text, 0, $max - 1)) . '…' : $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform success result of every writing ability (spec §8.1).
|
||||||
|
* The adapter returns it as structuredContent plus JSON text.
|
||||||
|
*/
|
||||||
|
final class ToolResult
|
||||||
|
{
|
||||||
|
private const RESERVED = [
|
||||||
|
'ok', 'summary', 'changed', 'outline', 'rev', 'warnings',
|
||||||
|
'revision_id', 'snapshot_id', 'log_id', 'preview_url', 'edit_url',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var list<array{id: string, type: string, action: string, label: string}> */
|
||||||
|
private array $changed = [];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $warnings = [];
|
||||||
|
|
||||||
|
private ?string $outline = null;
|
||||||
|
private ?string $rev = null;
|
||||||
|
private ?int $revisionId = null;
|
||||||
|
private ?string $snapshotId = null;
|
||||||
|
private ?int $logId = null;
|
||||||
|
private ?string $previewUrl = null;
|
||||||
|
private ?string $editUrl = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private array $extra = [];
|
||||||
|
|
||||||
|
private function __construct(private string $summary)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ok(string $summary): self
|
||||||
|
{
|
||||||
|
return new self($summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changed(string $id, string $type, string $action, string $label = ''): self
|
||||||
|
{
|
||||||
|
$this->changed[] = ['id' => $id, 'type' => $type, 'action' => $action, 'label' => $label];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warning(string $warning): self
|
||||||
|
{
|
||||||
|
$this->warnings[] = $warning;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $warnings */
|
||||||
|
public function warnings(array $warnings): self
|
||||||
|
{
|
||||||
|
foreach ($warnings as $warning) {
|
||||||
|
$this->warning($warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outline(?string $outline): self
|
||||||
|
{
|
||||||
|
$this->outline = $outline;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rev(?string $rev): self
|
||||||
|
{
|
||||||
|
$this->rev = $rev;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revisionId(?int $revisionId): self
|
||||||
|
{
|
||||||
|
$this->revisionId = $revisionId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function snapshotId(?string $snapshotId): self
|
||||||
|
{
|
||||||
|
$this->snapshotId = $snapshotId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logId(?int $logId): self
|
||||||
|
{
|
||||||
|
$this->logId = $logId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function urls(?string $previewUrl, ?string $editUrl): self
|
||||||
|
{
|
||||||
|
$this->previewUrl = $previewUrl === '' ? null : $previewUrl;
|
||||||
|
$this->editUrl = $editUrl === '' ? null : $editUrl;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(string $key, mixed $value): self
|
||||||
|
{
|
||||||
|
if (in_array($key, self::RESERVED, true)) {
|
||||||
|
throw new InvalidArgumentException(sprintf('ToolResult key "%s" is reserved.', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->extra[$key] = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
[
|
||||||
|
'ok' => true,
|
||||||
|
'summary' => $this->summary,
|
||||||
|
'changed' => $this->changed,
|
||||||
|
'outline' => $this->outline,
|
||||||
|
'rev' => $this->rev,
|
||||||
|
'warnings' => $this->warnings,
|
||||||
|
'revision_id' => $this->revisionId,
|
||||||
|
'snapshot_id' => $this->snapshotId,
|
||||||
|
'log_id' => $this->logId,
|
||||||
|
'preview_url' => $this->previewUrl,
|
||||||
|
'edit_url' => $this->editUrl,
|
||||||
|
],
|
||||||
|
$this->extra
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $extraProperties
|
||||||
|
* @param list<string> $extraRequired
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function schema(array $extraProperties = [], array $extraRequired = []): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => array_merge(
|
||||||
|
[
|
||||||
|
'ok' => ['type' => 'boolean'],
|
||||||
|
'summary' => ['type' => 'string'],
|
||||||
|
'changed' => [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'id' => ['type' => 'string'],
|
||||||
|
'type' => ['type' => 'string'],
|
||||||
|
'action' => ['type' => 'string'],
|
||||||
|
'label' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
'required' => ['id', 'type', 'action', 'label'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'outline' => ['type' => ['string', 'null']],
|
||||||
|
'rev' => ['type' => ['string', 'null']],
|
||||||
|
'warnings' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||||
|
'revision_id' => ['type' => ['integer', 'null']],
|
||||||
|
'snapshot_id' => ['type' => ['string', 'null']],
|
||||||
|
'log_id' => ['type' => ['integer', 'null']],
|
||||||
|
'preview_url' => ['type' => ['string', 'null']],
|
||||||
|
'edit_url' => ['type' => ['string', 'null']],
|
||||||
|
],
|
||||||
|
$extraProperties
|
||||||
|
),
|
||||||
|
'required' => array_merge(
|
||||||
|
['ok', 'summary', 'changed', 'warnings', 'rev', 'revision_id', 'log_id'],
|
||||||
|
$extraRequired
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (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<string, mixed> $details Extra error details for the client. */
|
||||||
|
private function __construct(
|
||||||
|
public readonly string $reason,
|
||||||
|
public readonly bool $rolledBack,
|
||||||
|
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(
|
||||||
|
sprintf('%s [%s]', $internal, $rolledBack ? 'rolled back' : 'rollback incomplete'),
|
||||||
|
0,
|
||||||
|
$previous
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, mixed> $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<string, mixed> $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->hint, $this->state)
|
||||||
|
: Errors::partialWrite(
|
||||||
|
$this->reason,
|
||||||
|
$this->postId,
|
||||||
|
$this->revisionId,
|
||||||
|
$this->state,
|
||||||
|
$this->hint,
|
||||||
|
$this->details
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP;
|
||||||
|
|
||||||
|
use WP_User;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role gate: only users whose role is allowed get the lit_mcp_use capability.
|
||||||
|
*
|
||||||
|
* The capability is granted dynamically via user_has_cap, so no role objects
|
||||||
|
* in the database are modified.
|
||||||
|
*/
|
||||||
|
final class Access
|
||||||
|
{
|
||||||
|
public const CAPABILITY = 'lit_mcp_use';
|
||||||
|
public const OPTION = 'lit_mcp_allowed_roles';
|
||||||
|
private const DEFAULT_ROLES = ['administrator'];
|
||||||
|
private const ADAPTER_META_TOOLS = [
|
||||||
|
'discover_abilities',
|
||||||
|
'get_ability_info',
|
||||||
|
'execute_ability',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_filter('user_has_cap', [$this, 'grantCapability'], 10, 4);
|
||||||
|
|
||||||
|
foreach (self::ADAPTER_META_TOOLS as $tool) {
|
||||||
|
add_filter(
|
||||||
|
"mcp_adapter_{$tool}_capability",
|
||||||
|
static fn (): string => self::CAPABILITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
if (! is_array($roles)) {
|
||||||
|
$roles = self::DEFAULT_ROLES;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($roles, 'is_string'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, bool> $allcaps
|
||||||
|
* @param list<string> $caps
|
||||||
|
* @param array<int, mixed> $args
|
||||||
|
* @return array<string, bool>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\Access;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab "Zugriff": roles allowed to use LIT MCP and the active OAuth connections
|
||||||
|
* with revocation (spec §8.4).
|
||||||
|
*/
|
||||||
|
final class AccessTab implements AdminTab
|
||||||
|
{
|
||||||
|
public const GROUP = 'lit_mcp_access';
|
||||||
|
public const REVOKE_ACTION = 'lit_mcp_revoke';
|
||||||
|
|
||||||
|
private AdminPostAction $action;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Access $access,
|
||||||
|
private ConnectionRepository $connections,
|
||||||
|
?callable $terminate = null
|
||||||
|
) {
|
||||||
|
$this->action = new AdminPostAction($terminate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return 'zugriff';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Zugriff', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSettings(): void
|
||||||
|
{
|
||||||
|
register_setting(self::GROUP, Access::OPTION, [
|
||||||
|
'type' => 'array',
|
||||||
|
'sanitize_callback' => [self::class, 'sanitizeRoles'],
|
||||||
|
'default' => ['administrator'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerActions(): void
|
||||||
|
{
|
||||||
|
add_action('admin_post_' . self::REVOKE_ACTION, [$this, 'handleRevoke']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps only existing role slugs. Runs twice on the very first save (update_option
|
||||||
|
* falls through to add_option, which sanitizes again), so each warning is added
|
||||||
|
* only once. The settings page itself needs manage_options, not lit_mcp_use, so
|
||||||
|
* removing the current user's own role is allowed – but warned about.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function sanitizeRoles(mixed $input): array
|
||||||
|
{
|
||||||
|
$existing = array_keys((array) wp_roles()->roles);
|
||||||
|
$roles = array_values(array_unique(array_intersect(
|
||||||
|
array_filter(is_array($input) ? $input : [], 'is_string'),
|
||||||
|
$existing
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($roles === []) {
|
||||||
|
self::warnOnce(
|
||||||
|
'lit_mcp_no_roles',
|
||||||
|
__('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
} elseif (self::currentUserLosesAccess($roles)) {
|
||||||
|
self::warnOnce(
|
||||||
|
'lit_mcp_own_role',
|
||||||
|
__(
|
||||||
|
'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
$allowed = $this->access->allowedRoles();
|
||||||
|
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
|
||||||
|
|
||||||
|
echo '<form method="post" action="options.php">';
|
||||||
|
settings_fields(self::GROUP);
|
||||||
|
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
|
||||||
|
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
|
||||||
|
printf(
|
||||||
|
'<table class="form-table" role="presentation"><tbody><tr><th scope="row">%1$s</th><td>'
|
||||||
|
. '<fieldset><legend class="screen-reader-text"><span>%1$s</span></legend>',
|
||||||
|
esc_html($legend)
|
||||||
|
);
|
||||||
|
foreach (wp_roles()->get_names() as $role => $name) {
|
||||||
|
printf(
|
||||||
|
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
|
||||||
|
esc_attr(Access::OPTION),
|
||||||
|
esc_attr((string) $role),
|
||||||
|
checked(in_array($role, $allowed, true), true, false),
|
||||||
|
esc_html(translate_user_role((string) $name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</fieldset><p class="description">'
|
||||||
|
. esc_html__('Nur Benutzer dieser Rollen können einen KI-Assistenten verbinden.', 'wp-lit-mcp') . ' '
|
||||||
|
. esc_html__('Innerhalb der Tools gelten zusätzlich ihre normalen WordPress-Rechte.', 'wp-lit-mcp')
|
||||||
|
. '</p></td></tr></tbody></table>';
|
||||||
|
submit_button();
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
$this->renderConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleRevoke(): void
|
||||||
|
{
|
||||||
|
$this->action->guard();
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below.
|
||||||
|
$uuid = isset($_POST['uuid']) && is_string($_POST['uuid'])
|
||||||
|
? sanitize_text_field(wp_unslash($_POST['uuid']))
|
||||||
|
: '';
|
||||||
|
$userId = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
check_admin_referer(self::REVOKE_ACTION . '_' . $uuid);
|
||||||
|
|
||||||
|
$revoked = $this->connections->revoke($userId, $uuid);
|
||||||
|
$this->action->redirect($this->slug(), $revoked ? 'revoked' : 'revoke_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function warnOnce(string $code, string $message): void
|
||||||
|
{
|
||||||
|
foreach ((array) get_settings_errors(Access::OPTION) as $error) {
|
||||||
|
if (is_array($error) && ($error['code'] ?? '') === $code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_settings_error(Access::OPTION, $code, $message, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $roles */
|
||||||
|
private static function currentUserLosesAccess(array $roles): bool
|
||||||
|
{
|
||||||
|
// Multisite super admins pass every capability check regardless of role.
|
||||||
|
if (is_multisite() && is_super_admin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
|
||||||
|
return array_intersect((array) $user->roles, $roles) === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderConnections(): void
|
||||||
|
{
|
||||||
|
echo '<h2>' . esc_html__('Verbindungen', 'wp-lit-mcp') . '</h2>';
|
||||||
|
$connections = $this->connections->all();
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<table class="widefat striped lit-mcp-connections"><thead><tr>'
|
||||||
|
. '<th scope="col" class="column-user">%s</th><th scope="col" class="column-client">%s</th>'
|
||||||
|
. '<th scope="col" class="column-created">%s</th><th scope="col" class="column-last-used">%s</th>'
|
||||||
|
. '<th scope="col" class="column-actions"><span class="screen-reader-text">%s</span></th>'
|
||||||
|
. '</tr></thead><tbody>',
|
||||||
|
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 === []) {
|
||||||
|
printf(
|
||||||
|
'<tr class="no-items"><td class="colspanchange" colspan="5">%s %s <a href="%s">%s</a></td></tr>',
|
||||||
|
esc_html__('Noch keine Verbindung.', 'wp-lit-mcp'),
|
||||||
|
esc_html__('So verbindest du einen KI-Assistenten:', 'wp-lit-mcp'),
|
||||||
|
esc_url(SettingsPage::url('verbindung')),
|
||||||
|
esc_html__('Tab „Verbindung“', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$this->renderConnectionRow($connection);
|
||||||
|
}
|
||||||
|
echo '</tbody></table>';
|
||||||
|
|
||||||
|
if ($connections !== []) {
|
||||||
|
echo '<p class="description">' . esc_html__(
|
||||||
|
'„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
) . '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: int, user_login: string, uuid: string, name: string,
|
||||||
|
* app_id: string, created: int, last_used: ?int} $connection
|
||||||
|
*/
|
||||||
|
private function renderConnectionRow(array $connection): void
|
||||||
|
{
|
||||||
|
$dateFormat = (string) get_option('date_format', 'd.m.Y');
|
||||||
|
$timeFormat = (string) get_option('time_format', 'H:i');
|
||||||
|
|
||||||
|
echo '<tr>';
|
||||||
|
echo '<td class="column-user">' . esc_html($connection['user_login']) . '</td>';
|
||||||
|
printf(
|
||||||
|
'<td class="column-client"><strong>%s</strong><br><span class="lit-mcp-muted">%s</span></td>',
|
||||||
|
esc_html($connection['name']),
|
||||||
|
esc_html($connection['app_id'])
|
||||||
|
);
|
||||||
|
printf(
|
||||||
|
'<td class="column-created">%s</td>',
|
||||||
|
esc_html((string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created']))
|
||||||
|
);
|
||||||
|
$lastUsed = $connection['last_used'] === null
|
||||||
|
? '–'
|
||||||
|
: (string) wp_date($dateFormat, $connection['last_used']);
|
||||||
|
echo '<td class="column-last-used">' . esc_html($lastUsed) . '</td>';
|
||||||
|
echo '<td class="column-actions">';
|
||||||
|
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
||||||
|
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::REVOKE_ACTION));
|
||||||
|
printf('<input type="hidden" name="user_id" value="%s">', esc_attr((string) $connection['user_id']));
|
||||||
|
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
|
||||||
|
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
|
||||||
|
$confirm = esc_js(sprintf(
|
||||||
|
/* translators: %s: client name */
|
||||||
|
__('Verbindung „%s“ widerrufen? Der Assistent 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 '</form></td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin notices for administrators (Etappe-0 follow-up d). Uses only cached check
|
||||||
|
* results so no HTTP request runs on ordinary admin page loads.
|
||||||
|
*/
|
||||||
|
final class AdminNotices
|
||||||
|
{
|
||||||
|
public function __construct(private ConnectionStatus $status)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_action('admin_notices', [$this, 'render']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function messages(bool $onOwnPage): array
|
||||||
|
{
|
||||||
|
$messages = [];
|
||||||
|
if (! $this->status->oauthEnabled()) {
|
||||||
|
$messages[] = __(
|
||||||
|
'LIT MCP: Der OAuth-Server ist deaktiviert (Filter wpmedia_mcp_oauth_server_enabled).',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
) . ' ' . __('KI-Assistenten können sich nicht verbinden.', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fire whenever the cached discovery result is not 'good' (this also covers
|
||||||
|
* 'recommended', which is how HealthCheck::run_self_check() itself reports both
|
||||||
|
* a genuine reachability problem and its acme-challenge-fingerprint "critical"
|
||||||
|
* case) — but only when oauth is enabled and permalinks are pretty. Those two
|
||||||
|
* conditions are exactly when run_self_check() short-circuits to its own
|
||||||
|
* disabled/permalink-missing early returns instead of a real check; guarding on
|
||||||
|
* them here (rather than trying to recognise their shape from the cached array)
|
||||||
|
* keeps this correct even though, in the vendor's current implementation,
|
||||||
|
* neither early return is ever persisted to the transient we read.
|
||||||
|
*/
|
||||||
|
$discovery = $this->status->discovery(false);
|
||||||
|
if (
|
||||||
|
$this->status->oauthEnabled()
|
||||||
|
&& $this->status->permalinksPretty()
|
||||||
|
&& is_array($discovery)
|
||||||
|
&& ($discovery['status'] ?? 'good') !== 'good'
|
||||||
|
) {
|
||||||
|
$messages[] = __(
|
||||||
|
'LIT MCP: Die OAuth-Discovery-Dokumente unter /.well-known/ sind nicht erreichbar.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
) . ' ' . __('Details unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($onOwnPage && ! $this->status->https()) {
|
||||||
|
$messages[] = __(
|
||||||
|
'LIT MCP: Die Website läuft ohne HTTPS; KI-Assistenten verbinden sich nur mit HTTPS-Websites.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
if (! current_user_can('manage_options')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
|
||||||
|
$onOwnPage = is_object($screen) && $screen->id === 'settings_page_' . SettingsPage::SLUG;
|
||||||
|
foreach ($this->messages($onOwnPage) as $message) {
|
||||||
|
printf('<div class="notice notice-warning"><p>%s</p></div>', esc_html($message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame of an admin-post.php handler of the settings page: only administrators
|
||||||
|
* (manage_options, else 403), and after the action a redirect back to the tab with a notice.
|
||||||
|
* The terminate seam replaces exit in tests.
|
||||||
|
*/
|
||||||
|
final class AdminPostAction
|
||||||
|
{
|
||||||
|
/** @var callable(): void */
|
||||||
|
private $terminate;
|
||||||
|
|
||||||
|
/** @param (callable(): void)|null $terminate Called after the redirect; default exit. */
|
||||||
|
public function __construct(?callable $terminate = null)
|
||||||
|
{
|
||||||
|
$this->terminate = $terminate ?? static function (): void {
|
||||||
|
exit;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops with 403 unless the current user may manage options. */
|
||||||
|
public function guard(): void
|
||||||
|
{
|
||||||
|
if (! current_user_can('manage_options')) {
|
||||||
|
wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirects to settings tab $slug with "lit_mcp_notice" = $notice and ends the request. */
|
||||||
|
public function redirect(string $slug, string $notice): void
|
||||||
|
{
|
||||||
|
wp_safe_redirect(SettingsPage::url($slug, ['lit_mcp_notice' => $notice]));
|
||||||
|
($this->terminate)();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tab of the settings page "Einstellungen → LIT MCP".
|
||||||
|
*/
|
||||||
|
interface AdminTab
|
||||||
|
{
|
||||||
|
public function slug(): string;
|
||||||
|
|
||||||
|
public function label(): string;
|
||||||
|
|
||||||
|
/** Registers Settings API options; runs on admin_init. */
|
||||||
|
public function registerSettings(): void;
|
||||||
|
|
||||||
|
/** Registers admin-post.php handlers; runs when the page is registered. */
|
||||||
|
public function registerActions(): void;
|
||||||
|
|
||||||
|
public function render(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use WPMedia\MCP\OAuth\Auth\TokenEndpoint;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth connections created by mcp-oauth (v1.1.2, TokenEndpoint): for every grant it
|
||||||
|
* creates one application password (name = client name, app_id = the CIMD client_id,
|
||||||
|
* always an https URL) and stores the refresh-token marker user meta
|
||||||
|
* `mcp_refresh_jti_<uuid>`. An application password counts as an LIT MCP connection
|
||||||
|
* only if BOTH hold: the marker meta exists and its app_id is an https URL. Any other
|
||||||
|
* application password of the user (other tools, manual ones) is neither listed nor
|
||||||
|
* revoked.
|
||||||
|
*
|
||||||
|
* Multisite: only users who are members of the current site are managed, and a
|
||||||
|
* non-super-admin never sees or revokes a super admin's connections.
|
||||||
|
*
|
||||||
|
* Not final so the access tab can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class ConnectionRepository
|
||||||
|
{
|
||||||
|
public function __construct(private ?\wpdb $db = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{user_id: int, user_login: string, uuid: string, name: string,
|
||||||
|
* app_id: string, created: int, last_used: ?int}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
$wpdb = $this->db ?? $GLOBALS['wpdb'];
|
||||||
|
$prefix = TokenEndpoint::REFRESH_JTI_META_PREFIX;
|
||||||
|
$rows = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT user_id, meta_key FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id, umeta_id",
|
||||||
|
$wpdb->esc_like($prefix) . '%'
|
||||||
|
));
|
||||||
|
|
||||||
|
$connections = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
$userId = (int) $row->user_id;
|
||||||
|
$uuid = substr((string) $row->meta_key, strlen($prefix));
|
||||||
|
if ($uuid === '' || ! $this->manageable($userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
|
||||||
|
if (! self::isMcpOauthPassword($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = get_userdata($userId);
|
||||||
|
$connections[] = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'user_login' => is_object($user) ? (string) $user->user_login : '#' . $userId,
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'name' => (string) ($item['name'] ?? ''),
|
||||||
|
'app_id' => (string) $item['app_id'],
|
||||||
|
'created' => (int) ($item['created'] ?? 0),
|
||||||
|
'last_used' => isset($item['last_used']) ? (int) $item['last_used'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(int $userId, string $uuid): bool
|
||||||
|
{
|
||||||
|
if ($userId <= 0 || $uuid === '' || ! $this->manageable($userId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$marker = (string) get_user_meta($userId, TokenEndpoint::REFRESH_JTI_META_PREFIX . $uuid, true);
|
||||||
|
if ($marker === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
|
||||||
|
if (! self::isMcpOauthPassword($item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires wp_delete_application_password, on which mcp-oauth purges its marker meta.
|
||||||
|
return \WP_Application_Passwords::delete_application_password($userId, $uuid) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current administrator may see and revoke this user's connections: only for
|
||||||
|
* users they may edit (edit_user, as for application passwords in the profile), and on
|
||||||
|
* multisite only members of this site, super admins only for other super admins.
|
||||||
|
*/
|
||||||
|
private function manageable(int $userId): bool
|
||||||
|
{
|
||||||
|
if (! current_user_can('edit_user', $userId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_multisite()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_super_admin($userId) && ! is_super_admin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_user_member_of_blog($userId, get_current_blog_id());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isMcpOauthPassword(mixed $item): bool
|
||||||
|
{
|
||||||
|
return is_array($item)
|
||||||
|
&& is_string($item['app_id'] ?? null)
|
||||||
|
&& str_starts_with($item['app_id'], 'https://');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\Server\ServerRegistrar;
|
||||||
|
use WP\MCP\Core\McpAdapter;
|
||||||
|
use WPMedia\MCP\OAuth\Auth\Discovery\HealthCheck;
|
||||||
|
use WPMedia\MCP\OAuth\Context as OAuthContext;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection checks for the settings page, admin notices and Site Health.
|
||||||
|
* Not final so notices and Site Health can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class ConnectionStatus
|
||||||
|
{
|
||||||
|
public const ENDPOINT_TRANSIENT = 'lit_mcp_endpoint_check';
|
||||||
|
private const CACHE_SECONDS = 300;
|
||||||
|
|
||||||
|
/** @var callable(): bool */
|
||||||
|
private $oauthEnabled;
|
||||||
|
|
||||||
|
public function __construct(?callable $oauthEnabled = null)
|
||||||
|
{
|
||||||
|
$this->oauthEnabled = $oauthEnabled ?? static fn (): bool => (new OAuthContext())->is_enabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function serverUrl(): string
|
||||||
|
{
|
||||||
|
return rest_url(ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function oauthEnabled(): bool
|
||||||
|
{
|
||||||
|
return ($this->oauthEnabled)();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adapterLoaded(): bool
|
||||||
|
{
|
||||||
|
return class_exists(McpAdapter::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function https(): bool
|
||||||
|
{
|
||||||
|
return str_starts_with((string) home_url(), 'https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a pretty permalink structure is active. HealthCheck::run_self_check()
|
||||||
|
* short-circuits to a 'recommended' result (never persisted to its transient)
|
||||||
|
* when this is false; used to tell that early return apart from a genuine
|
||||||
|
* discovery failure without depending on that vendor implementation detail.
|
||||||
|
*/
|
||||||
|
public function permalinksPretty(): bool
|
||||||
|
{
|
||||||
|
return (string) get_option('permalink_structure') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null mcp-oauth's discovery self check (label, status, …). */
|
||||||
|
public function discovery(bool $allowFetch): ?array
|
||||||
|
{
|
||||||
|
if (! $allowFetch) {
|
||||||
|
$cached = get_transient(HealthCheck::TRANSIENT_KEY);
|
||||||
|
|
||||||
|
return is_array($cached) ? $cached : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new HealthCheck(new OAuthContext()))->run_self_check();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, message: string}|null */
|
||||||
|
public function endpoint(bool $allowFetch): ?array
|
||||||
|
{
|
||||||
|
$cached = get_transient(self::ENDPOINT_TRANSIENT);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $allowFetch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = wp_remote_post(self::serverUrl(), [
|
||||||
|
'timeout' => 5,
|
||||||
|
'headers' => ['Content-Type' => 'application/json'],
|
||||||
|
'body' => '{"jsonrpc":"2.0","id":1,"method":"ping"}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
$result = ['ok' => false, 'message' => 'Endpunkt nicht erreichbar: ' . $response->get_error_message()];
|
||||||
|
} else {
|
||||||
|
$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' => 'Erreichbar; verlangt wie erwartet eine Anmeldung (HTTP 401).']
|
||||||
|
: ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)];
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient(self::ENDPOINT_TRANSIENT, $result, self::CACHE_SECONDS);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{label: string, ok: ?bool, message: string}> */
|
||||||
|
public function checks(bool $allowFetch): array
|
||||||
|
{
|
||||||
|
$adapter = $this->adapterLoaded();
|
||||||
|
$oauth = $this->oauthEnabled();
|
||||||
|
$https = $this->https();
|
||||||
|
$discovery = $this->discovery($allowFetch);
|
||||||
|
$endpoint = $this->endpoint($allowFetch);
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'label' => __('MCP-Adapter', 'wp-lit-mcp'),
|
||||||
|
'ok' => $adapter,
|
||||||
|
'message' => $adapter ? __('geladen', 'wp-lit-mcp') : __('fehlt', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => __('OAuth-Server', 'wp-lit-mcp'),
|
||||||
|
'ok' => $oauth,
|
||||||
|
'message' => $oauth
|
||||||
|
? __('aktiv', 'wp-lit-mcp')
|
||||||
|
: __('deaktiviert (Filter wpmedia_mcp_oauth_server_enabled)', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => __('HTTPS', 'wp-lit-mcp'),
|
||||||
|
'ok' => $https,
|
||||||
|
'message' => $https
|
||||||
|
? __('aktiv', 'wp-lit-mcp')
|
||||||
|
: __('Website läuft ohne HTTPS – KI-Assistenten verbinden sich nur über HTTPS', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => __('OAuth-Discovery', 'wp-lit-mcp'),
|
||||||
|
'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good',
|
||||||
|
'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' => __('MCP-Endpunkt', 'wp-lit-mcp'),
|
||||||
|
'ok' => $endpoint['ok'] ?? null,
|
||||||
|
'message' => $endpoint['message'] ?? __('noch nicht geprüft', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
final class ConnectionTab implements AdminTab
|
||||||
|
{
|
||||||
|
public function __construct(private ConnectionStatus $status)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return 'verbindung';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Verbindung', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSettings(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerActions(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
echo '<h2>' . esc_html__('So verbindest du einen KI-Assistenten', 'wp-lit-mcp') . '</h2>';
|
||||||
|
echo '<ol class="lit-mcp-steps"><li>';
|
||||||
|
printf(
|
||||||
|
'<label for="lit-mcp-url">%s</label>'
|
||||||
|
. '<span class="lit-mcp-copy-row copy-to-clipboard-container">'
|
||||||
|
. '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s">'
|
||||||
|
. '<button type="button" class="button lit-mcp-copy" data-clipboard-target="#lit-mcp-url">%s</button>'
|
||||||
|
. '<span class="success hidden" aria-hidden="true">%s</span></span>',
|
||||||
|
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 '</li><li>' . esc_html__(
|
||||||
|
'Im KI-Assistenten einen eigenen MCP-Server (Connector) hinzufügen und die URL einfügen.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
) . '</li><li>';
|
||||||
|
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('<a href="%s">%s</a>', esc_url(SettingsPage::url('zugriff')), esc_html__('Zugriff', 'wp-lit-mcp'))
|
||||||
|
);
|
||||||
|
echo '</li></ol>';
|
||||||
|
|
||||||
|
echo '<h2>' . esc_html__('Status', 'wp-lit-mcp') . '</h2><ul class="lit-mcp-status">';
|
||||||
|
foreach ($this->status->checks(true) as $check) {
|
||||||
|
[$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(
|
||||||
|
'<li class="lit-mcp-status-%1$s"><span class="dashicons dashicons-%1$s" aria-hidden="true"></span>'
|
||||||
|
. '<span class="screen-reader-text">%2$s</span> <strong>%3$s</strong>'
|
||||||
|
. ' <span class="lit-mcp-status-detail">%4$s</span></li>',
|
||||||
|
esc_attr($icon),
|
||||||
|
esc_html($state),
|
||||||
|
esc_html($check['label']),
|
||||||
|
esc_html($check['message'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</ul>';
|
||||||
|
printf(
|
||||||
|
'<p class="description">%s <a href="%s">%s</a>.</p>',
|
||||||
|
esc_html__('Weitere Prüfungen unter', 'wp-lit-mcp'),
|
||||||
|
esc_url(admin_url('site-health.php')),
|
||||||
|
esc_html__('Werkzeuge → Website-Zustand', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\Server\ServerRegistrar;
|
||||||
|
use Throwable;
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WPMedia\MCP\OAuth\Auth\JWT;
|
||||||
|
use WPMedia\MCP\OAuth\Auth\SecretManager;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records when an OAuth connection was last used, for "Zuletzt genutzt" in the tab "Zugriff".
|
||||||
|
*
|
||||||
|
* mcp-oauth authenticates with a JWT bound to an application password but never records
|
||||||
|
* its usage (WordPress only does that for Basic-Auth application passwords), so last_used
|
||||||
|
* stayed empty. After an authenticated request to our MCP route this calls WordPress' own
|
||||||
|
* WP_Application_Passwords::record_application_password_usage(), which writes at most once
|
||||||
|
* a day per password – the same data and throttle as in the user's profile.
|
||||||
|
*/
|
||||||
|
final class ConnectionUsage
|
||||||
|
{
|
||||||
|
/** @var callable(string): ?array<string, mixed> */
|
||||||
|
private $decode;
|
||||||
|
|
||||||
|
/** @var callable(int, string): mixed */
|
||||||
|
private $recordUsage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (callable(string): ?array<string, mixed>)|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<string, mixed> $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 || ! self::succeeded($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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only successful answers count as use: mcp-oauth answers auth failures with a
|
||||||
|
* WP_REST_Response(401), not a WP_Error (OAuthHttpTransport::build_auth_error_response).
|
||||||
|
*/
|
||||||
|
private static function succeeded(mixed $response): bool
|
||||||
|
{
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $response instanceof \WP_HTTP_Response || $response->get_status() < 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\Limits;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
public const GROUP = 'lit_mcp_limits';
|
||||||
|
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return 'limits';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Limits', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSettings(): void
|
||||||
|
{
|
||||||
|
register_setting(self::GROUP, Limits::OPTION, [
|
||||||
|
'type' => 'array',
|
||||||
|
'sanitize_callback' => [Limits::class, 'sanitize'],
|
||||||
|
'default' => Limits::DEFAULTS,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerActions(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
$values = (new Limits())->all();
|
||||||
|
$bounds = Limits::bounds();
|
||||||
|
$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
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'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 '<form method="post" action="options.php">';
|
||||||
|
settings_fields(self::GROUP);
|
||||||
|
echo '<table class="form-table" role="presentation"><tbody>';
|
||||||
|
foreach ($fields as $key => [$label, $unit, $description]) {
|
||||||
|
[$min, $max] = $bounds[$key];
|
||||||
|
printf(
|
||||||
|
'<tr><th scope="row"><label for="lit-mcp-%1$s">%2$s</label></th><td>'
|
||||||
|
. '<input id="lit-mcp-%1$s" class="small-text" type="number" min="%3$d" max="%4$d"'
|
||||||
|
. ' name="%5$s[%1$s]" value="%6$d" aria-describedby="lit-mcp-%1$s-description">'
|
||||||
|
. ' <span class="lit-mcp-unit">%7$s</span>'
|
||||||
|
. '<p class="description" id="lit-mcp-%1$s-description">%8$s</p>',
|
||||||
|
esc_attr($key),
|
||||||
|
esc_html($label),
|
||||||
|
esc_attr((string) $min),
|
||||||
|
esc_attr((string) $max),
|
||||||
|
esc_attr(Limits::OPTION),
|
||||||
|
esc_attr((string) $values[$key]),
|
||||||
|
esc_html($unit),
|
||||||
|
esc_html($description)
|
||||||
|
);
|
||||||
|
if ($key === 'upload_mb') {
|
||||||
|
$this->maybeRenderUploadSizeNotice($values['upload_mb']);
|
||||||
|
}
|
||||||
|
echo '</td></tr>';
|
||||||
|
}
|
||||||
|
echo '</tbody></table>';
|
||||||
|
// Design system snapshots are not available yet: no visible setting, but keep the saved value.
|
||||||
|
printf(
|
||||||
|
'<input type="hidden" name="%s[snapshot_count]" value="%d">',
|
||||||
|
esc_attr(Limits::OPTION),
|
||||||
|
(int) $values['snapshot_count']
|
||||||
|
);
|
||||||
|
submit_button();
|
||||||
|
echo '</form>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHP's own upload_max_filesize/post_max_size (via wp_max_upload_size()) can be lower than
|
||||||
|
* what is configured here – WordPress itself would then refuse a large upload/download before
|
||||||
|
* this limit ever applies. A warning only, since the two are independent knobs.
|
||||||
|
*/
|
||||||
|
private function maybeRenderUploadSizeNotice(int $uploadMb): void
|
||||||
|
{
|
||||||
|
$serverMaxMb = (int) floor(wp_max_upload_size() / (1024 * 1024));
|
||||||
|
if ($serverMaxMb <= 0 || $uploadMb <= $serverMaxMb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
/* translators: 1: configured limit in MB, 2: server limit in MB */
|
||||||
|
__('%1$d MB übersteigt das Server-Limit von %2$d MB (upload_max_filesize/post_max_size).', 'wp-lit-mcp'),
|
||||||
|
$uploadMb,
|
||||||
|
$serverMaxMb
|
||||||
|
);
|
||||||
|
printf('<div class="notice notice-warning inline"><p>%s</p></div>', esc_html($message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps -- method names are defined by WP_List_Table.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of change log entries (spec §8.3, ruling C5/Q13), built like WordPress' own list
|
||||||
|
* screens: the change is the primary column with row actions, the other columns can be
|
||||||
|
* hidden and the page size set via "Ansicht anpassen". WP_List_Table must be loaded before
|
||||||
|
* this file (LogTab takes care of that).
|
||||||
|
*/
|
||||||
|
final class LogListTable extends \WP_List_Table
|
||||||
|
{
|
||||||
|
/** Longer client names are shortened in the "Wer" column (full name as tooltip). */
|
||||||
|
private const CLIENT_LENGTH = 22;
|
||||||
|
|
||||||
|
/** Entries younger than a day show a relative time. */
|
||||||
|
private const RELATIVE_SECONDS = 86400;
|
||||||
|
|
||||||
|
/** @var (callable(): void)|null */
|
||||||
|
private $renderFilters;
|
||||||
|
|
||||||
|
private LogObjects $objects;
|
||||||
|
|
||||||
|
private int $now;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
* @param array<string, string> $toolLabels ability name => human label
|
||||||
|
* @param int|null $now current Unix time (tests), default time()
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private ChangeLog $log,
|
||||||
|
private array $filters,
|
||||||
|
?callable $renderFilters = null,
|
||||||
|
private array $toolLabels = [],
|
||||||
|
?LogObjects $objects = null,
|
||||||
|
?int $now = null
|
||||||
|
) {
|
||||||
|
$this->renderFilters = $renderFilters;
|
||||||
|
$this->objects = $objects ?? new LogObjects();
|
||||||
|
$this->now = $now ?? time();
|
||||||
|
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function get_columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Key "title" as on core's list screens: WordPress never offers it for hiding.
|
||||||
|
'title' => __('Änderung', 'wp-lit-mcp'),
|
||||||
|
'object' => __('Objekt', 'wp-lit-mcp'),
|
||||||
|
'user' => __('Wer', 'wp-lit-mcp'),
|
||||||
|
'created_at' => __('Zeit', 'wp-lit-mcp'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string */
|
||||||
|
protected function get_default_primary_column_name()
|
||||||
|
{
|
||||||
|
return 'title';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column headers and hidden columns come from WordPress (get_column_info()), so the
|
||||||
|
* "Ansicht anpassen" choices apply. Users and objects of the page are loaded in one go.
|
||||||
|
*/
|
||||||
|
public function prepare_items(): void
|
||||||
|
{
|
||||||
|
$perPage = $this->get_items_per_page(LogTab::PER_PAGE_OPTION, LogTab::PER_PAGE_DEFAULT);
|
||||||
|
$page = max(1, (int) $this->get_pagenum());
|
||||||
|
$data = $this->log->query($this->filters, $perPage, ($page - 1) * $perPage);
|
||||||
|
$this->items = $data['rows'];
|
||||||
|
$this->set_pagination_args(['total_items' => $data['total'], 'per_page' => $perPage]);
|
||||||
|
|
||||||
|
$userIds = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn (array $row): int => (int) ($row['user_id'] ?? 0),
|
||||||
|
$this->items
|
||||||
|
))));
|
||||||
|
if ($userIds !== []) {
|
||||||
|
cache_users($userIds);
|
||||||
|
}
|
||||||
|
$this->objects->prime($this->items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function no_items(): void
|
||||||
|
{
|
||||||
|
if (! LogTab::hasActiveFilter($this->filters)) {
|
||||||
|
esc_html_e('Noch keine Änderungen protokolliert.', 'wp-lit-mcp');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'%s <a href="%s">%s</a>',
|
||||||
|
esc_html__('Keine Einträge für diese Filter.', 'wp-lit-mcp'),
|
||||||
|
esc_url(SettingsPage::url('protokoll')),
|
||||||
|
esc_html__('Filter zurücksetzen', '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 '<div class="alignleft actions">';
|
||||||
|
($this->renderFilters)();
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks rows whose write did not complete (result "partial": failed after an
|
||||||
|
* irreversible step, spec §2 principle 7/8.2) so admins notice them while scrolling.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
*/
|
||||||
|
public function single_row($item): void
|
||||||
|
{
|
||||||
|
$status = self::status((string) ($item['result'] ?? ''));
|
||||||
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed class names.
|
||||||
|
echo $status === null ? '<tr>' : '<tr class="lit-mcp-row-' . $status[0] . '">';
|
||||||
|
$this->single_row_columns($item);
|
||||||
|
echo '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge class and word for a result that is not "ok", null for "ok".
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string}|null
|
||||||
|
*/
|
||||||
|
private static function status(string $result): ?array
|
||||||
|
{
|
||||||
|
return match ($result) {
|
||||||
|
'ok' => null,
|
||||||
|
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
|
||||||
|
'failed', 'error' => ['error', __('fehlgeschlagen', 'wp-lit-mcp')],
|
||||||
|
default => ['error', __('unbekannt', 'wp-lit-mcp')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary column: the summary, below it the tool's human label (technical name as tooltip).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
*/
|
||||||
|
protected function column_title($item): string
|
||||||
|
{
|
||||||
|
$status = self::status((string) ($item['result'] ?? ''));
|
||||||
|
$badge = $status === null ? '' : sprintf(
|
||||||
|
'<span class="lit-mcp-badge lit-mcp-badge-%s">%s</span> ',
|
||||||
|
esc_attr($status[0]),
|
||||||
|
esc_html($status[1])
|
||||||
|
);
|
||||||
|
|
||||||
|
$ability = (string) ($item['ability'] ?? '');
|
||||||
|
$tool = isset($this->toolLabels[$ability])
|
||||||
|
? sprintf(
|
||||||
|
'<span class="lit-mcp-muted" title="%s">%s</span>',
|
||||||
|
esc_attr($ability),
|
||||||
|
esc_html($this->toolLabels[$ability])
|
||||||
|
)
|
||||||
|
: '<span class="lit-mcp-muted">' . esc_html($ability) . '</span>';
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<div class="lit-mcp-summary">%s%s</div>%s',
|
||||||
|
$badge,
|
||||||
|
esc_html((string) ($item['summary'] ?? '')),
|
||||||
|
$tool
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Revision ansehen" and "Objekt öffnen" – each only where it leads somewhere the
|
||||||
|
* current user may go.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
* @param string $column_name
|
||||||
|
* @param string $primary
|
||||||
|
*/
|
||||||
|
protected function handle_row_actions($item, $column_name, $primary): string
|
||||||
|
{
|
||||||
|
if ($column_name !== $primary) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions = [];
|
||||||
|
$revision = $this->revisionUrl((int) ($item['revision_id'] ?? 0));
|
||||||
|
if ($revision !== '') {
|
||||||
|
$actions['revision'] = sprintf(
|
||||||
|
'<a href="%s">%s</a>',
|
||||||
|
esc_url($revision),
|
||||||
|
esc_html__('Revision ansehen', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$object = $this->object($item);
|
||||||
|
if ($object['url'] !== '') {
|
||||||
|
$actions['edit'] = sprintf(
|
||||||
|
'<a href="%s">%s</a>',
|
||||||
|
esc_url($object['url']),
|
||||||
|
esc_html__('Objekt öffnen', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions === []
|
||||||
|
? (string) parent::handle_row_actions($item, $column_name, $primary)
|
||||||
|
: (string) $this->row_actions($actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $item */
|
||||||
|
protected function column_object($item): string
|
||||||
|
{
|
||||||
|
return $this->object($item)['html'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
* @return array{html: string, url: string}
|
||||||
|
*/
|
||||||
|
private function object(array $item): array
|
||||||
|
{
|
||||||
|
return $this->objects->describe((string) ($item['object_type'] ?? ''), (string) ($item['object_id'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WordPress user; below it the connected client, shortened (full name as tooltip).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
*/
|
||||||
|
protected function column_user($item): string
|
||||||
|
{
|
||||||
|
$userId = (int) ($item['user_id'] ?? 0);
|
||||||
|
$user = get_userdata($userId);
|
||||||
|
$html = esc_html(is_object($user) ? (string) $user->display_name : '#' . $userId);
|
||||||
|
|
||||||
|
$client = (string) ($item['client'] ?? '');
|
||||||
|
if ($client !== '') {
|
||||||
|
$html .= sprintf(
|
||||||
|
'<br><span class="lit-mcp-muted" title="%s">%s</span>',
|
||||||
|
esc_attr($client),
|
||||||
|
esc_html(mb_strimwidth($client, 0, self::CLIENT_LENGTH, '…'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored in UTC (MySQL format). Within the last 24 hours relative ("vor 5 Minuten"),
|
||||||
|
* otherwise date and time in the site's timezone and formats; full time plus UTC as
|
||||||
|
* tooltip, as on WordPress' comments screen.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $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}$/D', $utc) === 1
|
||||||
|
? strtotime($utc . ' UTC')
|
||||||
|
: false;
|
||||||
|
if ($timestamp === false) {
|
||||||
|
return '–';
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = (string) wp_date((string) get_option('date_format'), $timestamp);
|
||||||
|
$time = (string) wp_date((string) get_option('time_format'), $timestamp);
|
||||||
|
$age = $this->now - $timestamp;
|
||||||
|
$text = $age >= 0 && $age < self::RELATIVE_SECONDS
|
||||||
|
/* translators: %s: time span, e.g. "5 Minuten" */
|
||||||
|
? sprintf(__('vor %s', 'wp-lit-mcp'), human_time_diff($timestamp, $this->now))
|
||||||
|
/* translators: 1: date, 2: time */
|
||||||
|
: sprintf(__('%1$s um %2$s', 'wp-lit-mcp'), $date, $time);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<time datetime="%s" title="%s">%s</time>',
|
||||||
|
esc_attr(gmdate('c', $timestamp)),
|
||||||
|
esc_attr(sprintf('%s %s (UTC: %s)', $date, $time, $utc)),
|
||||||
|
esc_html($text)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only when the user could actually act on it: revision_id set and the revision's
|
||||||
|
* parent post is one the current user may edit (ruling C5/Q13).
|
||||||
|
*/
|
||||||
|
private function revisionUrl(int $revisionId): string
|
||||||
|
{
|
||||||
|
if ($revisionId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$revision = get_post($revisionId);
|
||||||
|
$parentId = $revision instanceof WP_Post ? (int) $revision->post_parent : 0;
|
||||||
|
if ($parentId <= 0 || ! current_user_can('edit_post', $parentId)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return admin_url('revision.php?revision=' . $revisionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use WP_Post;
|
||||||
|
use WP_Term;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human names for the objects in the change log ("Seite: Kontakt" instead of "page 834"),
|
||||||
|
* linked to their edit screen where one exists and the current user may use it. prime()
|
||||||
|
* loads the posts and menus of a whole list page in one query each (no query per row).
|
||||||
|
* Deliberately not final so the list table can be unit-tested with a mock.
|
||||||
|
*/
|
||||||
|
class LogObjects
|
||||||
|
{
|
||||||
|
/** @var array<int, WP_Term> nav_menu terms of the current page, by term id */
|
||||||
|
private array $menus = [];
|
||||||
|
|
||||||
|
/** @param list<array<string, mixed>> $rows */
|
||||||
|
public function prime(array $rows): void
|
||||||
|
{
|
||||||
|
$postIds = [];
|
||||||
|
$menuIds = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$type = (string) ($row['object_type'] ?? '');
|
||||||
|
$id = (string) ($row['object_id'] ?? '');
|
||||||
|
if (! ctype_digit($id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($type === 'nav_menu') {
|
||||||
|
$menuIds[] = (int) $id;
|
||||||
|
} elseif ($this->isPostType($type)) {
|
||||||
|
$postIds[] = (int) $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($postIds !== []) {
|
||||||
|
_prime_post_caches(array_values(array_unique($postIds)), false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($menuIds !== []) {
|
||||||
|
$terms = get_terms([
|
||||||
|
'taxonomy' => 'nav_menu',
|
||||||
|
'include' => array_values(array_unique($menuIds)),
|
||||||
|
'hide_empty' => false,
|
||||||
|
]);
|
||||||
|
foreach (is_array($terms) ? $terms : [] as $term) {
|
||||||
|
if ($term instanceof WP_Term) {
|
||||||
|
$this->menus[(int) $term->term_id] = $term;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{html: string, url: string} escaped HTML for the "Objekt" column and the
|
||||||
|
* edit URL ('' when there is none to offer)
|
||||||
|
*/
|
||||||
|
public function describe(string $type, string $id): array
|
||||||
|
{
|
||||||
|
$numeric = ctype_digit($id);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$type === '' && $id === '' => ['html' => '–', 'url' => ''],
|
||||||
|
$type === 'nav_menu' && $numeric => $this->menu((int) $id),
|
||||||
|
$type === 'menu_location' => [
|
||||||
|
'html' => $this->prefixed(__('Menüposition', 'wp-lit-mcp'), $this->location($id)),
|
||||||
|
'url' => '',
|
||||||
|
],
|
||||||
|
$type === 'option' => $this->options($id),
|
||||||
|
$numeric && $this->isPostType($type) => $this->post($type, (int) $id),
|
||||||
|
default => ['html' => '<code>' . esc_html(trim($type . ' ' . $id)) . '</code>', 'url' => ''],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPostType(string $type): bool
|
||||||
|
{
|
||||||
|
return $type !== '' && post_type_exists($type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{html: string, url: string} */
|
||||||
|
private function post(string $type, int $id): array
|
||||||
|
{
|
||||||
|
$typeLabel = $this->postTypeLabel($type);
|
||||||
|
$post = get_post($id);
|
||||||
|
if (! $post instanceof WP_Post) {
|
||||||
|
return ['html' => $this->prefixed($typeLabel, esc_html($this->deleted($id))), 'url' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = trim((string) $post->post_title) !== ''
|
||||||
|
? (string) $post->post_title
|
||||||
|
: __('(kein Titel)', 'wp-lit-mcp');
|
||||||
|
if ($post->post_status === 'trash') {
|
||||||
|
return [
|
||||||
|
'html' => $this->prefixed($typeLabel, esc_html(sprintf(
|
||||||
|
/* translators: %s: post title */
|
||||||
|
__('%s (im Papierkorb)', 'wp-lit-mcp'),
|
||||||
|
$title
|
||||||
|
))),
|
||||||
|
'url' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = get_edit_post_link($id, 'raw');
|
||||||
|
|
||||||
|
return $this->linked($typeLabel, $title, is_string($url) ? $url : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function postTypeLabel(string $type): string
|
||||||
|
{
|
||||||
|
if ($type === 'attachment') {
|
||||||
|
return __('Medium', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
$object = get_post_type_object($type);
|
||||||
|
|
||||||
|
return is_object($object) && isset($object->labels->singular_name)
|
||||||
|
? (string) $object->labels->singular_name
|
||||||
|
: $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{html: string, url: string} */
|
||||||
|
private function menu(int $id): array
|
||||||
|
{
|
||||||
|
$label = __('Menü', 'wp-lit-mcp');
|
||||||
|
if (! isset($this->menus[$id])) {
|
||||||
|
return ['html' => $this->prefixed($label, esc_html($this->deleted($id))), 'url' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = current_user_can('edit_theme_options') ? admin_url('nav-menus.php?action=edit&menu=' . $id) : '';
|
||||||
|
|
||||||
|
return $this->linked($label, (string) $this->menus[$id]->name, $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function location(string $location): string
|
||||||
|
{
|
||||||
|
$registered = get_registered_nav_menus();
|
||||||
|
|
||||||
|
return isset($registered[$location])
|
||||||
|
? esc_html((string) $registered[$location])
|
||||||
|
: '<code>' . esc_html($location) . '</code>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{html: string, url: string} */
|
||||||
|
private function options(string $keys): array
|
||||||
|
{
|
||||||
|
$names = array_values(array_filter(array_map('trim', explode(',', $keys)), 'strlen'));
|
||||||
|
$known = self::optionLabels();
|
||||||
|
$labels = array_map(
|
||||||
|
static fn (string $key): string => isset($known[$key])
|
||||||
|
? esc_html($known[$key])
|
||||||
|
: '<code>' . esc_html($key) . '</code>',
|
||||||
|
$names
|
||||||
|
);
|
||||||
|
$prefix = count($names) > 1 ? __('Einstellungen', 'wp-lit-mcp') : __('Einstellung', 'wp-lit-mcp');
|
||||||
|
|
||||||
|
return ['html' => $this->prefixed($prefix, implode(', ', $labels)), 'url' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Labels as on WordPress' own settings screens; unknown keys are shown as code.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function optionLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'blogname' => __('Titel der Website', 'wp-lit-mcp'),
|
||||||
|
'blogdescription' => __('Untertitel', 'wp-lit-mcp'),
|
||||||
|
'WPLANG' => __('Sprache der Website', 'wp-lit-mcp'),
|
||||||
|
'timezone_string' => __('Zeitzone', 'wp-lit-mcp'),
|
||||||
|
'date_format' => __('Datumsformat', 'wp-lit-mcp'),
|
||||||
|
'time_format' => __('Zeitformat', 'wp-lit-mcp'),
|
||||||
|
'show_on_front' => __('Startseite zeigt', 'wp-lit-mcp'),
|
||||||
|
'page_on_front' => __('Startseite', 'wp-lit-mcp'),
|
||||||
|
'page_for_posts' => __('Beitragsseite', 'wp-lit-mcp'),
|
||||||
|
'posts_per_page' => __('Beiträge pro Seite', 'wp-lit-mcp'),
|
||||||
|
'default_comment_status' => __('Kommentare erlauben', 'wp-lit-mcp'),
|
||||||
|
'default_ping_status' => __('Pingbacks erlauben', 'wp-lit-mcp'),
|
||||||
|
'permalink_structure' => __('Permalink-Struktur', 'wp-lit-mcp'),
|
||||||
|
'site_icon' => __('Website-Icon', 'wp-lit-mcp'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleted(int $id): string
|
||||||
|
{
|
||||||
|
/* translators: %d: object id */
|
||||||
|
return sprintf(__('#%d (gelöscht)', 'wp-lit-mcp'), $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{html: string, url: string} */
|
||||||
|
private function linked(string $prefix, string $name, string $url): array
|
||||||
|
{
|
||||||
|
$html = $url !== ''
|
||||||
|
? sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($name))
|
||||||
|
: esc_html($name);
|
||||||
|
|
||||||
|
return ['html' => $this->prefixed($prefix, $html), 'url' => $url];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param string $html already escaped */
|
||||||
|
private function prefixed(string $label, string $html): string
|
||||||
|
{
|
||||||
|
return esc_html($label) . ': ' . $html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
|
use LIT\MCP\Limits;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab "Protokoll": filterable list of change log entries, links to revisions, and
|
||||||
|
* "Protokoll leeren" (spec §8.3). Page size and visible columns via WordPress'
|
||||||
|
* "Ansicht anpassen" (screen options), as on the core list screens.
|
||||||
|
*/
|
||||||
|
final class LogTab implements AdminTab
|
||||||
|
{
|
||||||
|
public const CLEAR_ACTION = 'lit_mcp_clear_log';
|
||||||
|
|
||||||
|
/** Screen option "Einträge pro Seite" (user option; WordPress needs the "_per_page" end). */
|
||||||
|
public const PER_PAGE_OPTION = 'lit_mcp_log_per_page';
|
||||||
|
public const PER_PAGE_DEFAULT = 20;
|
||||||
|
private const RESULTS = ['ok', 'partial'];
|
||||||
|
|
||||||
|
private AdminPostAction $action;
|
||||||
|
|
||||||
|
/** Created on the screen's load hook so WordPress knows its columns for the screen options. */
|
||||||
|
private ?LogListTable $table = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private ChangeLog $log,
|
||||||
|
private Limits $limits,
|
||||||
|
private IntegrationRegistry $registry,
|
||||||
|
?callable $terminate = null
|
||||||
|
) {
|
||||||
|
$this->action = new AdminPostAction($terminate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return 'protokoll';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Protokoll', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSettings(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerActions(): void
|
||||||
|
{
|
||||||
|
add_action('admin_post_' . self::CLEAR_ACTION, [$this, 'handleClear']);
|
||||||
|
add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'loadScreen']);
|
||||||
|
add_filter('set_screen_option_' . self::PER_PAGE_OPTION, [$this, 'savePerPage'], 10, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load hook of the settings page: on this tab only, "Einträge pro Seite" in the screen
|
||||||
|
* options, and the list table built early so its columns can be hidden there too
|
||||||
|
* (WP_List_Table registers them for the screen, as on edit.php).
|
||||||
|
*/
|
||||||
|
public function loadScreen(): void
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
||||||
|
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
|
||||||
|
if ($tab !== $this->slug()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_screen_option('per_page', [
|
||||||
|
'label' => __('Einträge pro Seite', 'wp-lit-mcp'),
|
||||||
|
'default' => self::PER_PAGE_DEFAULT,
|
||||||
|
'option' => self::PER_PAGE_OPTION,
|
||||||
|
]);
|
||||||
|
$this->table = $this->createTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets WordPress save the page size (it already checked nonce and range 1–999).
|
||||||
|
*
|
||||||
|
* @return int|false
|
||||||
|
*/
|
||||||
|
public function savePerPage(mixed $status, string $option, mixed $value): mixed
|
||||||
|
{
|
||||||
|
return $option === self::PER_PAGE_OPTION ? (int) $value : $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, int|string> $filters */
|
||||||
|
public static function hasActiveFilter(array $filters): bool
|
||||||
|
{
|
||||||
|
foreach ($filters as $value) {
|
||||||
|
if ($value !== '' && $value !== 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ability: string, user_id: int} */
|
||||||
|
public function filters(): array
|
||||||
|
{
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
|
||||||
|
$ability = isset($_GET['ability']) ? sanitize_text_field(wp_unslash((string) $_GET['ability'])) : '';
|
||||||
|
$userId = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0;
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $ability) === 1 ? $ability : '',
|
||||||
|
'user_id' => $userId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional whitelisted filters (ruling C5/Q13: ability, result, user, date range).
|
||||||
|
* Kept separate from filters() so that method's return shape stays exactly what is
|
||||||
|
* unit-tested (and what earlier callers of this tab may already rely on).
|
||||||
|
*
|
||||||
|
* @return array{result: string, date_from: string, date_to: string}
|
||||||
|
*/
|
||||||
|
public function extraFilters(): array
|
||||||
|
{
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
|
||||||
|
$result = isset($_GET['result']) ? sanitize_text_field(wp_unslash((string) $_GET['result'])) : '';
|
||||||
|
$dateFrom = isset($_GET['date_from']) ? sanitize_text_field(wp_unslash((string) $_GET['date_from'])) : '';
|
||||||
|
$dateTo = isset($_GET['date_to']) ? sanitize_text_field(wp_unslash((string) $_GET['date_to'])) : '';
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||||
|
|
||||||
|
return [
|
||||||
|
'result' => in_array($result, self::RESULTS, true) ? $result : '',
|
||||||
|
'date_from' => self::isDate($dateFrom) ? $dateFrom : '',
|
||||||
|
'date_to' => self::isDate($dateTo) ? $dateTo : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isDate(string $value): bool
|
||||||
|
{
|
||||||
|
return preg_match('#^\d{4}-\d{2}-\d{2}$#D', $value) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTable(): LogListTable
|
||||||
|
{
|
||||||
|
if (! class_exists('WP_List_Table')) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = $this->filters();
|
||||||
|
$extra = $this->extraFilters();
|
||||||
|
$labels = [];
|
||||||
|
foreach ($this->registry->abilities() as $ability) {
|
||||||
|
$labels[$ability->name()] = $ability->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LogListTable(
|
||||||
|
$this->log,
|
||||||
|
$filters + $extra,
|
||||||
|
function () use ($filters, $extra, $labels): void {
|
||||||
|
$this->renderFilterControls($filters, $extra, $labels);
|
||||||
|
},
|
||||||
|
$labels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
$days = $this->limits->logRetentionDays();
|
||||||
|
printf(
|
||||||
|
'<p class="description">%s %s</p>',
|
||||||
|
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(
|
||||||
|
'<a href="%s">%s</a>',
|
||||||
|
esc_url(SettingsPage::url('limits')),
|
||||||
|
esc_html(sprintf(
|
||||||
|
/* translators: %d: number of days */
|
||||||
|
_n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'),
|
||||||
|
$days
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
echo '<form method="get">';
|
||||||
|
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
|
||||||
|
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
|
||||||
|
$table = $this->table ?? $this->createTable();
|
||||||
|
$table->prepare_items();
|
||||||
|
$table->display();
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
$this->renderClearForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{ability: string, user_id: int} $filters
|
||||||
|
* @param array{result: string, date_from: string, date_to: string} $extra
|
||||||
|
* @param array<string, string> $labels ability name => label
|
||||||
|
*/
|
||||||
|
private function renderFilterControls(array $filters, array $extra, array $labels): void
|
||||||
|
{
|
||||||
|
self::filterLabel('ability', __('Nach Tool filtern', 'wp-lit-mcp'));
|
||||||
|
echo '<select name="ability" id="lit-mcp-filter-ability"><option value="">'
|
||||||
|
. esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
|
||||||
|
foreach ($this->toolOptionGroups() as $group => $abilities) {
|
||||||
|
printf('<optgroup label="%s">', esc_attr($group));
|
||||||
|
foreach ($abilities as $ability) {
|
||||||
|
printf(
|
||||||
|
'<option value="%s"%s>%s</option>',
|
||||||
|
esc_attr($ability),
|
||||||
|
selected($filters['ability'], $ability, false),
|
||||||
|
esc_html($labels[$ability] ?? $ability)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</optgroup>';
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
|
||||||
|
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'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp'));
|
||||||
|
echo '<select name="result" id="lit-mcp-filter-result">';
|
||||||
|
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
|
||||||
|
$labels = ['ok' => __('erfolgreich', 'wp-lit-mcp'), 'partial' => __('teilweise', 'wp-lit-mcp')];
|
||||||
|
foreach (self::RESULTS as $result) {
|
||||||
|
printf(
|
||||||
|
'<option value="%s"%s>%s</option>',
|
||||||
|
esc_attr($result),
|
||||||
|
selected($extra['result'], $result, false),
|
||||||
|
esc_html($labels[$result])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
|
||||||
|
foreach (['date_from' => __('von', 'wp-lit-mcp'), 'date_to' => __('bis', 'wp-lit-mcp')] as $name => $label) {
|
||||||
|
$id = 'lit-mcp-filter-' . str_replace('_', '-', $name);
|
||||||
|
printf(
|
||||||
|
'<span class="lit-mcp-date"><label for="%1$s">%2$s</label>'
|
||||||
|
. '<input type="date" name="%3$s" id="%1$s" value="%4$s"></span>',
|
||||||
|
esc_attr($id),
|
||||||
|
esc_html($label),
|
||||||
|
esc_attr($name),
|
||||||
|
esc_attr($extra[$name])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit_button(__('Filtern', 'wp-lit-mcp'), '', 'filter_action', false, ['id' => 'lit-mcp-filter-submit']);
|
||||||
|
|
||||||
|
if (self::hasActiveFilter($filters + $extra)) {
|
||||||
|
printf(
|
||||||
|
'<a class="lit-mcp-reset" href="%s">%s</a>',
|
||||||
|
esc_url(SettingsPage::url($this->slug())),
|
||||||
|
esc_html__('Filter zurücksetzen', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tools found in the log, grouped like the "Tools" tab (integration order);
|
||||||
|
* tools no longer available land in "Weitere".
|
||||||
|
*
|
||||||
|
* @return array<string, list<string>> group label => ability names
|
||||||
|
*/
|
||||||
|
private function toolOptionGroups(): array
|
||||||
|
{
|
||||||
|
$logged = array_flip($this->log->abilities());
|
||||||
|
$groupLabels = $this->registry->labels();
|
||||||
|
$groups = [];
|
||||||
|
foreach ($this->registry->grouped() as $group => $abilities) {
|
||||||
|
foreach ($abilities as $ability) {
|
||||||
|
if (isset($logged[$ability->name()])) {
|
||||||
|
$groups[$groupLabels[$group] ?? $group][] = $ability->name();
|
||||||
|
unset($logged[$ability->name()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($logged !== []) {
|
||||||
|
$groups[__('Weitere', 'wp-lit-mcp')] = array_map('strval', array_keys($logged));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function filterLabel(string $field, string $text): void
|
||||||
|
{
|
||||||
|
printf(
|
||||||
|
'<label class="screen-reader-text" for="lit-mcp-filter-%s">%s</label>',
|
||||||
|
esc_attr($field),
|
||||||
|
esc_html($text)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderClearForm(): void
|
||||||
|
{
|
||||||
|
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
||||||
|
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::CLEAR_ACTION));
|
||||||
|
wp_nonce_field(self::CLEAR_ACTION);
|
||||||
|
$confirmMessage = esc_js(__(
|
||||||
|
'Das gesamte Protokoll unwiderruflich löschen?',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
));
|
||||||
|
submit_button(
|
||||||
|
__('Protokoll leeren', 'wp-lit-mcp'),
|
||||||
|
'delete',
|
||||||
|
'submit',
|
||||||
|
false,
|
||||||
|
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
|
||||||
|
);
|
||||||
|
echo '</form>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleClear(): void
|
||||||
|
{
|
||||||
|
$this->action->guard();
|
||||||
|
check_admin_referer(self::CLEAR_ACTION);
|
||||||
|
$this->log->clear();
|
||||||
|
$this->action->redirect($this->slug(), 'cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings page "Einstellungen → LIT MCP" with tabs (spec §8.4). Settings API, no JS framework.
|
||||||
|
*/
|
||||||
|
final class SettingsPage
|
||||||
|
{
|
||||||
|
public const SLUG = 'lit-mcp';
|
||||||
|
|
||||||
|
/** Hook suffix WordPress gives this page (add_options_page → "settings_page_<slug>"). */
|
||||||
|
public const SCREEN_HOOK = 'settings_page_' . self::SLUG;
|
||||||
|
|
||||||
|
/** @var array<string, AdminTab> */
|
||||||
|
private array $tabs = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AdminTab> $tabs The first tab is the default.
|
||||||
|
* @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 = '',
|
||||||
|
private string $assetsUrl = '',
|
||||||
|
private string $version = ''
|
||||||
|
) {
|
||||||
|
foreach ($tabs as $tab) {
|
||||||
|
$this->tabs[$tab->slug()] = $tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addMenu(): void
|
||||||
|
{
|
||||||
|
add_options_page(
|
||||||
|
__('LIT MCP', 'wp-lit-mcp'),
|
||||||
|
__('LIT MCP', 'wp-lit-mcp'),
|
||||||
|
'manage_options',
|
||||||
|
self::SLUG,
|
||||||
|
[$this, 'render']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSettings(): void
|
||||||
|
{
|
||||||
|
foreach ($this->tabs as $tab) {
|
||||||
|
$tab->registerSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string> */
|
||||||
|
public function tabSlugs(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->tabs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentTab(): string
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
||||||
|
$requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
|
||||||
|
|
||||||
|
return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $args */
|
||||||
|
public static function url(string $tab = '', array $args = []): string
|
||||||
|
{
|
||||||
|
$query = array_merge(['page' => self::SLUG], $tab !== '' ? ['tab' => $tab] : [], $args);
|
||||||
|
|
||||||
|
return add_query_arg($query, admin_url('options-general.php'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
if (! current_user_can('manage_options')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $this->currentTab();
|
||||||
|
echo '<div class="wrap lit-mcp">';
|
||||||
|
echo '<div class="lit-mcp-header"><h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
|
||||||
|
$this->renderLogo();
|
||||||
|
// Admin notices of other plugins are moved right after this marker – above the tabs.
|
||||||
|
echo '</div><hr class="wp-header-end">';
|
||||||
|
$this->renderNotice();
|
||||||
|
printf('<nav class="nav-tab-wrapper wp-clearfix" aria-label="%s">', esc_attr__('Bereiche', 'wp-lit-mcp'));
|
||||||
|
foreach ($this->tabs as $slug => $tab) {
|
||||||
|
printf(
|
||||||
|
'<a href="%s" class="nav-tab%s"%s>%s</a>',
|
||||||
|
esc_url(self::url($slug)),
|
||||||
|
$slug === $current ? ' nav-tab-active' : '',
|
||||||
|
$slug === $current ? ' aria-current="page"' : '',
|
||||||
|
esc_html($tab->label())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</nav>';
|
||||||
|
$this->tabs[$current]->render();
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderLogo(): void
|
||||||
|
{
|
||||||
|
if ($this->logoUrl === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<a class="lit-mcp-logo-link" href="https://loosen-it.de" target="_blank" rel="noopener">'
|
||||||
|
. '<img src="%s" alt="Loosen-IT Logo" class="lit-mcp-logo">'
|
||||||
|
. '<span class="screen-reader-text"> %s</span></a>',
|
||||||
|
esc_url($this->logoUrl),
|
||||||
|
esc_html__('(öffnet in einem neuen Tab)', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderNotice(): void
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
|
||||||
|
$key = isset($_GET['lit_mcp_notice']) ? sanitize_key(wp_unslash((string) $_GET['lit_mcp_notice'])) : '';
|
||||||
|
$notice = match ($key) {
|
||||||
|
'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')],
|
||||||
|
'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')],
|
||||||
|
'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($notice !== null) {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
|
||||||
|
esc_attr($notice[0]),
|
||||||
|
esc_html($notice[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site Health test for our MCP endpoint (401 + WWW-Authenticate without a token).
|
||||||
|
* The discovery documents are covered by mcp-oauth's own Site Health test.
|
||||||
|
*/
|
||||||
|
final class SiteHealth
|
||||||
|
{
|
||||||
|
public const TEST = 'lit_mcp_endpoint';
|
||||||
|
|
||||||
|
public function __construct(private ConnectionStatus $status)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_filter('site_status_tests', [$this, 'addTest']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTest(mixed $tests): mixed
|
||||||
|
{
|
||||||
|
if (! is_array($tests)) {
|
||||||
|
return $tests;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tests['direct'][self::TEST] = [
|
||||||
|
'label' => __('LIT MCP Endpunkt', 'wp-lit-mcp'),
|
||||||
|
'test' => [$this, 'run'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $tests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function run(): array
|
||||||
|
{
|
||||||
|
$oauth = $this->status->oauthEnabled();
|
||||||
|
$endpoint = $oauth ? $this->status->endpoint(true) : null;
|
||||||
|
$good = $oauth && ($endpoint['ok'] ?? false) === true;
|
||||||
|
$detail = $oauth
|
||||||
|
? (string) ($endpoint['message'] ?? '')
|
||||||
|
: __('Der OAuth-Server ist deaktiviert (Filter wpmedia_mcp_oauth_server_enabled).', 'wp-lit-mcp');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $good
|
||||||
|
? __('Der LIT-MCP-Endpunkt ist erreichbar und verlangt OAuth', 'wp-lit-mcp')
|
||||||
|
: __('Der LIT-MCP-Endpunkt ist nicht nutzbar', 'wp-lit-mcp'),
|
||||||
|
'status' => $good ? 'good' : 'critical',
|
||||||
|
'badge' => ['label' => 'LIT MCP', 'color' => $good ? 'blue' : 'red'],
|
||||||
|
'description' => '<p>' . esc_html($detail) . '</p>',
|
||||||
|
'actions' => sprintf(
|
||||||
|
'<a href="%s">%s</a>',
|
||||||
|
esc_url(SettingsPage::url('verbindung')),
|
||||||
|
esc_html__('Einstellungen → LIT MCP', 'wp-lit-mcp')
|
||||||
|
),
|
||||||
|
'test' => self::TEST,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 conversation of the connected assistant. */
|
||||||
|
public const DIRECT_WARNING_THRESHOLD = 25;
|
||||||
|
|
||||||
|
/** Tools that remove something: "delete" (for good) or "trash" (to the trash). */
|
||||||
|
private const DELETING = [
|
||||||
|
'lit/media-delete' => 'delete',
|
||||||
|
'lit/divi-delete' => 'delete',
|
||||||
|
'lit/post-trash' => 'trash',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private IntegrationRegistry $registry, private ExposurePolicy $policy)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return 'tools';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Tools', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSettings(): void
|
||||||
|
{
|
||||||
|
register_setting(self::GROUP, ExposurePolicy::OPTION, [
|
||||||
|
'type' => 'array',
|
||||||
|
'sanitize_callback' => [$this, 'sanitize'],
|
||||||
|
'default' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize callback: ExposurePolicy::sanitize() restricted to the known group ids.
|
||||||
|
*
|
||||||
|
* @return array<string, string|true>
|
||||||
|
*/
|
||||||
|
public function sanitize(mixed $input): array
|
||||||
|
{
|
||||||
|
return ExposurePolicy::sanitize($input, $this->registry->ids());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerActions(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
// Bind the policy to the groups of exactly the abilities listed below. The plugin's policy
|
||||||
|
// carries a lazy map of the same registry (resolved here anyway); a policy built without
|
||||||
|
// withGroups() would otherwise ignore every group mode and "nur lesen" switch on this page.
|
||||||
|
$policy = $this->policy->withGroups($this->registry->groupMap());
|
||||||
|
$groups = $this->registry->grouped();
|
||||||
|
$labels = $this->registry->labels();
|
||||||
|
|
||||||
|
$this->renderIntro();
|
||||||
|
$this->renderCounter(count($policy->directNames($this->registry->abilities())));
|
||||||
|
|
||||||
|
// Lets WordPress' postbox script remember which groups each user closed.
|
||||||
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core nonce markup.
|
||||||
|
echo wp_nonce_field('closedpostboxes', 'closedpostboxesnonce', false, false);
|
||||||
|
|
||||||
|
echo '<form method="post" action="options.php">';
|
||||||
|
settings_fields(self::GROUP);
|
||||||
|
submit_button(null, 'primary', 'submit-top');
|
||||||
|
echo '<div class="lit-mcp-groups">';
|
||||||
|
$closed = $this->savedClosedBoxes();
|
||||||
|
foreach ($groups as $group => $abilities) {
|
||||||
|
$this->renderGroup($policy, $group, $labels[$group] ?? $group, $abilities, $closed);
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
submit_button();
|
||||||
|
echo '</form>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderIntro(): void
|
||||||
|
{
|
||||||
|
echo '<p class="description">'
|
||||||
|
. esc_html__('Lege fest, welche Tools der KI-Assistent nutzen kann.', 'wp-lit-mcp') . ' '
|
||||||
|
. esc_html__('Änderungen gelten ab der nächsten Verbindung.', 'wp-lit-mcp') . ' '
|
||||||
|
. esc_html__('Im KI-Assistenten die Verbindung dafür gegebenenfalls neu herstellen.', 'wp-lit-mcp')
|
||||||
|
. '</p>';
|
||||||
|
|
||||||
|
echo '<dl class="lit-mcp-legend">';
|
||||||
|
$legend = [
|
||||||
|
__('Direkt', 'wp-lit-mcp') => __('Der Assistent sieht das Tool sofort.', 'wp-lit-mcp'),
|
||||||
|
__('Indirekt', 'wp-lit-mcp') => __('Der Assistent findet es bei Bedarf über die Tool-Suche.', 'wp-lit-mcp'),
|
||||||
|
__('Aus', 'wp-lit-mcp') => __('Nicht verfügbar.', 'wp-lit-mcp'),
|
||||||
|
__('Nur lesen', 'wp-lit-mcp') => __('Schaltet alle schreibenden Tools einer Gruppe aus.', 'wp-lit-mcp'),
|
||||||
|
];
|
||||||
|
foreach ($legend as $term => $text) {
|
||||||
|
printf('<dt>%s</dt><dd>%s</dd>', esc_html($term), esc_html($text));
|
||||||
|
}
|
||||||
|
echo '</dl>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only when the direct tools get many: then the count is worth a warning, otherwise it is noise. */
|
||||||
|
private function renderCounter(int $direct): void
|
||||||
|
{
|
||||||
|
if ($direct <= self::DIRECT_WARNING_THRESHOLD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$counter = sprintf(
|
||||||
|
/* translators: %d: number of direct tools */
|
||||||
|
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
||||||
|
$direct
|
||||||
|
);
|
||||||
|
printf('<div class="notice notice-warning inline"><p><strong>%s</strong>', esc_html($counter));
|
||||||
|
echo ' – '
|
||||||
|
. esc_html__('Viele direkte Tools kosten den Assistenten in jeder Unterhaltung Kontext.', 'wp-lit-mcp')
|
||||||
|
. ' '
|
||||||
|
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp');
|
||||||
|
echo '</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed postbox ids the user saved via WordPress' postbox script, or null if never saved.
|
||||||
|
*
|
||||||
|
* @return list<string>|null
|
||||||
|
*/
|
||||||
|
private function savedClosedBoxes(): ?array
|
||||||
|
{
|
||||||
|
$saved = get_user_option('closedpostboxes_' . SettingsPage::SCREEN_HOOK);
|
||||||
|
|
||||||
|
return is_array($saved) ? array_values(array_filter($saved, 'is_string')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Ability> $abilities
|
||||||
|
* @param list<string>|null $closedBoxes
|
||||||
|
*/
|
||||||
|
private function renderGroup(
|
||||||
|
ExposurePolicy $policy,
|
||||||
|
string $group,
|
||||||
|
string $label,
|
||||||
|
array $abilities,
|
||||||
|
?array $closedBoxes
|
||||||
|
): void {
|
||||||
|
$count = count($abilities);
|
||||||
|
$boxId = 'lit-mcp-group-' . self::idPart($group);
|
||||||
|
$modes = [ExposurePolicy::DIRECT => 0, ExposurePolicy::INDIRECT => 0, ExposurePolicy::DISABLED => 0];
|
||||||
|
$mixed = false;
|
||||||
|
foreach ($abilities as $ability) {
|
||||||
|
$modes[$policy->mode($ability)]++;
|
||||||
|
$mixed = $mixed || $policy->hasToolOverride($ability->name());
|
||||||
|
}
|
||||||
|
// Groups start open; one whose tools are all off may start closed. A saved user choice wins.
|
||||||
|
$closed = $closedBoxes === null
|
||||||
|
? $count > 0 && $modes[ExposurePolicy::DISABLED] === $count
|
||||||
|
: in_array($boxId, $closedBoxes, true);
|
||||||
|
|
||||||
|
printf('<div id="%s" class="postbox lit-mcp-group%s">', esc_attr($boxId), $closed ? ' closed' : '');
|
||||||
|
echo '<div class="postbox-header">';
|
||||||
|
printf(
|
||||||
|
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
|
||||||
|
esc_html($label),
|
||||||
|
esc_html(sprintf(
|
||||||
|
/* translators: %d: number of tools */
|
||||||
|
_n('%d Tool', '%d Tools', $count, 'wp-lit-mcp'),
|
||||||
|
$count
|
||||||
|
)),
|
||||||
|
esc_html($this->summary($modes, $policy->isReadonly($group)))
|
||||||
|
);
|
||||||
|
$this->renderGroupControls($policy, $group, $mixed);
|
||||||
|
printf(
|
||||||
|
'<div class="handle-actions hide-if-no-js"><button type="button" class="handlediv" aria-expanded="%s">'
|
||||||
|
. '<span class="screen-reader-text">%s</span>'
|
||||||
|
. '<span class="toggle-indicator" aria-hidden="true"></span></button></div>',
|
||||||
|
$closed ? 'false' : 'true',
|
||||||
|
esc_html(sprintf(
|
||||||
|
/* translators: %s: group label */
|
||||||
|
__('Gruppe ein-/ausblenden: %s', 'wp-lit-mcp'),
|
||||||
|
$label
|
||||||
|
))
|
||||||
|
);
|
||||||
|
echo '</div><div class="inside">';
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<table class="widefat striped lit-mcp-tools-table"><thead><tr>'
|
||||||
|
. '<th scope="col" class="column-tool">%s</th><th scope="col" class="column-kind">%s</th>'
|
||||||
|
. '<th scope="col" class="column-mode">%s</th></tr></thead><tbody>',
|
||||||
|
esc_html__('Tool', 'wp-lit-mcp'),
|
||||||
|
esc_html__('Art', 'wp-lit-mcp'),
|
||||||
|
esc_html__('Einstellung', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
$groupMode = $policy->groupMode($group);
|
||||||
|
foreach ($abilities as $ability) {
|
||||||
|
$this->renderTool($policy, $ability, $groupMode !== null);
|
||||||
|
}
|
||||||
|
echo '</tbody></table></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, int> $modes */
|
||||||
|
private function summary(array $modes, bool $readonly): string
|
||||||
|
{
|
||||||
|
$summary = sprintf(
|
||||||
|
/* translators: 1: number of direct tools, 2: indirect, 3: off */
|
||||||
|
__('%1$d direkt · %2$d indirekt · %3$d aus', 'wp-lit-mcp'),
|
||||||
|
$modes[ExposurePolicy::DIRECT],
|
||||||
|
$modes[ExposurePolicy::INDIRECT],
|
||||||
|
$modes[ExposurePolicy::DISABLED]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $readonly ? __('nur lesen', 'wp-lit-mcp') . ' · ' . $summary : $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderGroupControls(ExposurePolicy $policy, string $group, bool $mixed): void
|
||||||
|
{
|
||||||
|
$selectId = 'lit-mcp-group-mode-' . self::idPart($group);
|
||||||
|
$groupMode = $policy->groupMode($group);
|
||||||
|
|
||||||
|
echo '<div class="lit-mcp-group-controls">';
|
||||||
|
printf(
|
||||||
|
'<span class="lit-mcp-control"><label for="%1$s">%2$s</label> <select name="%3$s[%4$s]" id="%1$s">',
|
||||||
|
esc_attr($selectId),
|
||||||
|
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
|
||||||
|
esc_attr(ExposurePolicy::OPTION),
|
||||||
|
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
|
||||||
|
);
|
||||||
|
printf(
|
||||||
|
'<option value=""%s>%s</option>',
|
||||||
|
selected($groupMode === null, true, false),
|
||||||
|
esc_html__('Standard der Tools', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
foreach (ExposurePolicy::MODES as $mode) {
|
||||||
|
printf(
|
||||||
|
'<option value="%s"%s>%s</option>',
|
||||||
|
esc_attr($mode),
|
||||||
|
selected($groupMode === $mode, true, false),
|
||||||
|
esc_html($this->modeLabel($mode))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</select></span>';
|
||||||
|
printf(
|
||||||
|
'<span class="lit-mcp-control"><label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label></span>',
|
||||||
|
esc_attr(ExposurePolicy::OPTION),
|
||||||
|
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
||||||
|
checked($policy->isReadonly($group), true, false),
|
||||||
|
esc_html__('Nur lesen', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
if ($mixed) {
|
||||||
|
echo '<span class="description">'
|
||||||
|
. esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp')
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
$selectId = 'lit-mcp-tool-' . self::idPart($name);
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<tr><td class="column-tool"><strong>%1$s</strong><br><span class="lit-mcp-tool-name">%2$s</span></td>'
|
||||||
|
. '<td class="column-kind">%3$s</td><td class="column-mode">'
|
||||||
|
. '<label class="screen-reader-text" for="%4$s">%5$s</label>'
|
||||||
|
. '<select name="%6$s[%7$s]"%8$s id="%4$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' : ''
|
||||||
|
);
|
||||||
|
$inherited = $this->modeLabel($policy->inheritedMode($ability));
|
||||||
|
printf(
|
||||||
|
'<option value=""%s>%s</option>',
|
||||||
|
selected($own === null, true, false),
|
||||||
|
esc_html($groupOverride && ! $required
|
||||||
|
/* translators: %s: mode inherited from the group */
|
||||||
|
? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited)
|
||||||
|
/* translators: %s: default mode */
|
||||||
|
: sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability))))
|
||||||
|
);
|
||||||
|
foreach (ExposurePolicy::MODES as $mode) {
|
||||||
|
if (! ExposurePolicy::isAllowed($name, $mode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<option value="%s"%s>%s</option>',
|
||||||
|
esc_attr($mode),
|
||||||
|
selected($own === $mode, true, false),
|
||||||
|
esc_html($this->modeLabel($mode))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
|
||||||
|
if ($locked && $own !== null) {
|
||||||
|
// A disabled select is not submitted – keep the tool's own setting for later.
|
||||||
|
printf(
|
||||||
|
'<input type="hidden" name="%s[%s]" value="%s">',
|
||||||
|
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 '<p class="description">' . esc_html($note) . '</p>';
|
||||||
|
}
|
||||||
|
echo '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "liest" / "schreibt" / "überschreibt" / "löscht" / "Papierkorb" with a dashicon. The
|
||||||
|
* "destructive" annotation is a broad hint (deleting, replacing, swapping, ruling S47),
|
||||||
|
* so only the tools in DELETING say "löscht"; other destructive tools "überschreiben".
|
||||||
|
*/
|
||||||
|
private function kind(Ability $ability): string
|
||||||
|
{
|
||||||
|
$annotations = $ability->annotations();
|
||||||
|
[$icon, $text] = match (true) {
|
||||||
|
isset(self::DELETING[$ability->name()]) => ['trash', self::DELETING[$ability->name()] === 'trash'
|
||||||
|
? __('Papierkorb', 'wp-lit-mcp')
|
||||||
|
: __('löscht', 'wp-lit-mcp')],
|
||||||
|
($annotations['destructive'] ?? false) === true => ['update', __('überschreibt', 'wp-lit-mcp')],
|
||||||
|
($annotations['readonly'] ?? false) === true => ['visibility', __('liest', 'wp-lit-mcp')],
|
||||||
|
default => ['edit', __('schreibt', 'wp-lit-mcp')],
|
||||||
|
};
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<span class="lit-mcp-kind" title="%2$s"><span class="dashicons dashicons-%1$s" aria-hidden="true"></span>'
|
||||||
|
. ' <span class="lit-mcp-kind-text">%2$s</span></span>',
|
||||||
|
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 modeLabel(string $mode): string
|
||||||
|
{
|
||||||
|
return match ($mode) {
|
||||||
|
ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
|
||||||
|
ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
|
||||||
|
default => __('aus', 'wp-lit-mcp'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\ChangeLog;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\FollowUp;
|
||||||
|
use LIT\MCP\Log;
|
||||||
|
use LIT\MCP\Server\RequestContext;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change log table {prefix}lit_mcp_log (spec §8.3). Stores references only,
|
||||||
|
* never content. Deliberately not final so abilities can be unit-tested with a mock.
|
||||||
|
*/
|
||||||
|
class ChangeLog
|
||||||
|
{
|
||||||
|
public const TABLE = 'lit_mcp_log';
|
||||||
|
|
||||||
|
public function __construct(private ?\wpdb $db = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tableName(): string
|
||||||
|
{
|
||||||
|
return $this->db()->prefix . self::TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function charsetCollate(): string
|
||||||
|
{
|
||||||
|
return (string) $this->db()->get_charset_collate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function record(
|
||||||
|
string $ability,
|
||||||
|
string $objectType,
|
||||||
|
string $objectId,
|
||||||
|
string $summary,
|
||||||
|
?int $revisionId = null,
|
||||||
|
?string $snapshotId = null,
|
||||||
|
string $result = 'ok'
|
||||||
|
): ?int {
|
||||||
|
$wpdb = $this->db();
|
||||||
|
$inserted = $wpdb->insert(
|
||||||
|
$this->tableName(),
|
||||||
|
[
|
||||||
|
'created_at' => current_time('mysql', true),
|
||||||
|
'user_id' => get_current_user_id(),
|
||||||
|
'client' => $this->sanitizedClientName(),
|
||||||
|
'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,
|
||||||
|
'result' => $result,
|
||||||
|
],
|
||||||
|
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($inserted === false) {
|
||||||
|
Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
$table = $this->tableName();
|
||||||
|
$cutoff = gmdate('Y-m-d H:i:s', time() - max(1, $days) * 86400);
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is our own constant.
|
||||||
|
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads rows for the "Protokoll" admin tab (spec §8.3), newest first. All filters are
|
||||||
|
* optional and whitelisted by the caller (LogTab); values only ever reach the query
|
||||||
|
* through wpdb::prepare() placeholders.
|
||||||
|
*
|
||||||
|
* @param array{
|
||||||
|
* ability?: string, user_id?: int, result?: string, date_from?: string, date_to?: string
|
||||||
|
* } $filters
|
||||||
|
* @return array{rows: list<array<string, mixed>>, total: int}
|
||||||
|
*/
|
||||||
|
public function query(array $filters, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
$wpdb = $this->db();
|
||||||
|
$table = $this->tableName();
|
||||||
|
$where = ['1 = %d'];
|
||||||
|
$args = [1];
|
||||||
|
if (($filters['ability'] ?? '') !== '') {
|
||||||
|
$where[] = 'ability = %s';
|
||||||
|
$args[] = (string) $filters['ability'];
|
||||||
|
}
|
||||||
|
if ((int) ($filters['user_id'] ?? 0) > 0) {
|
||||||
|
$where[] = 'user_id = %d';
|
||||||
|
$args[] = (int) $filters['user_id'];
|
||||||
|
}
|
||||||
|
if (($filters['result'] ?? '') !== '') {
|
||||||
|
$where[] = 'result = %s';
|
||||||
|
$args[] = (string) $filters['result'];
|
||||||
|
}
|
||||||
|
if (($filters['date_from'] ?? '') !== '') {
|
||||||
|
$where[] = 'created_at >= %s';
|
||||||
|
$args[] = $filters['date_from'] . ' 00:00:00';
|
||||||
|
}
|
||||||
|
if (($filters['date_to'] ?? '') !== '') {
|
||||||
|
$where[] = 'created_at <= %s';
|
||||||
|
$args[] = $filters['date_to'] . ' 23:59:59';
|
||||||
|
}
|
||||||
|
$whereSql = implode(' AND ', $where);
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $whereSql holds one %s/%d per filter actually present.
|
||||||
|
$total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$whereSql}", ...$args));
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT * FROM {$table} WHERE {$whereSql} ORDER BY id DESC LIMIT %d OFFSET %d",
|
||||||
|
...array_merge($args, [$limit, $offset])
|
||||||
|
),
|
||||||
|
'ARRAY_A'
|
||||||
|
);
|
||||||
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||||
|
|
||||||
|
return ['rows' => is_array($rows) ? array_values($rows) : [], 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function abilities(): array
|
||||||
|
{
|
||||||
|
$wpdb = $this->db();
|
||||||
|
$table = $this->tableName();
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
|
||||||
|
$abilities = (array) $wpdb->get_col("SELECT DISTINCT ability FROM {$table} ORDER BY ability");
|
||||||
|
|
||||||
|
return array_values(array_map('strval', $abilities));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes every row (button "Protokoll leeren", spec §8.3). Irreversible. */
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$wpdb = $this->db();
|
||||||
|
$table = $this->tableName();
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
|
||||||
|
$wpdb->query("DELETE FROM {$table}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RequestContext::clientName() is a client-chosen string (app-password name or
|
||||||
|
* OAuth client_id) – treat it as untrusted input before it lands in the DB.
|
||||||
|
*/
|
||||||
|
private function sanitizedClientName(): string
|
||||||
|
{
|
||||||
|
return mb_substr(sanitize_text_field(RequestContext::clientName()), 0, 191);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function db(): \wpdb
|
||||||
|
{
|
||||||
|
return $this->db ?? $GLOBALS['wpdb'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\ChangeLog;
|
||||||
|
|
||||||
|
use LIT\MCP\Limits;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily WP-Cron job that removes log rows older than the configured retention.
|
||||||
|
*/
|
||||||
|
final class ChangeLogCleanup
|
||||||
|
{
|
||||||
|
public const HOOK = 'lit_mcp_log_cleanup';
|
||||||
|
|
||||||
|
public function __construct(private ChangeLog $log, private Limits $limits)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_action(self::HOOK, [$this, 'run']);
|
||||||
|
add_action('init', [$this, 'ensureScheduled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureScheduled(): void
|
||||||
|
{
|
||||||
|
if (wp_next_scheduled(self::HOOK) === false) {
|
||||||
|
wp_schedule_event(time() + 3600, 'daily', self::HOOK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->log->purgeOlderThan($this->limits->logRetentionDays());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\ChangeLog;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates/updates the change log table via dbDelta. Runs on activation and –
|
||||||
|
* because plugin updates never fire the activation hook – whenever the stored
|
||||||
|
* schema version differs.
|
||||||
|
*/
|
||||||
|
final class ChangeLogInstaller
|
||||||
|
{
|
||||||
|
public const VERSION_OPTION = 'lit_mcp_db_version';
|
||||||
|
public const VERSION = '1';
|
||||||
|
|
||||||
|
public function __construct(private ChangeLog $log)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maybeInstall(): void
|
||||||
|
{
|
||||||
|
if (get_option(self::VERSION_OPTION) !== self::VERSION) {
|
||||||
|
$this->install();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function install(): void
|
||||||
|
{
|
||||||
|
if (! function_exists('dbDelta')) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $this->log->tableName();
|
||||||
|
$charset = $this->log->charsetCollate();
|
||||||
|
|
||||||
|
dbDelta("CREATE TABLE {$table} (
|
||||||
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
created_at datetime NOT NULL,
|
||||||
|
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
client varchar(191) NOT NULL DEFAULT '',
|
||||||
|
ability varchar(100) NOT NULL,
|
||||||
|
object_type varchar(50) NOT NULL DEFAULT '',
|
||||||
|
object_id varchar(191) NOT NULL DEFAULT '',
|
||||||
|
summary text NOT NULL,
|
||||||
|
revision_id bigint(20) unsigned DEFAULT NULL,
|
||||||
|
snapshot_id varchar(64) DEFAULT NULL,
|
||||||
|
result varchar(20) NOT NULL DEFAULT 'ok',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY created_at (created_at),
|
||||||
|
KEY ability (ability),
|
||||||
|
KEY user_id (user_id)
|
||||||
|
) {$charset};");
|
||||||
|
|
||||||
|
update_option(self::VERSION_OPTION, self::VERSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that the libraries WP-LIT-MCP builds on are loadable.
|
||||||
|
*/
|
||||||
|
final class Dependencies
|
||||||
|
{
|
||||||
|
private const REQUIRED = [
|
||||||
|
'wordpress/mcp-adapter' => '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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Guides;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guide topics: markdown files from one directory (the default), extended or overridden
|
||||||
|
* by the site via the filter `lit_mcp_guides` (spec §9.2), which returns
|
||||||
|
* array<string topic, string markdown>. Topic names are restricted to lowercase slugs,
|
||||||
|
* so no path outside the directory can ever be read; filter entries with another name or
|
||||||
|
* a non-string content are ignored. Filtered content is site-owned and returned as-is.
|
||||||
|
*/
|
||||||
|
final class GuideRepository
|
||||||
|
{
|
||||||
|
private const TOPIC_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/D';
|
||||||
|
|
||||||
|
/** @var array<string, string>|null Filtered topics, read once per instance (= request). */
|
||||||
|
private ?array $filtered = null;
|
||||||
|
|
||||||
|
public function __construct(private string $directory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function topics(): array
|
||||||
|
{
|
||||||
|
$topics = array_unique(array_merge($this->fileTopics(), array_keys($this->filtered())));
|
||||||
|
sort($topics);
|
||||||
|
|
||||||
|
return array_values($topics);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $topic): ?string
|
||||||
|
{
|
||||||
|
if (! self::isTopic($topic)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = $this->filtered();
|
||||||
|
if (array_key_exists($topic, $filtered)) {
|
||||||
|
return $filtered[$topic];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($topic, $this->fileTopics(), true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||||
|
$content = file_get_contents($this->directory . '/' . $topic . '.md');
|
||||||
|
|
||||||
|
return $content === false ? null : $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function fileTopics(): array
|
||||||
|
{
|
||||||
|
$files = glob($this->directory . '/*.md');
|
||||||
|
if ($files === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$topics = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$topic = basename($file, '.md');
|
||||||
|
if (self::isTopic($topic)) {
|
||||||
|
$topics[] = $topic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $topics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
private function filtered(): array
|
||||||
|
{
|
||||||
|
if ($this->filtered !== null) {
|
||||||
|
return $this->filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
$guides = apply_filters('lit_mcp_guides', []);
|
||||||
|
$this->filtered = [];
|
||||||
|
foreach (is_array($guides) ? $guides : [] as $topic => $markdown) {
|
||||||
|
if (is_string($topic) && self::isTopic($topic) && is_string($markdown)) {
|
||||||
|
$this->filtered[$topic] = $markdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isTopic(string $topic): bool
|
||||||
|
{
|
||||||
|
return preg_match(self::TOPIC_PATTERN, $topic) === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Guides;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the short server instructions every MCP client loads on connect.
|
||||||
|
* Keep this text short: it occupies context in every conversation.
|
||||||
|
*/
|
||||||
|
final class InstructionsBuilder
|
||||||
|
{
|
||||||
|
/** @var (callable(): list<string>)|null */
|
||||||
|
private $postTypeNames;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$active = implode(', ', array_map(
|
||||||
|
static fn (Integration $integration): string => $integration->id(),
|
||||||
|
$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/<name>).',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->postTypeNames !== null) {
|
||||||
|
$lines[] = 'Inhaltstypen: ' . implode(', ', ($this->postTypeNames)())
|
||||||
|
. ' (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".',
|
||||||
|
'- Divi-Änderungen verlangen die tree_rev aus lit-divi-outline bzw. der letzten Divi-Antwort '
|
||||||
|
. '(nicht rev).',
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return implode("\n", array_merge($lines, [
|
||||||
|
'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; '
|
||||||
|
. 'neue Inhalte entstehen als Entwurf.',
|
||||||
|
'- 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Core;
|
||||||
|
|
||||||
|
use LIT\MCP\Access;
|
||||||
|
use LIT\MCP\Guides\GuideRepository;
|
||||||
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
final class CoreIntegration implements Integration
|
||||||
|
{
|
||||||
|
public function __construct(private string $guidesDirectory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return 'core';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Grundlagen', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new GuideAbility(new GuideRepository($this->guidesDirectory)),
|
||||||
|
new SiteStatusAbility(new EnvironmentProbe(), new Access()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Core;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the themes/plugins LIT MCP integrates with.
|
||||||
|
*/
|
||||||
|
class EnvironmentProbe
|
||||||
|
{
|
||||||
|
/** @return list<array{id: string, active: bool, version: ?string}> */
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Core;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Guides\GuideRepository;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
final class GuideAbility implements Ability
|
||||||
|
{
|
||||||
|
private const DEFAULT_TOPIC = 'overview';
|
||||||
|
|
||||||
|
public function __construct(private GuideRepository $guides)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/guide';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Anleitung';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => '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 Annotations::read();
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Core;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\Annotations;
|
||||||
|
use LIT\MCP\Access;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
final class SiteStatusAbility implements Ability
|
||||||
|
{
|
||||||
|
public function __construct(private EnvironmentProbe $probe, private Access $access)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/site-status';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Website-Status';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
|
||||||
|
. 'erkannte Integrationen (Divi, Pods, The Events Calendar).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return ['type' => '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 Annotations::read();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divi 5 attributes addressed by dotted paths ("title.decoration.font.font"). The value at
|
||||||
|
* a path is a breakpoint map: {"desktop": {"value": …, "hover": …}, "phone": {"value": …}}.
|
||||||
|
* flatten() lists every such path – the full form lit-divi-node-get returns and
|
||||||
|
* lit-divi-update accepts (spec §5.4).
|
||||||
|
*/
|
||||||
|
final class AttrMap
|
||||||
|
{
|
||||||
|
public const BREAKPOINTS = ['desktop', 'tablet', 'phone', 'tabletWide', 'phoneWide', 'widescreen', 'ultraWide'];
|
||||||
|
|
||||||
|
/** Top-level keys that are no setting groups: versions, presets, global references. */
|
||||||
|
public const META_KEYS = [
|
||||||
|
'builderVersion', 'modulePreset', 'groupPreset', 'globalModule', 'globalParent', 'localAttrs',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Whether two attribute paths reach the same value: equal, or one lies below the other. */
|
||||||
|
public static function overlaps(string $a, string $b): bool
|
||||||
|
{
|
||||||
|
return $a === $b || str_starts_with($a, $b . '.') || str_starts_with($b, $a . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed> 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<string, mixed> $attrs */
|
||||||
|
public static function get(array $attrs, string $path): mixed
|
||||||
|
{
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
$value = $value[$segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop value of a path (state "value"), e.g. the text of "title.innerContent".
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $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<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value at $path and every parent that becomes empty.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LogicException;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
* so the Serializer regenerates exactly the changed openers and nothing else.
|
||||||
|
*
|
||||||
|
* Opaque blocks (attributes that are no JSON object, divi/global-layout, anything carrying
|
||||||
|
* globalModule) are never regenerated and never descended into by the tools – Divi's own
|
||||||
|
* code warns that parse_blocks() mishandles global modules; Etappe 3 edits them.
|
||||||
|
*/
|
||||||
|
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_-]*$/D';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private string $name,
|
||||||
|
private array $attrs,
|
||||||
|
private ?string $rawOpen,
|
||||||
|
private ?string $rawClose,
|
||||||
|
private bool $void,
|
||||||
|
private bool $opaque
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $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<string, mixed> $attrs
|
||||||
|
* @param list<Block> $children
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, mixed> */
|
||||||
|
public function attrs(): array
|
||||||
|
{
|
||||||
|
return $this->attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The divi/placeholder wrapper the builder puts around a page's sections (transparent for IDs). */
|
||||||
|
public function isPlaceholder(): bool
|
||||||
|
{
|
||||||
|
return $this->name === 'divi/placeholder';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
{
|
||||||
|
if ($this->void) {
|
||||||
|
$this->void = false;
|
||||||
|
$this->rawOpen = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered parts of a document or of a block's inner content: blocks and the raw text
|
||||||
|
* between them (whitespace, freeform HTML, Divi 4 shortcodes). Keeping every byte of that
|
||||||
|
* text is what makes parse → serialize byte-identical (spec §5.2).
|
||||||
|
*/
|
||||||
|
abstract class Container
|
||||||
|
{
|
||||||
|
/** @var list<string|Block> */
|
||||||
|
protected array $parts = [];
|
||||||
|
|
||||||
|
/** @return list<string|Block> */
|
||||||
|
public function parts(): array
|
||||||
|
{
|
||||||
|
return $this->parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Block> */
|
||||||
|
public function children(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($this->parts, static fn (string|Block $part): bool => $part instanceof Block));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
{
|
||||||
|
$this->guardMutable();
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$this->guardMutable();
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
if ($position === false) {
|
||||||
|
throw new InvalidArgumentException('Block is not a child of this container.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $position;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed post_content: top-level blocks and the text between them.
|
||||||
|
*/
|
||||||
|
final class Document extends Container
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use JsonException;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON of block attributes. PHP arrays cannot tell an empty JSON object from an empty list,
|
||||||
|
* nor an object with the keys "0", "1", … from a list. decodeObject() therefore keeps such
|
||||||
|
* objects as stdClass, so a regenerated opener encodes them as {} again (Divi breaks on
|
||||||
|
* "[]" where it expects "{}" – see its EmptyArrayCorruptionMigration).
|
||||||
|
*/
|
||||||
|
final class Json
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|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 {
|
||||||
|
$value = json_decode($json, false, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $value instanceof stdClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = get_object_vars($value);
|
||||||
|
if ($keys !== [] && array_is_list($keys)) {
|
||||||
|
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 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).
|
||||||
|
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* post_content is no balanced block markup (closer without opener, unclosed block, PCRE
|
||||||
|
* failure). The Divi tools then refuse to read or write the entry – nothing is guessed.
|
||||||
|
*/
|
||||||
|
final class MalformedMarkup extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path IDs of a Document (spec §5.3): s0, s0.r1, s0.r1.c0.m2. One letter per role –
|
||||||
|
* s section, r row / row-inner, c column / column-inner, m module (incl. group and child
|
||||||
|
* items), g global module or global layout, x non-Divi block – numbered per parent and letter.
|
||||||
|
* divi/placeholder wrappers are transparent (their children count as top level). Opaque and
|
||||||
|
* non-Divi blocks get an ID but are not descended into. IDs are only valid for the tree_rev
|
||||||
|
* they were read with; build a new index after every change.
|
||||||
|
*/
|
||||||
|
final class NodeIndex
|
||||||
|
{
|
||||||
|
public const ID_PATTERN = '/^[srcmgx]\d+(\.[srcmgx]\d+)*$/D';
|
||||||
|
|
||||||
|
/** @var array<string, Block> */
|
||||||
|
private array $byId = [];
|
||||||
|
|
||||||
|
/** @var array<int, string> spl_object_id(block) => id */
|
||||||
|
private array $ids = [];
|
||||||
|
|
||||||
|
/** @var array<int, Container> spl_object_id(block) => container holding it */
|
||||||
|
private array $parents = [];
|
||||||
|
|
||||||
|
/** @var list<array{id: string, block: Block, depth: int}> */
|
||||||
|
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->isPlaceholder()) {
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parent node of $block; null at the top level (the root or a placeholder holds it). */
|
||||||
|
public function parentBlock(Block $block): ?Block
|
||||||
|
{
|
||||||
|
$parent = $this->parentOf($block);
|
||||||
|
|
||||||
|
return $parent instanceof Block && ! $parent->isPlaceholder() ? $parent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin label of the section around $block (or $block itself), for automatic labels ("" when none). */
|
||||||
|
public function sectionLabel(?Block $block): string
|
||||||
|
{
|
||||||
|
for ($node = $block; $node !== null; $node = $this->parentBlock($node)) {
|
||||||
|
if ($node->name() === 'divi/section') {
|
||||||
|
return NodeSummary::label($node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{id: string, block: Block, depth: int}> In document order. */
|
||||||
|
public function entries(): array
|
||||||
|
{
|
||||||
|
return $this->entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, int> $counters */
|
||||||
|
private function walk(Container $container, string $prefix, int $depth, array &$counters): void
|
||||||
|
{
|
||||||
|
foreach ($container->children() as $block) {
|
||||||
|
if ($prefix === '' && $block->isPlaceholder()) {
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Text;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short human-readable facts about a node for the outline: admin label, first text,
|
||||||
|
* preset IDs, column layout. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class NodeSummary
|
||||||
|
{
|
||||||
|
/** innerContent groups read first for the excerpt (then alphabetical). */
|
||||||
|
private const PRIORITY = ['title', 'name', 'author', 'content', 'button', 'number'];
|
||||||
|
|
||||||
|
/** Keys of object values that carry readable text, in this order. */
|
||||||
|
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'address', 'linkUrl', 'url', 'src'];
|
||||||
|
|
||||||
|
/** The admin label is a one-line title in the layers panel, never an unbounded copy. */
|
||||||
|
private const LABEL_LIMIT = 80;
|
||||||
|
|
||||||
|
public static function label(Block $block): string
|
||||||
|
{
|
||||||
|
$label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel');
|
||||||
|
if (! is_string($label) || $label === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) preg_replace('/\s+/u', ' ', $label));
|
||||||
|
|
||||||
|
return Text::shorten($normalized, self::LABEL_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Text::shorten($text, $max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, string> $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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outline text of a write result: the whole page ($sectionIds null) or the given
|
||||||
|
* sections one after another (none: text null), and whether any breadth cap fired (S18).
|
||||||
|
*
|
||||||
|
* @param list<string>|null $sectionIds
|
||||||
|
* @return array{text: ?string, truncated: bool}
|
||||||
|
*/
|
||||||
|
public function summary(NodeIndex $index, ?array $sectionIds = null): array
|
||||||
|
{
|
||||||
|
if ($sectionIds === []) {
|
||||||
|
return ['text' => null, 'truncated' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$texts = [];
|
||||||
|
$truncated = false;
|
||||||
|
foreach ($sectionIds ?? [null] as $sectionId) {
|
||||||
|
$texts[] = $this->text($this->rows($index, $sectionId));
|
||||||
|
$truncated = $truncated || $this->wasTruncated();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['text' => implode("\n", $texts), 'truncated' => $truncated];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
|
||||||
|
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}>
|
||||||
|
*/
|
||||||
|
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'];
|
||||||
|
if ($rootId !== null && $id !== $rootId && ! str_starts_with($id, $rootId . '.')) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array<string, mixed>> $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)';
|
||||||
|
$line .= ($row['truncated'] ?? false) ? ' (weitere Kinder abgeschnitten)' : '';
|
||||||
|
$lines[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, type: string, label: string, excerpt: string, info: string,
|
||||||
|
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
'truncated' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses block markup into a Document without WordPress (spec §4.4, §5.2). Uses the
|
||||||
|
* block-delimiter pattern of WP_Block_Parser, but records the exact bytes of every
|
||||||
|
* delimiter and of all text between them, so Serializer::document() reproduces the input
|
||||||
|
* byte for byte. Unbalanced markup is refused (MalformedMarkup), never repaired.
|
||||||
|
*/
|
||||||
|
final class Parser
|
||||||
|
{
|
||||||
|
/** WP_Block_Parser::next_token() (WordPress 7.0). */
|
||||||
|
private const TOKEN = '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
|
||||||
|
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s';
|
||||||
|
|
||||||
|
/** @throws MalformedMarkup */
|
||||||
|
public function parse(string $content): Document
|
||||||
|
{
|
||||||
|
$document = new Document();
|
||||||
|
/** @var list<Block> $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<int|string, array{0: string, 1: int}> $match */
|
||||||
|
private static function name(array $match): string
|
||||||
|
{
|
||||||
|
$namespace = self::captured($match, 'namespace') ? $match['namespace'][0] : 'core/';
|
||||||
|
|
||||||
|
return $namespace . $match['name'][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int|string, array{0: string, 1: int}> $match */
|
||||||
|
private static function captured(array $match, string $group): bool
|
||||||
|
{
|
||||||
|
return isset($match[$group]) && $match[$group][1] !== -1 && $match[$group][0] !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: json_encode()'s own
|
||||||
|
* \" (an escaped quote inside a JSON string) is replaced by \u0022, see ESCAPES below.
|
||||||
|
*/
|
||||||
|
final class Serializer
|
||||||
|
{
|
||||||
|
/** serialize_block_attributes() replacements (WordPress 7.0). */
|
||||||
|
private const ESCAPES = [
|
||||||
|
'\\\\' => '\\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<string, mixed> $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 '<!-- wp:' . self::serializedName($block->name()) . ' ' . $attrs . ($block->isVoid() ? '/-->' : '-->');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function closer(Block $block): string
|
||||||
|
{
|
||||||
|
return '<!-- /wp:' . self::serializedName($block->name()) . ' -->';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function serializedName(string $name): string
|
||||||
|
{
|
||||||
|
return str_starts_with($name, 'core/') ? substr($name, 5) : $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string|Block> $parts */
|
||||||
|
private static function parts(array $parts): string
|
||||||
|
{
|
||||||
|
$out = '';
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$out .= $part instanceof Block ? self::block($part) : $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies full-form changes (spec §5.4): attribute path => 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.
|
||||||
|
*
|
||||||
|
* 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'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* which children are valid – StructureRules would be bypassed. Sections get their type
|
||||||
|
* through "section_type" when they are inserted.
|
||||||
|
*/
|
||||||
|
private const STRUCTURE = [
|
||||||
|
'divi/section' => ['module.advanced.type'],
|
||||||
|
'divi/column' => ['module.advanced.specialtyColumns'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private ModuleCatalog $catalog,
|
||||||
|
private Placeholders $placeholders,
|
||||||
|
private ValueCleaner $cleaner
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs Current attributes of the block.
|
||||||
|
* @param array<array-key, mixed> $changes Path => breakpoint map, or null to remove the path.
|
||||||
|
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
|
||||||
|
*/
|
||||||
|
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 = ControlChars::stripDeep($changes);
|
||||||
|
$errors = [];
|
||||||
|
$invalid = [];
|
||||||
|
foreach ($changes as $path => $map) {
|
||||||
|
$path = (string) $path;
|
||||||
|
$key = $field . '.' . $path;
|
||||||
|
if ($this->isStructure($module, $path)) {
|
||||||
|
$errors[$key] = $module === 'divi/section'
|
||||||
|
? 'Struktur des Abschnitts; nur im Divi-Builder änderbar (Abschnittsart beim Einfügen über '
|
||||||
|
. 'section_type)'
|
||||||
|
: 'Spezialspalten sind Struktur; nur im Divi-Builder änderbar';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->catalog->isKnownPath($module, $path)) {
|
||||||
|
$suggestions = $this->catalog->suggestions($module, $path);
|
||||||
|
$errors[$key] = 'unbekannter Attributpfad'
|
||||||
|
. ($suggestions === [] ? '' : '; ähnlich: ' . implode(', ', $suggestions));
|
||||||
|
$invalid[] = $path;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->cleaner->guardPath($path);
|
||||||
|
if ($map === null) {
|
||||||
|
$attrs = AttrMap::remove($attrs, $path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, mixed>> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isStructure(string $module, string $path): bool
|
||||||
|
{
|
||||||
|
foreach (self::STRUCTURE[$module] ?? [] as $structure) {
|
||||||
|
if (AttrMap::overlaps($path, $structure)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $normalized
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
* @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->cleaner->clean($module, $path, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $changes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column widths of Divi rows (pure, static): the "columns" input of new rows
|
||||||
|
* ("1_3,2_3" → flexTypes, ruling S34) and the flexType checks on update (rulings S41, S43,
|
||||||
|
* S49). Every width this class returns is built from digits or taken from a fixed list.
|
||||||
|
*/
|
||||||
|
final class ColumnWidths
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Column fractions of "columns" besides multiples of 1/24: the builder's special column
|
||||||
|
* classes (ruling S49 – no 4_5, the builder does not offer it).
|
||||||
|
*/
|
||||||
|
private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '1_7', '1_9', '1_10', '1_11'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The builder's "Column Class" choices (Divi 5.2.1 visual-builder/build/field-library.js):
|
||||||
|
* n_24, the special fractions and "none" (no width class). Ruling S43.
|
||||||
|
*/
|
||||||
|
private const COLUMN_CLASSES = ['1_5', '2_5', '3_5', '1_7', '1_9', '1_10', '1_11', 'none'];
|
||||||
|
|
||||||
|
private const SIZING = 'module.decoration.sizing';
|
||||||
|
|
||||||
|
/** Divi's column default (column/module-default-render-attributes.json). */
|
||||||
|
private const DEFAULT_WIDTH = '24_24';
|
||||||
|
|
||||||
|
private const CLASS_LIST = '1_24 … 24_24, 1_5, 2_5, 3_5, 1_7, 1_9, 1_10, 1_11 oder none';
|
||||||
|
|
||||||
|
private const FORMAT = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet';
|
||||||
|
|
||||||
|
private const FULL_ROW = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "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).
|
||||||
|
* On an error the widths are one full column, so the caller can go on collecting errors.
|
||||||
|
*
|
||||||
|
* @return array{widths: list<string>, error: ?string}
|
||||||
|
*/
|
||||||
|
public static function parse(mixed $columns): array
|
||||||
|
{
|
||||||
|
$parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : [];
|
||||||
|
$widths = [];
|
||||||
|
$fractions = [];
|
||||||
|
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) {
|
||||||
|
return self::failed(self::FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$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 {
|
||||||
|
return self::failed(sprintf('Spaltenbreite %s gibt es in Divi nicht', $part));
|
||||||
|
}
|
||||||
|
$fractions[] = [$numerator, $denominator];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($widths === [] || ! self::sumsToOne($fractions)) {
|
||||||
|
return self::failed(self::FULL_ROW);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['widths' => $widths, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S41: the desktop flexType values of all columns of a row (in order), checked with
|
||||||
|
* the rules of "columns" – valid Divi widths that fill the row exactly. Null when valid,
|
||||||
|
* else the German problem.
|
||||||
|
*
|
||||||
|
* @param list<mixed> $flexTypes
|
||||||
|
*/
|
||||||
|
public static function rowError(array $flexTypes): ?string
|
||||||
|
{
|
||||||
|
$valid = $flexTypes !== [] && array_filter(
|
||||||
|
$flexTypes,
|
||||||
|
static fn (mixed $width): bool => is_string($width) && preg_match('/^\d{1,2}_\d{1,2}$/D', $width) === 1
|
||||||
|
) === $flexTypes;
|
||||||
|
|
||||||
|
return $valid ? self::parse(implode(',', $flexTypes))['error'] : 'Spaltenbreiten wie "8_24" erwartet';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S43: whether $width is a column class the Visual Builder offers. */
|
||||||
|
public static function isColumnClass(mixed $width): bool
|
||||||
|
{
|
||||||
|
if (! is_string($width)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($width, self::COLUMN_CLASSES, true)
|
||||||
|
|| (preg_match('/^([1-9]|1\d|2[0-4])_24$/D', $width) === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column widths on update (rulings S41, S43, S49). Every new flexType – desktop and every
|
||||||
|
* other breakpoint – must be one of the builder's column classes (isColumnClass()) or null
|
||||||
|
* (removed; desktop then is Divi's default 24_24). The desktop row sum is only checked for
|
||||||
|
* rows that are exact today – every column has a class other than "none", the classes fill
|
||||||
|
* the row (rowError()), the row does not wrap or use a grid on desktop – and when no new
|
||||||
|
* width is "none". Such a row must stay exact after all changes; other rows are laid out by
|
||||||
|
* Divi as the builder allows.
|
||||||
|
*
|
||||||
|
* @param list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates Block, its new attrs, input path.
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function updateErrors(NodeIndex $index, array $updates): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$new = [];
|
||||||
|
$rows = [];
|
||||||
|
foreach ($updates as [$block, $attrs, $field]) {
|
||||||
|
if ($block->name() !== 'divi/column') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$breakpoint = self::invalidBreakpoint($block->attrs(), $attrs);
|
||||||
|
if ($breakpoint !== null) {
|
||||||
|
$errors[$field . '.attrs.' . self::SIZING] = sprintf(
|
||||||
|
'Spaltenbreite (flexType) für %s wie im Divi-Builder erwartet: %s',
|
||||||
|
$breakpoint,
|
||||||
|
self::CLASS_LIST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = self::width($attrs);
|
||||||
|
if ($width === self::width($block->attrs())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($width !== null && ! self::isColumnClass($width)) {
|
||||||
|
$errors[$field . '.attrs.' . self::SIZING] = 'Spaltenbreite (flexType) wie im Divi-Builder erwartet: '
|
||||||
|
. self::CLASS_LIST;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$new[spl_object_id($block)] = $width ?? self::DEFAULT_WIDTH;
|
||||||
|
$row = $index->parentOf($block);
|
||||||
|
if ($row instanceof Block) {
|
||||||
|
$rows[spl_object_id($row)]['row'] = $row;
|
||||||
|
$rows[spl_object_id($row)]['fields'][] = $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as ['row' => $row, 'fields' => $fields]) {
|
||||||
|
$before = [];
|
||||||
|
$after = [];
|
||||||
|
foreach ($row->children() as $column) {
|
||||||
|
$before[] = self::width($column->attrs());
|
||||||
|
$after[] = $new[spl_object_id($column)] ?? self::width($column->attrs());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::isExact($row, $before) || in_array('none', $after, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = self::rowError($after);
|
||||||
|
if ($error !== null) {
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$errors[$field . '.attrs.' . self::SIZING] = $error
|
||||||
|
. ' (Breiten aller Spalten der Zeile zusammen ändern)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<mixed> $widths Desktop flexTypes of the row's columns today. */
|
||||||
|
private static function isExact(Block $row, array $widths): bool
|
||||||
|
{
|
||||||
|
$layout = AttrMap::desktop($row->attrs(), 'module.decoration.layout');
|
||||||
|
$wraps = is_array($layout)
|
||||||
|
&& (($layout['flexWrap'] ?? null) === 'wrap' || ($layout['display'] ?? null) === 'grid');
|
||||||
|
if ($wraps) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::rowError($widths) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first non-desktop breakpoint whose flexType changed to something that is no column
|
||||||
|
* class (ruling S49), or null.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $before
|
||||||
|
* @param array<string, mixed> $after
|
||||||
|
*/
|
||||||
|
private static function invalidBreakpoint(array $before, array $after): ?string
|
||||||
|
{
|
||||||
|
foreach (AttrMap::BREAKPOINTS as $breakpoint) {
|
||||||
|
if ($breakpoint === 'desktop') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = self::width($after, $breakpoint);
|
||||||
|
if ($width !== null && $width !== self::width($before, $breakpoint) && ! self::isColumnClass($width)) {
|
||||||
|
return $breakpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attrs */
|
||||||
|
private static function width(array $attrs, string $breakpoint = 'desktop'): mixed
|
||||||
|
{
|
||||||
|
$map = AttrMap::get($attrs, self::SIZING);
|
||||||
|
$sizing = is_array($map) && is_array($map[$breakpoint] ?? null) ? ($map[$breakpoint]['value'] ?? null) : null;
|
||||||
|
|
||||||
|
return is_array($sizing) ? ($sizing['flexType'] ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{widths: list<string>, error: string} */
|
||||||
|
private static function failed(string $error): array
|
||||||
|
{
|
||||||
|
return ['widths' => [self::DEFAULT_WIDTH], 'error' => $error];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<array{int, int}> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds new Divi blocks from node descriptions (spec §5.4): short form, full form ("attrs")
|
||||||
|
* or both; sections with rows ("columns": "1_3,2_3") and modules; parent modules with
|
||||||
|
* "items"; presets by name; automatic admin labels "<Abschnitt> – <Modul>" (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 / 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
|
||||||
|
* names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built
|
||||||
|
* from digits (ColumnWidths). Design-system names that match several entries are errors
|
||||||
|
* listing the ids (ruling S13a: colors/variables in Placeholders, presets in
|
||||||
|
* DesignSystem::modulePresetId()).
|
||||||
|
*/
|
||||||
|
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'];
|
||||||
|
|
||||||
|
/** 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';
|
||||||
|
|
||||||
|
private const BACKGROUND = 'module.decoration.background';
|
||||||
|
|
||||||
|
private const DEFAULT_LEVEL = 'h2';
|
||||||
|
|
||||||
|
/** Advice of the S40 overlap error for new nodes. */
|
||||||
|
private const ONE_FORM = 'für diesen Pfad nur Kurzform oder attrs angeben';
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $errors = [];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
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 ValueCleaner $cleaner,
|
||||||
|
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<array-key, mixed> $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<Block>, errors: array<string, string>, invalid_paths: list<string>}
|
||||||
|
*/
|
||||||
|
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<array-key, mixed> $change id, set, attrs, preset, label
|
||||||
|
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
|
||||||
|
*/
|
||||||
|
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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$set = $change['set'] ?? null;
|
||||||
|
$short = is_array($set) ? $this->shortPaths($block->shortName(), $set, 'set.') : [];
|
||||||
|
if (array_key_exists('label', $change)) {
|
||||||
|
// Ruling S41: the admin label is a short-form value too.
|
||||||
|
$short['label'] = self::LABEL_PATH;
|
||||||
|
}
|
||||||
|
$this->overlaps(
|
||||||
|
$short,
|
||||||
|
$change['attrs'] ?? null,
|
||||||
|
$field,
|
||||||
|
'bitte als zwei Änderungen senden'
|
||||||
|
);
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rulings S38/S40: one change or new node may not reach the same attribute path through the
|
||||||
|
* short form 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<string, string> $short Input name of the short-form value ("set.text", "text") => its path.
|
||||||
|
* @param mixed $full The "attrs" input.
|
||||||
|
* @param string $advice How to resolve it, for the error message.
|
||||||
|
*/
|
||||||
|
private function overlaps(array $short, mixed $full, string $field, string $advice): void
|
||||||
|
{
|
||||||
|
if ($short === [] || ! is_array($full)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($full) as $path) {
|
||||||
|
$path = (string) $path;
|
||||||
|
foreach ($short as $name => $shortPath) {
|
||||||
|
if (AttrMap::overlaps($path, $shortPath)) {
|
||||||
|
$this->errors[$field . '.attrs.' . $path] = sprintf(
|
||||||
|
'derselbe Attributpfad wird auch über %s geändert; %s',
|
||||||
|
$name,
|
||||||
|
$advice
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<array-key, mixed> $full "attrs" input. */
|
||||||
|
private function touches(array $full, string $path): bool
|
||||||
|
{
|
||||||
|
foreach (array_keys($full) as $given) {
|
||||||
|
if (AttrMap::overlaps((string) $given, $path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S41: the default level of a new heading stays when attrs replaced the desktop font
|
||||||
|
* value it lives in (attrs replace a breakpoint value whole) – unless attrs name a level.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function defaultLevel(string $short, array $attrs): array
|
||||||
|
{
|
||||||
|
$field = ShortformMap::fields($short)['level'] ?? null;
|
||||||
|
if ($field === null || $field['sub'] === null) {
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = AttrMap::desktop($attrs, (string) $field['path']);
|
||||||
|
if ($value !== null && ! is_array($value)) {
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) && array_key_exists((string) $field['sub'], $value)) {
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = AttrMap::get($attrs, (string) $field['path'] . '.desktop.value');
|
||||||
|
$merged = array_merge([(string) $field['sub'] => self::DEFAULT_LEVEL], is_array($current) ? $current : []);
|
||||||
|
|
||||||
|
return AttrMap::set($attrs, (string) $field['path'] . '.desktop.value', $merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths of the short-form fields a client gave, keyed by their input name ($prefix . field).
|
||||||
|
*
|
||||||
|
* @param array<array-key, mixed> $values Field => value.
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function shortPaths(string $type, array $values, string $prefix): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
foreach ($this->shortform->paths($type, array_map('strval', array_keys($values))) as $name => $path) {
|
||||||
|
$paths[$prefix . $name] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin label for a new module: "<Abschnitt> – <Modultitel>" or the title alone. */
|
||||||
|
private 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'] = $this->typeFieldCollision($spec, $parent) ?? $violation;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'divi/section' => $this->section($spec, $path),
|
||||||
|
'divi/row' => $this->row($spec, $path, $context, $parent !== null && $parent->name() !== 'divi/section'),
|
||||||
|
default => $this->module($type, $spec, $path, $context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trial feedback: an item whose only possible type has a short-form field named "type" (the
|
||||||
|
* contact field's field type) – a `type` that is no allowed child was almost certainly meant as
|
||||||
|
* that field. Explains the collision instead of "unbekanntes Modul divi/<value>".
|
||||||
|
*
|
||||||
|
* @param array<array-key, mixed> $spec
|
||||||
|
*/
|
||||||
|
private function typeFieldCollision(array $spec, ?Block $parent): ?string
|
||||||
|
{
|
||||||
|
$children = $parent === null ? [] : $this->catalog->childrenOf($parent->name());
|
||||||
|
if (count($children) !== 1 || ! is_string($spec['type'] ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$child = ModuleCatalog::shortName($children[0]);
|
||||||
|
$field = ShortformMap::fields($child)['type'] ?? null;
|
||||||
|
if ($field === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
|
||||||
|
$value = json_encode(
|
||||||
|
$spec['type'],
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
|
||||||
|
);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'`type` ist hier der Knotentyp (%1$s, darf fehlen); das Feldtyp-Feld über attrs setzen: '
|
||||||
|
. '{"attrs": {"%2$s": {"desktop": %3$s}}} – oder type weglassen und danach mit lit-divi-update '
|
||||||
|
. '{"set": {"type": %3$s}} setzen.',
|
||||||
|
$child,
|
||||||
|
(string) $field['path'],
|
||||||
|
(string) $value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<array-key, mixed> $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<array-key, mixed> $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');
|
||||||
|
$this->overlaps(
|
||||||
|
['background_color' => self::BACKGROUND],
|
||||||
|
$spec['attrs'] ?? null,
|
||||||
|
$path,
|
||||||
|
self::ONE_FORM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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<array-key, mixed> $spec
|
||||||
|
* @param bool $nested In a column (Divi 5 rows nest): own auto label (ledger T3).
|
||||||
|
*/
|
||||||
|
private function row(array $spec, string $path, string $context, bool $nested): Block
|
||||||
|
{
|
||||||
|
$this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path);
|
||||||
|
$parsed = ColumnWidths::parse($spec['columns'] ?? '4_4');
|
||||||
|
if ($parsed['error'] !== null) {
|
||||||
|
$this->errors[$path . '.columns'] = $parsed['error'];
|
||||||
|
}
|
||||||
|
$widths = $parsed['widths'];
|
||||||
|
$label = $context === '' ? null : $context . ($nested ? ' – Zeile (innen)' : ' – 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<array-key, mixed> $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)));
|
||||||
|
$this->overlaps($this->shortPaths($short, $values, ''), $spec['attrs'] ?? null, $path, self::ONE_FORM);
|
||||||
|
$defaultLevel = $name === 'divi/heading' && ! array_key_exists('level', $values);
|
||||||
|
if ($defaultLevel) {
|
||||||
|
$values['level'] = self::DEFAULT_LEVEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = ['builderVersion' => $this->builderVersion];
|
||||||
|
if ($values !== []) {
|
||||||
|
$result = $this->shortform->apply($short, $attrs, $values, $path);
|
||||||
|
$attrs = $result['attrs'];
|
||||||
|
$this->errors += $result['errors'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context));
|
||||||
|
if ($defaultLevel) {
|
||||||
|
$attrs = $this->defaultLevel($short, $attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
$block = Block::create($name, $attrs);
|
||||||
|
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<string, mixed> $attrs
|
||||||
|
* @param array<array-key, mixed> $spec
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function common(string $name, array $attrs, array $spec, string $path, ?string $autoLabel): array
|
||||||
|
{
|
||||||
|
// Ruling S41: "label" and attrs on the admin label is an error; attrs alone replace the auto label.
|
||||||
|
$full = $spec['attrs'] ?? null;
|
||||||
|
if (array_key_exists('label', $spec) && $spec['label'] !== null) {
|
||||||
|
$this->overlaps(['label' => self::LABEL_PATH], $full, $path, self::ONE_FORM);
|
||||||
|
} elseif (is_array($full) && $this->touches($full, self::LABEL_PATH)) {
|
||||||
|
$autoLabel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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<string, mixed> $attrs
|
||||||
|
* @param array<array-key, mixed> $spec
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
// An unknown path is the more useful message on its key (it decides invalid_attr_path).
|
||||||
|
foreach ($result['invalid_paths'] as $invalid) {
|
||||||
|
$key = $path . '.attrs.' . $invalid;
|
||||||
|
if (isset($result['errors'][$key])) {
|
||||||
|
$this->errors[$key] = $result['errors'][$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$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<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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->cleaner->clean($name, self::LABEL_PATH, $label, null, ValueKind::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<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function preset(string $name, array $attrs, mixed $preset, string $path): array
|
||||||
|
{
|
||||||
|
if ($preset === null || $preset === '') {
|
||||||
|
unset($attrs['modulePreset']);
|
||||||
|
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
$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->cleaner->clean($name, 'modulePreset', $id, null, ValueKind::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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S13: the short-form color check (ShortformWriter::color()), never text().
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function backgroundColor(array $attrs, mixed $color, string $path): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$clean = $this->shortform->color('divi/section', self::BACKGROUND, $color, 'color');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$this->errors[$path] = $e->getMessage();
|
||||||
|
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttrMap::set($attrs, self::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed> $spec
|
||||||
|
* @param list<string> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. After the sanitizer a changed
|
||||||
|
* result of strip() only ever means rejection. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class ControlChars
|
||||||
|
{
|
||||||
|
public static function strip(string $value): string
|
||||||
|
{
|
||||||
|
return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* strip() 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 stripDeep(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
return self::strip($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($value) ? array_map([self::class, 'stripDeep'], $value) : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The allow list of content references ($variable({"type":"content",…})$) that VariableRef
|
||||||
|
* delegates to: which names exist and how each of their settings is cleaned. Pure PHP.
|
||||||
|
*
|
||||||
|
* Allowed names: a non-color global variable or a theme font (no settings, DESIGN_SETTINGS,
|
||||||
|
* S27); custom_meta_<key> 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<string, array<string, string>> 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_<key> 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_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
|
||||||
|
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D';
|
||||||
|
|
||||||
|
/** @var array<string, array<string, string>> 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_<key> 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(<name>) (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<string, array<string, string>> */
|
||||||
|
private array $map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed>|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<string, string> 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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($name, $this->design->contentVariableIds(), 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<x>(" → "$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<array-key, mixed> $map
|
||||||
|
* @return array<string, array<string, string>>
|
||||||
|
* @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<string, array<string, string>> $map */
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An independent second scan of an HTML fragment (ruling S45): a small state machine after the
|
||||||
|
* 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
|
||||||
|
* unterminated construct runs to the end, a script body containing "<!--" (the escaped/double-
|
||||||
|
* escaped script states) makes the rest of the string markup. Linear time, pure PHP.
|
||||||
|
*/
|
||||||
|
final class HtmlContext
|
||||||
|
{
|
||||||
|
/** 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<array{int, int}> [start, end) byte spans that are markup or raw bodies. */
|
||||||
|
public static function markupSpans(string $html): array
|
||||||
|
{
|
||||||
|
$spans = [];
|
||||||
|
$length = strlen($html);
|
||||||
|
$i = 0;
|
||||||
|
while ($i < $length) {
|
||||||
|
$lt = strpos($html, '<', $i);
|
||||||
|
if ($lt === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = self::markupEnd($html, $lt);
|
||||||
|
if ($end === $lt) {
|
||||||
|
$i = $lt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end ??= $length;
|
||||||
|
$name = self::rawName($html, $lt, $end);
|
||||||
|
if ($name !== null && $end < $length) {
|
||||||
|
$end = self::rawEnd($html, $name, $end);
|
||||||
|
}
|
||||||
|
$spans[] = [$lt, $end];
|
||||||
|
$i = $end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [start, end) overlaps no markup span. */
|
||||||
|
public static function isText(array $spans, int $start, int $end): bool
|
||||||
|
{
|
||||||
|
foreach ($spans as [$from, $to]) {
|
||||||
|
if ($start < $to && $from < $end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether offset $position lies between spans (not strictly inside one). */
|
||||||
|
public static function isBoundary(array $spans, int $position): bool
|
||||||
|
{
|
||||||
|
foreach ($spans as [$from, $to]) {
|
||||||
|
if ($from < $position && $position < $to) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End (exclusive) of the markup starting with the "<" at $lt; $lt itself when that "<" is
|
||||||
|
* plain text, null when the markup is not terminated.
|
||||||
|
*/
|
||||||
|
private static function markupEnd(string $html, int $lt): ?int
|
||||||
|
{
|
||||||
|
$next = $html[$lt + 1] ?? '';
|
||||||
|
if (substr($html, $lt, 4) === '<!--') {
|
||||||
|
return self::commentEnd($html, $lt + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($next === '!' || $next === '?') {
|
||||||
|
return self::after($html, '>', $lt + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($next === '/') {
|
||||||
|
$third = $html[$lt + 2] ?? '';
|
||||||
|
if (ctype_alpha($third)) {
|
||||||
|
return self::tagEnd($html, $lt + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $third === '>' ? $lt + 3 : self::after($html, '>', $lt + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctype_alpha($next) ? self::tagEnd($html, $lt + 2) : $lt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After "<!--": "-->" or "--!>", and the abrupt "<!-->" / "<!--->". */
|
||||||
|
private static function commentEnd(string $html, int $from): ?int
|
||||||
|
{
|
||||||
|
if (($html[$from] ?? '') === '>') {
|
||||||
|
return $from + 1;
|
||||||
|
}
|
||||||
|
if (substr($html, $from, 2) === '->') {
|
||||||
|
return $from + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$close = strpos($html, '-->', $from);
|
||||||
|
$bang = strpos($html, '--!>', $from);
|
||||||
|
if ($bang !== false && ($close === false || $bang < $close)) {
|
||||||
|
return $bang + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $close === false ? null : $close + 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inside a tag: ">" ends it unless it lies in an attribute value quoted right after "="
|
||||||
|
* (whitespace allowed around "=").
|
||||||
|
*/
|
||||||
|
private static function tagEnd(string $html, int $from): ?int
|
||||||
|
{
|
||||||
|
$length = strlen($html);
|
||||||
|
for ($p = $from; $p < $length; $p++) {
|
||||||
|
$char = $html[$p];
|
||||||
|
if ($char === '>') {
|
||||||
|
return $p + 1;
|
||||||
|
}
|
||||||
|
if ($char !== '=') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$p++;
|
||||||
|
while ($p < $length && strpos(" \t\n\r\f", $html[$p]) !== false) {
|
||||||
|
$p++;
|
||||||
|
}
|
||||||
|
$quote = $html[$p] ?? '';
|
||||||
|
if ($quote === '"' || $quote === "'") {
|
||||||
|
$close = strpos($html, $quote, $p + 1);
|
||||||
|
if ($close === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$p = $close;
|
||||||
|
} else {
|
||||||
|
$p--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function after(string $html, string $needle, int $from): ?int
|
||||||
|
{
|
||||||
|
$found = strpos($html, $needle, $from);
|
||||||
|
|
||||||
|
return $found === false ? null : $found + strlen($needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name of a raw-text element opened by the start tag [$lt, $end), else null. */
|
||||||
|
private static function rawName(string $html, int $lt, int $end): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/^<([A-Za-z][A-Za-z0-9]*)/', substr($html, $lt, min(20, $end - $lt)), $match) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = strtolower($match[1]);
|
||||||
|
|
||||||
|
return in_array($name, self::RAW, true) ? $name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the raw body after $from ends: at its "</name" followed by whitespace, "/" or ">".
|
||||||
|
* plaintext never ends; a script body with "<!--" is treated as running to the end.
|
||||||
|
*/
|
||||||
|
private static function rawEnd(string $html, string $name, int $from): int
|
||||||
|
{
|
||||||
|
$length = strlen($html);
|
||||||
|
if ($name === 'plaintext') {
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = $from;
|
||||||
|
while (($found = stripos($html, '</' . $name, $p)) !== false) {
|
||||||
|
$follow = $html[$found + 2 + strlen($name)] ?? '';
|
||||||
|
if ($follow !== '' && strpos(" \t\n\r\f/>", $follow) !== false) {
|
||||||
|
if ($name === 'script' && str_contains(substr($html, $from, $found - $from), '<!--')) {
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
$p = $found + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S45 tokenizer of lit/divi-replace-text (spec §5.7, rulings S8/S45): splits a string into
|
||||||
|
* tags (quote-aware – a ">" inside a quoted attribute value does not end the tag), comments,
|
||||||
|
* entities and $variable(…)$ references ("token"), the bodies of raw-text elements such as
|
||||||
|
* <script> ("raw") and the rest ("text"). A string with an unterminated tag, quote, comment or
|
||||||
|
* reference has no tokens (null) – nobody can say where its text is. TextReplacer searches text
|
||||||
|
* tokens only and compares the non-text structure before and after; HtmlContext is the second,
|
||||||
|
* independently written scan. Linear time, pure PHP.
|
||||||
|
*/
|
||||||
|
final class HtmlTokens
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Rulings S8/S45: comments, tags (quote-aware: a ">" inside a quoted attribute value does not
|
||||||
|
* end the tag; an unquoted "<" does, so an unterminated tag cannot make PCRE rescan the
|
||||||
|
* rest from every "<"), entities and references are tokens, the rest is text. Possessive
|
||||||
|
* quantifiers keep it linear.
|
||||||
|
*/
|
||||||
|
public const SPLIT = '/((?s:<!--.*?-->)|<(?:[^<>"\']++|"[^"]*+"|\'[^\']*+\')*+>|&[#A-Za-z0-9]++;'
|
||||||
|
. '|\$variable\(.+?\)\$)/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw-text elements: their body is code, never text. Must equal HtmlContext::RAW (the second,
|
||||||
|
* independent scan) – HtmlTokensTest asserts it, and that RAW_OPEN opens exactly these.
|
||||||
|
*/
|
||||||
|
public const RAW_ELEMENTS = ['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext'];
|
||||||
|
|
||||||
|
/** Opening tag of a RAW_ELEMENTS element. */
|
||||||
|
private const RAW_OPEN = '/^<(script|style|xmp|iframe|noembed|noframes|noscript|plaintext)\b/i';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S45 tokenizer: [type, bytes, offset] with type "token" (tag – quote-aware, ">" in a
|
||||||
|
* quoted value does not end it –, comment, entity, reference), "raw" (body of script, style
|
||||||
|
* and the other raw-text elements) or "text". Null when the value has an unterminated tag,
|
||||||
|
* quote, comment or reference – then nobody can say where its text is.
|
||||||
|
*
|
||||||
|
* @return list<array{string, string, int}>|null
|
||||||
|
*/
|
||||||
|
public static function tokenize(string $value): ?array
|
||||||
|
{
|
||||||
|
if (self::unterminated($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split(self::SPLIT, $value, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE);
|
||||||
|
if (! is_array($parts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = [];
|
||||||
|
$raw = null;
|
||||||
|
foreach ($parts as $i => [$text, $offset]) {
|
||||||
|
$text = (string) $text;
|
||||||
|
if ($i % 2 === 1) {
|
||||||
|
if ($raw === null && preg_match(self::RAW_OPEN, $text, $match) === 1) {
|
||||||
|
$raw = strtolower($match[1]);
|
||||||
|
} elseif ($raw !== null && self::closes($raw, $text)) {
|
||||||
|
$raw = null;
|
||||||
|
}
|
||||||
|
$tokens[] = ['token', $text, (int) $offset];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "<" that starts markup but matched no token: an unterminated tag or quote.
|
||||||
|
if ($raw === null && preg_match('#<[A-Za-z/!?]#', $text) === 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$tokens[] = [$raw === null ? 'text' : 'raw', $text, (int) $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether token $text closes raw-text element $raw ("</script-x>" does not; plaintext never ends). */
|
||||||
|
private static function closes(string $raw, string $text): bool
|
||||||
|
{
|
||||||
|
return $raw !== 'plaintext' && preg_match('#^</' . $raw . '[\s/>]#i', $text) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap checks that keep the tokenizer linear: a comment opened after the last "-->", or a
|
||||||
|
* reference marker after the last ")$" of its line, can never be closed – PCRE would retry
|
||||||
|
* it from every later opener.
|
||||||
|
*/
|
||||||
|
private static function unterminated(string $value): bool
|
||||||
|
{
|
||||||
|
$open = strrpos($value, '<!--');
|
||||||
|
if ($open !== false) {
|
||||||
|
$close = strrpos($value, '-->');
|
||||||
|
if ($close === false || $close < $open + 2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($value, VariableRef::MARKER)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode("\n", $value) as $line) {
|
||||||
|
$marker = strrpos($line, VariableRef::MARKER);
|
||||||
|
$end = $marker === false ? false : strrpos($line, VariableRef::END);
|
||||||
|
if ($marker !== false && ($end === false || $end < $marker)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{string, string, int}> $tokens
|
||||||
|
* @return list<array{string, string}> The non-text tokens (type and bytes), in order.
|
||||||
|
*/
|
||||||
|
public static function structure(array $tokens): array
|
||||||
|
{
|
||||||
|
$structure = [];
|
||||||
|
foreach ($tokens as [$type, $text]) {
|
||||||
|
if ($type !== 'text') {
|
||||||
|
$structure[] = [$type, $text];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $structure;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up media library images for short-form fields like image_id (spec §6.3: Divi image
|
||||||
|
* modules reference images by attachment ID plus URL). WpMediaResolver is the WordPress side.
|
||||||
|
*/
|
||||||
|
interface MediaResolver
|
||||||
|
{
|
||||||
|
/** @return array{url: string, alt: string}|null Null when $id is no image the user may read. */
|
||||||
|
public function image(int $id): ?array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
||||||
|
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
|
||||||
|
* {{post:title|…}}, {{field:<meta_key>}}. 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, DesignSystem::colorIdFor/variableFor). The result is still checked by VariableRef
|
||||||
|
* wherever it is written. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class Placeholders
|
||||||
|
{
|
||||||
|
/** {{post:<key>}} => 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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->colorIdFor($name);
|
||||||
|
if ($id === null) {
|
||||||
|
$colors = $this->design->colors();
|
||||||
|
$known = array_merge(
|
||||||
|
array_keys(array_intersect(DesignSystem::COLOR_ALIASES, array_column($colors, 'id'))),
|
||||||
|
array_column($colors, 'label')
|
||||||
|
);
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Unbekannte globale Farbe "%s". Vorhanden: %s.',
|
||||||
|
$name,
|
||||||
|
implode(', ', array_unique($known))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return VariableRef::serialize('color', $id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function variable(string $name): string
|
||||||
|
{
|
||||||
|
$id = $this->design->variableFor($name);
|
||||||
|
if ($id === null) {
|
||||||
|
$labels = array_column($this->design->variables(), 'label');
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Unbekannte globale Variable "%s". Vorhanden: %s.',
|
||||||
|
$name,
|
||||||
|
$labels === [] ? '(keine)' : implode(', ', $labels)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return VariableRef::serialize($this->design->isColorVariable($id) ? 'color' : 'content', $id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 VariableRef::serialize('content', $id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' => ''] : [];
|
||||||
|
|
||||||
|
return VariableRef::serialize('content', $option, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function field(string $name): string
|
||||||
|
{
|
||||||
|
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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return VariableRef::serialize('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes short-form fields (spec §5.4, ShortformMap) into Divi attributes: checks the value per
|
||||||
|
* kind, resolves placeholders (not for code) and sets it at "<path>.desktop.value[.<sub>]";
|
||||||
|
* null removes the field. Pure PHP.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
{
|
||||||
|
/** 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, array<string, mixed>>>|null $modulesForTests Short form
|
||||||
|
* per type, a test seam (trusted wiring only, ledger T2); null = ShortformMap.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private Placeholders $placeholders,
|
||||||
|
private MediaResolver $media,
|
||||||
|
private ValueCleaner $cleaner,
|
||||||
|
private ?array $modulesForTests = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $type Short module name ("button").
|
||||||
|
* @param array<string, mixed> $attrs Current attributes.
|
||||||
|
* @param array<string, mixed> $values Field => value.
|
||||||
|
* @return array{attrs: array<string, mixed>, errors: array<string, string>}
|
||||||
|
*/
|
||||||
|
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 = ControlChars::stripDeep($values);
|
||||||
|
$fields = $this->modulesForTests === null ? ShortformMap::fields($type) : ($this->modulesForTests[$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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute paths the given short-form fields of $type write to (unknown fields left out).
|
||||||
|
*
|
||||||
|
* @param list<string> $names Field names.
|
||||||
|
* @return array<string, string> Field => attribute path.
|
||||||
|
*/
|
||||||
|
public function paths(string $type, array $names): array
|
||||||
|
{
|
||||||
|
$fields = $this->modulesForTests === null ? ShortformMap::fields($type) : ($this->modulesForTests[$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
|
||||||
|
* 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->cleaner->clean($module, $path, $value, $subKey, ValueKind::Text);
|
||||||
|
if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') {
|
||||||
|
throw new InvalidArgumentException(self::COLOR_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match(self::COLOR, $value) !== 1) {
|
||||||
|
throw new InvalidArgumentException(self::COLOR_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @param array<string, mixed> $spec
|
||||||
|
* @param array<string, mixed> $all All values of this call (media must not overwrite an explicit alt).
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @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->cleaner->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<string, mixed> $attrs
|
||||||
|
* @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key.
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @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->cleaner->clean($module, $path, $image['url'], $urlKey, ValueKind::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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
// 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(ControlChars::strip($alt))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$clean = $this->cleaner->clean($module, $path, $alt, $altKey, ValueKind::Text);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($clean) && ! VariableRef::contains($clean) ? $clean : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 !== ValueKind::Code) {
|
||||||
|
$value = $this->placeholders->resolve($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$clean = $this->cleaner->clean($module, $path, $value, $sub, $kind);
|
||||||
|
if ($kind === ValueKind::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The forms a search text can take in stored block markup – the database prefilter of
|
||||||
|
* lit/divi-replace-text and the check for global modules it does not edit. Only a prefilter:
|
||||||
|
* the exact, safe match happens in the tree (TextReplacer). Pure PHP.
|
||||||
|
*/
|
||||||
|
final class StoredForms
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The search text as it can be stored in block markup: raw, as serialize_block_attributes()
|
||||||
|
* escapes it (<, >, &, " and --; the JS builder keeps non-ASCII raw) and with \uXXXX for
|
||||||
|
* non-ASCII (WordPress' PHP serializer). Case-insensitive adds the lower and upper case
|
||||||
|
* forms, since the hex digits of \u escapes do not fold.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function of(string $search, bool $caseSensitive): array
|
||||||
|
{
|
||||||
|
$variants = $caseSensitive ? [$search] : [$search, mb_strtolower($search), mb_strtoupper($search)];
|
||||||
|
$forms = [];
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$escaped = substr(Serializer::attributes(['s' => $variant]), 6, -2);
|
||||||
|
array_push($forms, $variant, $escaped, self::unicodeEscaped($escaped));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($forms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether block markup contains $search in one of its stored forms. */
|
||||||
|
public static function mentions(string $markup, string $search, bool $caseSensitive): bool
|
||||||
|
{
|
||||||
|
foreach (self::of($search, $caseSensitive) as $form) {
|
||||||
|
$found = $caseSensitive ? str_contains($markup, $form) : mb_stripos($markup, $form) !== false;
|
||||||
|
if ($found) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-ASCII characters as JSON \uXXXX escapes (UTF-16, lower-case hex like json_encode()). */
|
||||||
|
private static function unicodeEscaped(string $value): string
|
||||||
|
{
|
||||||
|
return (string) preg_replace_callback('/[^\x00-\x7F]/u', static function (array $match): string {
|
||||||
|
$units = unpack('n*', (string) mb_convert_encoding($match[0], 'UTF-16BE', 'UTF-8'));
|
||||||
|
|
||||||
|
return implode('', array_map(static fn (int $unit): string => sprintf('\\u%04x', $unit), (array) $units));
|
||||||
|
}, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which node may sit where (spec §5.4 "Struktur"): page → sections; regular section → rows;
|
||||||
|
* 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 = 'fullwidth-module';
|
||||||
|
|
||||||
|
public function __construct(private ModuleCatalog $catalog)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null when $child may be placed into $parent (null = top level of the page), else the German reason. */
|
||||||
|
public function violation(?Block $parent, string $child): ?string
|
||||||
|
{
|
||||||
|
if (! $this->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) && 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') {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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: catalog category "module" (not "fullwidth-module"). */
|
||||||
|
public function isPlainModule(string $name): bool
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$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 $this->catalog->category($child) === self::FULLWIDTH
|
||||||
|
? null
|
||||||
|
: 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces visible text in the attributes of one block (lit/divi-replace-text, spec §5.7, D10):
|
||||||
|
* only in content (…innerContent) – plain values and the text keys of object values (text,
|
||||||
|
* title, label, alt, titleText) – never in URLs, IDs, styles or $variable(…)$ references.
|
||||||
|
* Pure PHP.
|
||||||
|
*
|
||||||
|
* Rulings S8/S45: each string is tokenized by HtmlTokens (quote-aware tags, comments, entities,
|
||||||
|
* references, raw-text bodies such as <script>); only text tokens are searched. A string with an
|
||||||
|
* unterminated tag, quote, comment or reference and a hit is refused. The inserted text is the
|
||||||
|
* replacement as the $clean callback returns it for that place (ValueCleaner::textReplacementFor()
|
||||||
|
* in the ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
|
||||||
|
* effective hit is returned as it is and never cleaned (Task 1 forward risk). A changed string
|
||||||
|
* must keep every non-text token of the original byte for byte, gain only the inserted text's
|
||||||
|
* own, add no reference marker, and an independent scan (HtmlContext) must see each replaced
|
||||||
|
* hit and both ends of each insertion in text context – otherwise the whole call is refused
|
||||||
|
* (InvalidArgumentException, the page is not changed).
|
||||||
|
*/
|
||||||
|
final class TextReplacer
|
||||||
|
{
|
||||||
|
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
|
||||||
|
|
||||||
|
private const CONTEXT = 30;
|
||||||
|
|
||||||
|
private string $pattern;
|
||||||
|
|
||||||
|
/** @var array<string, ?string> Cleaned replacement per place (path|key). */
|
||||||
|
private array $cleaned = [];
|
||||||
|
|
||||||
|
/** @throws InvalidArgumentException When problems() finds any. */
|
||||||
|
public function __construct(private string $search, private string $replace, private bool $caseSensitive = true)
|
||||||
|
{
|
||||||
|
$problems = self::problems($search, $replace);
|
||||||
|
if ($problems !== []) {
|
||||||
|
throw new InvalidArgumentException(implode(' ', $problems));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pattern = '/' . preg_quote($search, '/') . '/u' . ($caseSensitive ? '' : 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input problems, field => message (empty when fine). The replacement must neither be nor
|
||||||
|
* contain a reference (rulings S2/D9) – also not one a sanitizer would build by stripping
|
||||||
|
* control characters, tags or %XX octets (S28/S32a); the search must not contain the marker.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function problems(string $search, string $replace): array
|
||||||
|
{
|
||||||
|
$problems = [];
|
||||||
|
if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) {
|
||||||
|
$problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.';
|
||||||
|
} 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 '
|
||||||
|
. 'nie ersetzt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! mb_check_encoding($replace, 'UTF-8')) {
|
||||||
|
$problems['replace'] = 'Ersetzung ist kein gültiges UTF-8.';
|
||||||
|
} elseif (VariableRef::contains(self::stripped($replace))) {
|
||||||
|
$problems['replace'] = 'Ersetzung darf keinen $variable(…)$-Verweis enthalten (auch nicht zerlegt); '
|
||||||
|
. 'dynamische Inhalte nur über lit/divi-update einzeln setzen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether block markup (e.g. of a global module this tool does not edit) contains the search text. */
|
||||||
|
public function mentions(string $markup): bool
|
||||||
|
{
|
||||||
|
return StoredForms::mentions($markup, $this->search, $this->caseSensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @param callable(string $path, ?string $key, string $replacement): ?string $clean
|
||||||
|
* The replacement as it may be inserted at that place, or null when the place is no text
|
||||||
|
* field (URL, code) and stays as it is. May throw InvalidArgumentException.
|
||||||
|
* @return array{attrs: array<string, mixed>, count: int,
|
||||||
|
* samples: list<array{path: string, before: string, after: string}>}
|
||||||
|
* @throws InvalidArgumentException When a replacement would change markup or references.
|
||||||
|
*/
|
||||||
|
public function apply(array $attrs, callable $clean): array
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
$samples = [];
|
||||||
|
foreach (AttrMap::flatten($attrs) as $path => $map) {
|
||||||
|
// A key containing "." would make the dotted path address something else.
|
||||||
|
$content = str_ends_with($path, '.innerContent') && AttrMap::isBreakpointMap($map);
|
||||||
|
if (! $content || AttrMap::get($attrs, $path) !== $map) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($map as $breakpoint => $states) {
|
||||||
|
foreach (is_array($states) ? $states : [] as $state => $value) {
|
||||||
|
$replaced = $this->value($value, $path, $clean, $count, $samples);
|
||||||
|
if ($replaced !== $value) {
|
||||||
|
$attrs = AttrMap::set($attrs, sprintf('%s.%s.%s', $path, $breakpoint, $state), $replaced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['attrs' => $attrs, 'count' => $count, 'samples' => $samples];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(string, ?string, string): ?string $clean
|
||||||
|
* @param list<array{path: string, before: string, after: string}> $samples
|
||||||
|
*/
|
||||||
|
private function value(mixed $value, string $path, callable $clean, int &$count, array &$samples): mixed
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
return $this->text($value, $path, null, $clean, $count, $samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
foreach (self::TEXT_KEYS as $key) {
|
||||||
|
if (is_string($value[$key] ?? null)) {
|
||||||
|
$value[$key] = $this->text($value[$key], $path, $key, $clean, $count, $samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(string, ?string, string): ?string $clean
|
||||||
|
* @param list<array{path: string, before: string, after: string}> $samples
|
||||||
|
*/
|
||||||
|
private function text(
|
||||||
|
string $value,
|
||||||
|
string $path,
|
||||||
|
?string $key,
|
||||||
|
callable $clean,
|
||||||
|
int &$count,
|
||||||
|
array &$samples
|
||||||
|
): string {
|
||||||
|
$place = $path . ($key === null ? '' : '.' . $key);
|
||||||
|
$tokens = HtmlTokens::tokenize($value);
|
||||||
|
if ($tokens === null) {
|
||||||
|
// Unterminated markup, quotes, comments or references: where does the text end? Never guess.
|
||||||
|
if (preg_match($this->pattern, $value) === 1) {
|
||||||
|
throw self::refused($place);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hits = $this->hits($tokens);
|
||||||
|
if ($hits === null || $hits === []) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacement = $this->replacement($path, $key, $clean);
|
||||||
|
if ($replacement === null) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only hits that change something (search == replacement, or a cleaned replacement equal
|
||||||
|
// to the match, change nothing and are no hits).
|
||||||
|
$hits = array_values(array_filter($hits, static fn (array $hit): bool => $hit[2] !== $replacement));
|
||||||
|
if ($hits === []) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inserted = HtmlTokens::tokenize($replacement);
|
||||||
|
if ($inserted === null) {
|
||||||
|
throw self::refused($place);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$replaced, $insertedRanges, $expected] = self::splice($value, $tokens, $hits, $replacement, $inserted);
|
||||||
|
if (! self::safe($value, $tokens, $hits, $replaced, $insertedRanges, $expected)) {
|
||||||
|
throw self::refused($place);
|
||||||
|
}
|
||||||
|
|
||||||
|
$count += count($hits);
|
||||||
|
if (count($samples) < 5) {
|
||||||
|
$samples[] = [
|
||||||
|
'path' => $place,
|
||||||
|
'before' => $this->excerpt(NodeSummary::textOf($value), $this->search),
|
||||||
|
'after' => $this->excerpt(NodeSummary::textOf($replaced), $replacement),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $replaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hits in text tokens: [byte offset in the value, byte length, matched text]. Null when a
|
||||||
|
* token cannot be searched (invalid UTF-8): the string stays as it is.
|
||||||
|
*
|
||||||
|
* @param list<array{string, string, int}> $tokens
|
||||||
|
* @return list<array{int, int, string}>|null
|
||||||
|
*/
|
||||||
|
private function hits(array $tokens): ?array
|
||||||
|
{
|
||||||
|
$hits = [];
|
||||||
|
foreach ($tokens as [$type, $text, $offset]) {
|
||||||
|
if ($type !== 'text') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all($this->pattern, $text, $matches, PREG_OFFSET_CAPTURE) === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($matches[0] as [$match, $at]) {
|
||||||
|
$hits[] = [$offset + (int) $at, strlen((string) $match), (string) $match];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value with every hit replaced, the byte ranges of the inserted text in it, and the
|
||||||
|
* non-text tokens it must have: the original ones with the inserted text's own at each hit.
|
||||||
|
*
|
||||||
|
* @param list<array{string, string, int}> $tokens
|
||||||
|
* @param list<array{int, int, string}> $hits
|
||||||
|
* @param list<array{string, string, int}> $inserted
|
||||||
|
* @return array{string, list<array{int, int}>, list<array{string, string}>}
|
||||||
|
*/
|
||||||
|
private static function splice(
|
||||||
|
string $value,
|
||||||
|
array $tokens,
|
||||||
|
array $hits,
|
||||||
|
string $replacement,
|
||||||
|
array $inserted
|
||||||
|
): array {
|
||||||
|
$own = HtmlTokens::structure($inserted);
|
||||||
|
$replaced = '';
|
||||||
|
$ranges = [];
|
||||||
|
$expected = [];
|
||||||
|
$position = 0;
|
||||||
|
$next = 0;
|
||||||
|
foreach ($tokens as [$type, $text, $offset]) {
|
||||||
|
if ($type !== 'text') {
|
||||||
|
$expected[] = [$type, $text];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = $offset + strlen($text);
|
||||||
|
while (isset($hits[$next]) && $hits[$next][0] < $end) {
|
||||||
|
[$at, $length] = $hits[$next];
|
||||||
|
$replaced .= substr($value, $position, $at - $position);
|
||||||
|
$ranges[] = [strlen($replaced), strlen($replaced) + strlen($replacement)];
|
||||||
|
$replaced .= $replacement;
|
||||||
|
array_push($expected, ...$own);
|
||||||
|
$position = $at + $length;
|
||||||
|
$next++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$replaced .= substr($value, $position);
|
||||||
|
|
||||||
|
return [$replaced, $ranges, $expected];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rulings S8/S45: the changed value is safe when (1) every reference marker of the original
|
||||||
|
* is a whole reference and none is added, (2) its full non-text tokens (tags byte for byte,
|
||||||
|
* comments, entities, references, raw bodies) are exactly the expected ones, and (3) an
|
||||||
|
* independent scan (HtmlContext) puts every replaced hit of the original and both ends of
|
||||||
|
* every inserted text in text context.
|
||||||
|
*
|
||||||
|
* @param list<array{string, string, int}> $tokens
|
||||||
|
* @param list<array{int, int, string}> $hits
|
||||||
|
* @param list<array{int, int}> $insertedRanges
|
||||||
|
* @param list<array{string, string}> $expected
|
||||||
|
*/
|
||||||
|
private static function safe(
|
||||||
|
string $value,
|
||||||
|
array $tokens,
|
||||||
|
array $hits,
|
||||||
|
string $replaced,
|
||||||
|
array $insertedRanges,
|
||||||
|
array $expected
|
||||||
|
): bool {
|
||||||
|
$markers = substr_count($value, VariableRef::MARKER);
|
||||||
|
$references = count(array_filter(
|
||||||
|
$tokens,
|
||||||
|
static fn (array $token): bool => $token[0] === 'token' && str_starts_with($token[1], VariableRef::MARKER)
|
||||||
|
));
|
||||||
|
$after = HtmlTokens::tokenize($replaced);
|
||||||
|
$sameMarkers = $markers === $references && substr_count($replaced, VariableRef::MARKER) === $markers;
|
||||||
|
if (! $sameMarkers || $after === null || HtmlTokens::structure($after) !== $expected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = HtmlContext::markupSpans($value);
|
||||||
|
foreach ($hits as [$at, $length]) {
|
||||||
|
if (! HtmlContext::isText($before, $at, $at + $length)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$spans = HtmlContext::markupSpans($replaced);
|
||||||
|
foreach ($insertedRanges as [$start, $end]) {
|
||||||
|
if (! HtmlContext::isBoundary($spans, $start) || ! HtmlContext::isBoundary($spans, $end)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param callable(string, ?string, string): ?string $clean */
|
||||||
|
private function replacement(string $path, ?string $key, callable $clean): ?string
|
||||||
|
{
|
||||||
|
$place = $path . '|' . ($key ?? '');
|
||||||
|
if (! array_key_exists($place, $this->cleaned)) {
|
||||||
|
$cleaned = $clean($path, $key, $this->replace);
|
||||||
|
if ($cleaned !== null && VariableRef::contains($cleaned)) {
|
||||||
|
throw VariableRef::notAlone();
|
||||||
|
}
|
||||||
|
$this->cleaned[$place] = $cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cleaned[$place];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $value as a sanitizer could leave it: controls gone, then tags (nested like strip_tags
|
||||||
|
* counts them, an unclosed one up to the end) and %XX octets removed until nothing changes.
|
||||||
|
*/
|
||||||
|
private static function stripped(string $value): string
|
||||||
|
{
|
||||||
|
$value = ControlChars::strip($value);
|
||||||
|
do {
|
||||||
|
$before = $value;
|
||||||
|
$kept = '';
|
||||||
|
$depth = 0;
|
||||||
|
foreach (str_split($value) as $char) {
|
||||||
|
if ($char === '<') {
|
||||||
|
$depth++;
|
||||||
|
} elseif ($depth > 0) {
|
||||||
|
$depth -= $char === '>' ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
$kept .= $char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$value = (string) preg_replace('/%[0-9A-Fa-f]{2}/', '', $kept);
|
||||||
|
} while ($value !== $before);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function refused(string $place): InvalidArgumentException
|
||||||
|
{
|
||||||
|
return new InvalidArgumentException(sprintf(
|
||||||
|
'"%s": die Ersetzung würde dort HTML-Tags, Entities oder $variable(…)$-Verweise verändern oder neu '
|
||||||
|
. 'bilden (Text grenzt an ein einzelnes "<", ">", "&" oder einen unvollständigen Verweis); nichts '
|
||||||
|
. 'wurde ersetzt.',
|
||||||
|
$place
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function excerpt(string $text, string $needle): string
|
||||||
|
{
|
||||||
|
$position = $needle === '' ? false
|
||||||
|
: ($this->caseSensitive ? mb_strpos($text, $needle) : mb_stripos($text, $needle));
|
||||||
|
if ($position === false) {
|
||||||
|
return mb_substr($text, 0, 2 * self::CONTEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = max(0, $position - self::CONTEXT);
|
||||||
|
$length = mb_strlen($needle) + 2 * self::CONTEXT;
|
||||||
|
|
||||||
|
return ($start > 0 ? '…' : '') . mb_substr($text, $start, $length)
|
||||||
|
. ($start + $length < mb_strlen($text) ? '…' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Container;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after,
|
||||||
|
* optional index), move, remove, duplicate – always checked against StructureRules. Works on
|
||||||
|
* the tree only; saving is DiviWriter's job. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class TreeEditor
|
||||||
|
{
|
||||||
|
public function __construct(private StructureRules $rules)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a target position: exactly one of parent, before, after; index only with parent.
|
||||||
|
* No position at all means "end of the page".
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $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->isPlaceholder() ? $container : null,
|
||||||
|
'index' => $key === 'before' ? $sibling : $sibling + 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{container: Container, parent: ?Block, index: int} $position
|
||||||
|
* @param list<Block> $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 = $index->parentBlock($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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single choke point through which every client string reaches Divi post_content. Divi
|
||||||
|
* saves bypass kses (D7), so this pipeline is the guarantee. Pure PHP.
|
||||||
|
*
|
||||||
|
* Pipeline of clean() – in this order, each step on the string named:
|
||||||
|
* 0. (caller) S35: ControlChars::stripDeep() on the RAW input, before placeholder resolution
|
||||||
|
* and trim – AttrWriter::apply(), ShortformWriter::apply().
|
||||||
|
* 1. S7/S26: guardPath() – builder-only paths (HTML, attributes, custom CSS) need
|
||||||
|
* unfiltered_html; callers also run it for removals (AttrWriter::apply, ShortformWriter::set).
|
||||||
|
* 2. S13: keys inside values are plain identifiers (VALUE_KEY); per string, the rest:
|
||||||
|
* 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(), 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).
|
||||||
|
* 8. S28: final-string check – a marker or control characters in the cleaned string →
|
||||||
|
* rejection. Nothing transforms the string after step 7.
|
||||||
|
*
|
||||||
|
* Invariants:
|
||||||
|
* (I1) No client string reaches Block attrs except via ValueCleaner::clean() or
|
||||||
|
* VariableRef::canonical().
|
||||||
|
* (I2) After the sanitizer there is only rejection, never another transformation.
|
||||||
|
* (I3) A stored reference is always the output of VariableRef::serialize().
|
||||||
|
*
|
||||||
|
* Callers add their own rule on top: ShortformWriter S13 color and S33b attachment alt,
|
||||||
|
* Composer admin label and preset id, TextReplacer S45 (through textReplacementFor()),
|
||||||
|
* DynamicContentRules the same raw-strip/marker/final-check steps for reference settings.
|
||||||
|
*/
|
||||||
|
final class ValueCleaner
|
||||||
|
{
|
||||||
|
/** 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).
|
||||||
|
*/
|
||||||
|
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private ModuleCatalog $catalog,
|
||||||
|
private VariableRef $references,
|
||||||
|
private ValueSanitizer $sanitizer
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function clean(
|
||||||
|
string $module,
|
||||||
|
string $path,
|
||||||
|
mixed $value,
|
||||||
|
?string $subKey = null,
|
||||||
|
?ValueKind $kind = null
|
||||||
|
): mixed {
|
||||||
|
$this->guardPath($path);
|
||||||
|
|
||||||
|
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind
|
||||||
|
{
|
||||||
|
return $this->kind($module, $path, $subKey, $subKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The $clean callback of TextReplacer::apply() for a block of $module (D10): replace-text
|
||||||
|
* touches only text and html kinds, and the inserted text goes through clean() with the
|
||||||
|
* routed kind; URL and code places yield null and stay as they are.
|
||||||
|
*
|
||||||
|
* @return callable(string, ?string, string): ?string
|
||||||
|
*/
|
||||||
|
public function textReplacementFor(string $module): callable
|
||||||
|
{
|
||||||
|
return function (string $path, ?string $key, string $value) use ($module): ?string {
|
||||||
|
$kind = $this->kindOf($module, $path, $key);
|
||||||
|
|
||||||
|
return $kind === ValueKind::Text || $kind === ValueKind::Html
|
||||||
|
? (string) $this->clean($module, $path, $value, $key)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html
|
||||||
|
* (S7/S26); also for removals, which the callers do without clean().
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps 2–8 for $value and everything inside it.
|
||||||
|
*
|
||||||
|
* @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 ValueKind|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,
|
||||||
|
?ValueKind $kind = null
|
||||||
|
): mixed {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$clean = [];
|
||||||
|
foreach ($value as $childKey => $item) {
|
||||||
|
// Step 2 (S13).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3.
|
||||||
|
if (! mb_check_encoding($value, 'UTF-8')) {
|
||||||
|
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 (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);
|
||||||
|
|
||||||
|
// Step 5 (S3/S3a/S23/S24, S33a).
|
||||||
|
$routed = $this->kind($module, $path, $subName, $key);
|
||||||
|
if ($kind !== null && ! $kind->tightens($routed)) {
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").',
|
||||||
|
$kind->value,
|
||||||
|
$path,
|
||||||
|
$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 === ValueKind::Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7.
|
||||||
|
$clean = match ($kind) {
|
||||||
|
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 ("<x>", "%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.
|
||||||
|
if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) {
|
||||||
|
throw VariableRef::notAlone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
if ($key !== null && is_string($this->catalog->kinds($module)[$path] ?? null)) {
|
||||||
|
$kind = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($kind) {
|
||||||
|
'richtext' => ValueKind::Html,
|
||||||
|
'code' => ValueKind::Code,
|
||||||
|
'url', 'image' => ValueKind::Url,
|
||||||
|
'unknown' => $this->unknownKind($path, $key),
|
||||||
|
default => ValueKind::Text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 ? ValueKind::Url : ValueKind::Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = explode('.', $path);
|
||||||
|
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) {
|
||||||
|
return ValueKind::Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path === 'content.innerContent' ? ValueKind::Html : ValueKind::Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user