feat: admin settings page UI/UX pass with WordPress-native patterns
- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs, aria-current on the active tab; own CSS/JS enqueued only on this screen. - Verbindung: ordered "So verbindest du Claude" steps, copy button (core clipboard.js + wp.a11y.speak), Site-Health-style status list, German details. - Zugriff: roles as form-table fieldset, connections with site date format, empty-state row, confirm on revoke; ConnectionUsage records last use via WP_Application_Passwords::record_application_password_usage (once a day). - Tools: plain-language legend, counter as inline notice, groups as core postboxes (postbox.js toggles, per-user closed state, summary when closed), fixed column widths with new "Art" column, submit above and below. - Protokoll: site-timezone times with UTC tooltip, result icons, filters with labels in the list table nav, retention link to Limits. - Limits: short labels with units and descriptions; snapshot count hidden but preserved. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
|||||||
|
/* Einstellungen → LIT MCP. Only small additions to WordPress' own admin styles. */
|
||||||
|
|
||||||
|
/* Header: title left, logo right; wraps instead of overlapping on narrow screens. */
|
||||||
|
.lit-mcp-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-logo {
|
||||||
|
display: block;
|
||||||
|
height: 30px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 782px) {
|
||||||
|
.lit-mcp-logo {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .nav-tab-wrapper {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Muted secondary text (tool names, client URLs). */
|
||||||
|
.lit-mcp-muted,
|
||||||
|
.lit-mcp-tool-name {
|
||||||
|
color: #646970;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tool-name {
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab "Verbindung". */
|
||||||
|
.lit-mcp-steps {
|
||||||
|
max-width: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-steps li {
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-copy-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-copy-row input.large-text {
|
||||||
|
flex: 1 1 22rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-copy-row .success {
|
||||||
|
color: #007017;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-status {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-status li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-status .dashicons {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-status-detail {
|
||||||
|
color: #50575e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-status-yes-alt .dashicons,
|
||||||
|
.lit-mcp-result-yes-alt .dashicons {
|
||||||
|
color: #00a32a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-status-warning .dashicons,
|
||||||
|
.lit-mcp-result-warning .dashicons {
|
||||||
|
color: #dba617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-status-dismiss .dashicons,
|
||||||
|
.lit-mcp-result-dismiss .dashicons {
|
||||||
|
color: #d63638;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab "Zugriff". */
|
||||||
|
.lit-mcp-connections + .description {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-connections .column-actions {
|
||||||
|
width: 8em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 782px) {
|
||||||
|
.lit-mcp-connections .column-created {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-connections .column-actions {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-copy-row input.large-text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab "Tools": legend, collapsible groups (WordPress postboxes) with aligned columns. */
|
||||||
|
.lit-mcp-legend {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 2px 12px;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-legend dt {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-legend dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-groups .postbox-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-groups .postbox .hndle {
|
||||||
|
cursor: pointer;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-groups .hndle .count {
|
||||||
|
color: #646970;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-group-summary {
|
||||||
|
color: #646970;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-group-summary::before {
|
||||||
|
content: "– ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-groups .postbox:not(.closed) .lit-mcp-group-summary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-group-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 16px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-group-controls .description {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-groups .handle-actions {
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same arrow indicator as WordPress' own meta boxes (core scopes it to .meta-box-sortables). */
|
||||||
|
.lit-mcp-groups .postbox .toggle-indicator::before {
|
||||||
|
content: "\f142";
|
||||||
|
content: "\f142" / '';
|
||||||
|
display: inline-block;
|
||||||
|
font: normal 20px/1 dashicons;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-groups .postbox.closed .toggle-indicator::before {
|
||||||
|
content: "\f140";
|
||||||
|
content: "\f140" / '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-groups .postbox .inside {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table .column-tool {
|
||||||
|
width: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table .column-kind {
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table .column-mode {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table .description {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-kind {
|
||||||
|
color: #50575e;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-kind .dashicons {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
color: #787c82;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 782px) {
|
||||||
|
.lit-mcp-group-controls {
|
||||||
|
order: 3;
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table .column-tool {
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table .column-kind {
|
||||||
|
width: 12%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table .column-mode {
|
||||||
|
width: 43%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table th,
|
||||||
|
.lit-mcp-tools-table td {
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon only on phones; the word stays for screen readers (and as tooltip-free fallback). */
|
||||||
|
.lit-mcp-kind-text {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-tools-table select {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
padding-right: 20px;
|
||||||
|
background-position: right 2px top 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab "Protokoll". */
|
||||||
|
.lit-mcp .wp-list-table .column-created_at {
|
||||||
|
width: 10em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .wp-list-table .column-user,
|
||||||
|
.lit-mcp .wp-list-table .column-client {
|
||||||
|
width: 8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .wp-list-table .column-ability,
|
||||||
|
.lit-mcp .wp-list-table .column-object {
|
||||||
|
width: 11em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .wp-list-table .column-revision,
|
||||||
|
.lit-mcp .wp-list-table .column-result {
|
||||||
|
width: 8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .wp-list-table .lit-mcp-partial {
|
||||||
|
background: #fcf9e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp-result .dashicons {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lit-mcp .tablenav .actions select,
|
||||||
|
.lit-mcp .tablenav .actions input[type="date"] {
|
||||||
|
float: left;
|
||||||
|
margin-right: 6px;
|
||||||
|
max-width: 12.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab "Limits". */
|
||||||
|
.lit-mcp-unit {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
}() );
|
||||||
+67
-30
@@ -89,16 +89,17 @@ final class AccessTab implements AdminTab
|
|||||||
public function render(): void
|
public function render(): void
|
||||||
{
|
{
|
||||||
$allowed = $this->access->allowedRoles();
|
$allowed = $this->access->allowedRoles();
|
||||||
|
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
|
||||||
|
|
||||||
echo '<h2>' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '</h2>';
|
|
||||||
echo '<p>' . esc_html__(
|
|
||||||
'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.',
|
|
||||||
'wp-lit-mcp'
|
|
||||||
) . '</p>';
|
|
||||||
echo '<form method="post" action="options.php">';
|
echo '<form method="post" action="options.php">';
|
||||||
settings_fields(self::GROUP);
|
settings_fields(self::GROUP);
|
||||||
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
|
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
|
||||||
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
|
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) {
|
foreach (wp_roles()->get_names() as $role => $name) {
|
||||||
printf(
|
printf(
|
||||||
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
|
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
|
||||||
@@ -108,6 +109,10 @@ final class AccessTab implements AdminTab
|
|||||||
esc_html(translate_user_role((string) $name))
|
esc_html(translate_user_role((string) $name))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
echo '</fieldset><p class="description">'
|
||||||
|
. esc_html__('Nur Benutzer dieser Rollen können Claude 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();
|
submit_button();
|
||||||
echo '</form>';
|
echo '</form>';
|
||||||
|
|
||||||
@@ -160,31 +165,42 @@ final class AccessTab implements AdminTab
|
|||||||
|
|
||||||
private function renderConnections(): void
|
private function renderConnections(): void
|
||||||
{
|
{
|
||||||
echo '<h2>' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '</h2>';
|
echo '<h2>' . esc_html__('Verbindungen', 'wp-lit-mcp') . '</h2>';
|
||||||
$connections = $this->connections->all();
|
$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 === []) {
|
if ($connections === []) {
|
||||||
echo '<p>' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '</p>';
|
printf(
|
||||||
return;
|
'<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 Claude:', 'wp-lit-mcp'),
|
||||||
|
esc_url(SettingsPage::url('verbindung')),
|
||||||
|
esc_html__('Tab „Verbindung“', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
echo '<table class="widefat striped"><thead><tr>';
|
|
||||||
$headings = [
|
|
||||||
__('Benutzer', 'wp-lit-mcp'),
|
|
||||||
__('Client', 'wp-lit-mcp'),
|
|
||||||
__('Erstellt', 'wp-lit-mcp'),
|
|
||||||
__('Zuletzt genutzt', 'wp-lit-mcp'),
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
foreach ($headings as $heading) {
|
|
||||||
echo '<th>' . esc_html($heading) . '</th>';
|
|
||||||
}
|
|
||||||
echo '</tr></thead><tbody>';
|
|
||||||
|
|
||||||
foreach ($connections as $connection) {
|
foreach ($connections as $connection) {
|
||||||
$this->renderConnectionRow($connection);
|
$this->renderConnectionRow($connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
echo '</tbody></table>';
|
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>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,21 +209,42 @@ final class AccessTab implements AdminTab
|
|||||||
*/
|
*/
|
||||||
private function renderConnectionRow(array $connection): void
|
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 '<tr>';
|
||||||
echo '<td>' . esc_html($connection['user_login']) . '</td>';
|
echo '<td class="column-user">' . esc_html($connection['user_login']) . '</td>';
|
||||||
printf('<td>%s<br><code>%s</code></td>', esc_html($connection['name']), esc_html($connection['app_id']));
|
printf(
|
||||||
echo '<td>' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . '</td>';
|
'<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
|
$lastUsed = $connection['last_used'] === null
|
||||||
? '–'
|
? '–'
|
||||||
: (string) wp_date('Y-m-d H:i', $connection['last_used']);
|
: (string) wp_date($dateFormat, $connection['last_used']);
|
||||||
echo '<td>' . esc_html($lastUsed) . '</td>';
|
echo '<td class="column-last-used">' . esc_html($lastUsed) . '</td>';
|
||||||
echo '<td>';
|
echo '<td class="column-actions">';
|
||||||
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
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="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="user_id" value="%s">', esc_attr((string) $connection['user_id']));
|
||||||
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
|
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
|
||||||
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
|
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
|
||||||
submit_button(__('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false);
|
$confirm = esc_js(sprintf(
|
||||||
|
/* translators: %s: client name */
|
||||||
|
__('Verbindung „%s“ widerrufen? Claude muss sich danach neu anmelden.', 'wp-lit-mcp'),
|
||||||
|
$connection['name']
|
||||||
|
));
|
||||||
|
submit_button(
|
||||||
|
__('Widerrufen', 'wp-lit-mcp'),
|
||||||
|
'delete small',
|
||||||
|
'submit',
|
||||||
|
false,
|
||||||
|
['onclick' => "return window.confirm('" . $confirm . "');"]
|
||||||
|
);
|
||||||
echo '</form></td></tr>';
|
echo '</form></td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class ConnectionStatus
|
|||||||
$code = (int) wp_remote_retrieve_response_code($response);
|
$code = (int) wp_remote_retrieve_response_code($response);
|
||||||
$challenge = (string) wp_remote_retrieve_header($response, 'www-authenticate');
|
$challenge = (string) wp_remote_retrieve_header($response, 'www-authenticate');
|
||||||
$result = $code === 401 && str_starts_with($challenge, 'Bearer')
|
$result = $code === 401 && str_starts_with($challenge, 'Bearer')
|
||||||
? ['ok' => true, 'message' => 'Ohne Token: 401 mit WWW-Authenticate – korrekt.']
|
? ['ok' => true, 'message' => 'Erreichbar; verlangt wie erwartet eine Anmeldung (HTTP 401).']
|
||||||
: ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)];
|
: ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +132,19 @@ class ConnectionStatus
|
|||||||
: __('Website läuft ohne HTTPS – claude.ai verbindet sich nur über HTTPS', 'wp-lit-mcp'),
|
: __('Website läuft ohne HTTPS – claude.ai verbindet sich nur über HTTPS', 'wp-lit-mcp'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => __('Discovery (/.well-known/)', 'wp-lit-mcp'),
|
'label' => __('OAuth-Discovery', 'wp-lit-mcp'),
|
||||||
'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good',
|
'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good',
|
||||||
'message' => $discovery === null
|
'message' => match (true) {
|
||||||
? __('noch nicht geprüft', 'wp-lit-mcp')
|
$discovery === null => __('noch nicht geprüft', 'wp-lit-mcp'),
|
||||||
: wp_strip_all_tags((string) ($discovery['label'] ?? '')),
|
($discovery['status'] ?? '') === 'good' => __('Discovery-Dokumente erreichbar', 'wp-lit-mcp'),
|
||||||
|
default => __(
|
||||||
|
'Discovery-Dokumente (/.well-known/) nicht erreichbar – Details unter Website-Zustand',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => __('Endpunkt ohne Token', 'wp-lit-mcp'),
|
'label' => __('MCP-Endpunkt', 'wp-lit-mcp'),
|
||||||
'ok' => $endpoint['ok'] ?? null,
|
'ok' => $endpoint['ok'] ?? null,
|
||||||
'message' => $endpoint['message'] ?? __('noch nicht geprüft', 'wp-lit-mcp'),
|
'message' => $endpoint['message'] ?? __('noch nicht geprüft', 'wp-lit-mcp'),
|
||||||
],
|
],
|
||||||
|
|||||||
+40
-16
@@ -30,29 +30,53 @@ final class ConnectionTab implements AdminTab
|
|||||||
|
|
||||||
public function render(): void
|
public function render(): void
|
||||||
{
|
{
|
||||||
echo '<h2>' . esc_html__('MCP-Server', 'wp-lit-mcp') . '</h2>';
|
echo '<h2>' . esc_html__('So verbindest du Claude', 'wp-lit-mcp') . '</h2>';
|
||||||
|
echo '<ol class="lit-mcp-steps"><li>';
|
||||||
printf(
|
printf(
|
||||||
'<p><label for="lit-mcp-url">%s</label><br>'
|
'<label for="lit-mcp-url">%s</label>'
|
||||||
. '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s"></p>',
|
. '<span class="lit-mcp-copy-row copy-to-clipboard-container">'
|
||||||
esc_html__('Server-URL für den Connector', 'wp-lit-mcp'),
|
. '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s">'
|
||||||
esc_attr(ConnectionStatus::serverUrl())
|
. '<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 '<p>' . esc_html__(
|
echo '</li><li>' . esc_html__(
|
||||||
'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen;',
|
'In claude.ai unter Einstellungen → Connectors auf „Add custom connector“ klicken und die URL einfügen.',
|
||||||
'wp-lit-mcp'
|
'wp-lit-mcp'
|
||||||
) . ' ' . esc_html__('Die Anmeldung läuft danach über diese Website.', 'wp-lit-mcp') . '</p>';
|
) . '</li><li>';
|
||||||
|
|
||||||
echo '<h2>' . esc_html__('Status', 'wp-lit-mcp') . '</h2><table class="widefat striped"><tbody>';
|
|
||||||
foreach ($this->status->checks(true) as $check) {
|
|
||||||
$state = $check['ok'] === null ? '–' : ($check['ok'] ? '✔' : '✖');
|
|
||||||
printf(
|
printf(
|
||||||
'<tr><th scope="row">%s</th><td>%s</td><td>%s</td></tr>',
|
/* translators: %s: link to the "Zugriff" tab */
|
||||||
esc_html($check['label']),
|
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($state),
|
||||||
|
esc_html($check['label']),
|
||||||
esc_html($check['message'])
|
esc_html($check['message'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
echo '</tbody></table>';
|
echo '</ul>';
|
||||||
echo '<p>' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '</p>';
|
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,81 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 || is_wp_error($response)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = $request->get_header('authorization');
|
||||||
|
$userId = get_current_user_id();
|
||||||
|
if (! is_string($header) || stripos($header, 'Bearer ') !== 0 || $userId <= 0) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$claims = ($this->decode)(substr($header, 7));
|
||||||
|
$uuid = is_array($claims) ? (string) ($claims['app_pass_id'] ?? '') : '';
|
||||||
|
// Only the password of the user this request actually authenticated as.
|
||||||
|
if ($uuid !== '' && (int) ($claims['sub'] ?? 0) === $userId) {
|
||||||
|
($this->recordUsage)($userId, $uuid);
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Bookkeeping only – never break the MCP response.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-16
@@ -7,7 +7,8 @@ namespace LIT\MCP\Admin;
|
|||||||
use LIT\MCP\Limits;
|
use LIT\MCP\Limits;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab "Limits": upload/download size, log retention, snapshot count (spec §8.4).
|
* Tab "Limits": upload/download size and log retention (spec §8.4). The snapshot count is
|
||||||
|
* kept as a hidden field until the design system feature that uses it exists.
|
||||||
* Changes apply immediately – Limits::all() always reads the current option.
|
* Changes apply immediately – Limits::all() always reads the current option.
|
||||||
*/
|
*/
|
||||||
final class LimitsTab implements AdminTab
|
final class LimitsTab implements AdminTab
|
||||||
@@ -41,39 +42,56 @@ final class LimitsTab implements AdminTab
|
|||||||
{
|
{
|
||||||
$values = (new Limits())->all();
|
$values = (new Limits())->all();
|
||||||
$bounds = Limits::bounds();
|
$bounds = Limits::bounds();
|
||||||
$labels = [
|
$fields = [
|
||||||
'upload_mb' => sprintf(
|
'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 */
|
/* 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'),
|
||||||
'Maximale Upload-/Download-Größe (MB, höchstens %1$d; Base64-Upload/Download höchstens %2$d MB)',
|
|
||||||
'wp-lit-mcp'
|
|
||||||
),
|
|
||||||
$bounds['upload_mb'][1],
|
$bounds['upload_mb'][1],
|
||||||
Limits::INLINE_MAX_MB
|
Limits::INLINE_MAX_MB
|
||||||
),
|
),
|
||||||
'log_retention_days' => __('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'),
|
],
|
||||||
'snapshot_count' => __('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'),
|
'log_retention_days' => [
|
||||||
|
__('Protokoll aufbewahren', 'wp-lit-mcp'),
|
||||||
|
__('Tage', 'wp-lit-mcp'),
|
||||||
|
__('Ältere Einträge im Protokoll werden automatisch gelöscht.', 'wp-lit-mcp'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
echo '<form method="post" action="options.php">';
|
echo '<form method="post" action="options.php">';
|
||||||
settings_fields(self::GROUP);
|
settings_fields(self::GROUP);
|
||||||
echo '<table class="form-table"><tbody>';
|
echo '<table class="form-table" role="presentation"><tbody>';
|
||||||
foreach ($labels as $key => $label) {
|
foreach ($fields as $key => [$label, $unit, $description]) {
|
||||||
[$min, $max] = $bounds[$key];
|
[$min, $max] = $bounds[$key];
|
||||||
printf(
|
printf(
|
||||||
'<tr><th scope="row"><label for="lit-mcp-%1$s">%2$s</label></th><td>'
|
'<tr><th scope="row"><label for="lit-mcp-%1$s">%2$s</label></th><td>'
|
||||||
. '<input id="lit-mcp-%1$s" type="number" min="%3$d" max="%4$d" name="%5$s[%1$s]" value="%6$d">'
|
. '<input id="lit-mcp-%1$s" class="small-text" type="number" min="%3$d" max="%4$d"'
|
||||||
. '</td></tr>',
|
. ' 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_attr($key),
|
||||||
esc_html($label),
|
esc_html($label),
|
||||||
esc_attr((string) $min),
|
esc_attr((string) $min),
|
||||||
esc_attr((string) $max),
|
esc_attr((string) $max),
|
||||||
esc_attr(Limits::OPTION),
|
esc_attr(Limits::OPTION),
|
||||||
esc_attr((string) $values[$key])
|
esc_attr((string) $values[$key]),
|
||||||
|
esc_html($unit),
|
||||||
|
esc_html($description)
|
||||||
);
|
);
|
||||||
|
if ($key === 'upload_mb') {
|
||||||
|
$this->maybeRenderUploadSizeNotice($values['upload_mb']);
|
||||||
|
}
|
||||||
|
echo '</td></tr>';
|
||||||
}
|
}
|
||||||
echo '</tbody></table>';
|
echo '</tbody></table>';
|
||||||
$this->maybeRenderUploadSizeNotice($values['upload_mb']);
|
// 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();
|
submit_button();
|
||||||
echo '</form>';
|
echo '</form>';
|
||||||
}
|
}
|
||||||
@@ -96,6 +114,6 @@ final class LimitsTab implements AdminTab
|
|||||||
$uploadMb,
|
$uploadMb,
|
||||||
$serverMaxMb
|
$serverMaxMb
|
||||||
);
|
);
|
||||||
printf('<p class="description">%s</p>', esc_html($message));
|
printf('<div class="notice notice-warning inline"><p>%s</p></div>', esc_html($message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ final class LogListTable extends \WP_List_Table
|
|||||||
{
|
{
|
||||||
private const PER_PAGE = 50;
|
private const PER_PAGE = 50;
|
||||||
|
|
||||||
|
/** @var (callable(): void)|null */
|
||||||
|
private $renderFilters;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
|
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
|
||||||
* } $filters
|
* } $filters
|
||||||
|
* @param (callable(): void)|null $renderFilters prints the filter controls into the top table nav
|
||||||
*/
|
*/
|
||||||
public function __construct(private ChangeLog $log, private array $filters)
|
public function __construct(private ChangeLog $log, private array $filters, ?callable $renderFilters = null)
|
||||||
{
|
{
|
||||||
|
$this->renderFilters = $renderFilters;
|
||||||
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
|
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +37,7 @@ final class LogListTable extends \WP_List_Table
|
|||||||
public function get_columns(): array
|
public function get_columns(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'),
|
'created_at' => __('Zeit', 'wp-lit-mcp'),
|
||||||
'user' => __('Benutzer', 'wp-lit-mcp'),
|
'user' => __('Benutzer', 'wp-lit-mcp'),
|
||||||
'client' => __('Client', 'wp-lit-mcp'),
|
'client' => __('Client', 'wp-lit-mcp'),
|
||||||
'ability' => __('Tool', 'wp-lit-mcp'),
|
'ability' => __('Tool', 'wp-lit-mcp'),
|
||||||
@@ -57,6 +62,22 @@ final class LogListTable extends \WP_List_Table
|
|||||||
esc_html_e('Keine Einträge.', 'wp-lit-mcp');
|
esc_html_e('Keine Einträge.', 'wp-lit-mcp');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters left of the pagination in the top table nav, as on WordPress' own list screens.
|
||||||
|
*
|
||||||
|
* @param string $which
|
||||||
|
*/
|
||||||
|
protected function extra_tablenav($which): void
|
||||||
|
{
|
||||||
|
if ($which !== 'top' || $this->renderFilters === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<div class="alignleft actions">';
|
||||||
|
($this->renderFilters)();
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlights rows with result "partial" (a write that failed after an irreversible
|
* Highlights rows with result "partial" (a write that failed after an irreversible
|
||||||
* step, spec §2 principle 7/8.2) so admins notice them without reading every row.
|
* step, spec §2 principle 7/8.2) so admins notice them without reading every row.
|
||||||
@@ -65,12 +86,61 @@ final class LogListTable extends \WP_List_Table
|
|||||||
*/
|
*/
|
||||||
public function single_row($item): void
|
public function single_row($item): void
|
||||||
{
|
{
|
||||||
$style = ($item['result'] ?? '') === 'partial' ? ' style="background:#fcf3e3;"' : '';
|
$class = ($item['result'] ?? '') === 'partial' ? ' class="lit-mcp-partial"' : '';
|
||||||
printf('<tr%s>', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
|
printf('<tr%s>', $class); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
|
||||||
$this->single_row_columns($item);
|
$this->single_row_columns($item);
|
||||||
echo '</tr>';
|
echo '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored in UTC (MySQL format); shown in the site's timezone and date/time format,
|
||||||
|
* with the UTC value as tooltip.
|
||||||
|
*
|
||||||
|
* @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}$/', $utc) === 1
|
||||||
|
? strtotime($utc . ' UTC')
|
||||||
|
: false;
|
||||||
|
if ($timestamp === false) {
|
||||||
|
return '–';
|
||||||
|
}
|
||||||
|
|
||||||
|
$format = get_option('date_format') . ' ' . get_option('time_format');
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<span title="%s">%s</span>',
|
||||||
|
esc_attr('UTC: ' . $utc),
|
||||||
|
esc_html((string) wp_date($format, $timestamp))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $item */
|
||||||
|
protected function column_ability($item): string
|
||||||
|
{
|
||||||
|
return '<span class="lit-mcp-tool-name">' . esc_html((string) ($item['ability'] ?? '')) . '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $item */
|
||||||
|
protected function column_result($item): string
|
||||||
|
{
|
||||||
|
$result = (string) ($item['result'] ?? '');
|
||||||
|
[$icon, $word] = match ($result) {
|
||||||
|
'ok' => ['yes-alt', __('erfolgreich', 'wp-lit-mcp')],
|
||||||
|
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
|
||||||
|
default => ['dismiss', $result],
|
||||||
|
};
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<span class="lit-mcp-result lit-mcp-result-%1$s">'
|
||||||
|
. '<span class="dashicons dashicons-%1$s" aria-hidden="true"></span> %2$s</span>',
|
||||||
|
esc_attr($icon),
|
||||||
|
esc_html($word)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $item
|
* @param array<string, mixed> $item
|
||||||
* @param string $column_name
|
* @param string $column_name
|
||||||
@@ -144,7 +214,7 @@ final class LogListTable extends \WP_List_Table
|
|||||||
return sprintf(
|
return sprintf(
|
||||||
'<a href="%s">%s</a>',
|
'<a href="%s">%s</a>',
|
||||||
esc_url(admin_url('revision.php?revision=' . $revisionId)),
|
esc_url(admin_url('revision.php?revision=' . $revisionId)),
|
||||||
esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp')
|
esc_html__('Revision ansehen', 'wp-lit-mcp')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-23
@@ -94,21 +94,34 @@ final class LogTab implements AdminTab
|
|||||||
|
|
||||||
$filters = $this->filters();
|
$filters = $this->filters();
|
||||||
$extra = $this->extraFilters();
|
$extra = $this->extraFilters();
|
||||||
|
$days = $this->limits->logRetentionDays();
|
||||||
printf(
|
printf(
|
||||||
'<p>%s</p>',
|
'<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(
|
esc_html(sprintf(
|
||||||
/* translators: %d: number of days */
|
/* translators: %d: number of days */
|
||||||
__('Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“).', 'wp-lit-mcp'),
|
_n('%d Tag', '%d Tagen', $days, 'wp-lit-mcp'),
|
||||||
$this->limits->logRetentionDays()
|
$days
|
||||||
))
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
printf('<p>%s</p>', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'));
|
|
||||||
|
|
||||||
$this->renderFilterForm($filters, $extra);
|
echo '<form method="get">';
|
||||||
|
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
|
||||||
$table = new LogListTable($this->log, $filters + $extra);
|
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
|
||||||
|
$table = new LogListTable($this->log, $filters + $extra, function () use ($filters, $extra): void {
|
||||||
|
$this->renderFilterControls($filters, $extra);
|
||||||
|
});
|
||||||
$table->prepare_items();
|
$table->prepare_items();
|
||||||
$table->display();
|
$table->display();
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
$this->renderClearForm();
|
$this->renderClearForm();
|
||||||
}
|
}
|
||||||
@@ -117,13 +130,11 @@ final class LogTab implements AdminTab
|
|||||||
* @param array{ability: string, user_id: int} $filters
|
* @param array{ability: string, user_id: int} $filters
|
||||||
* @param array{result: string, date_from: string, date_to: string} $extra
|
* @param array{result: string, date_from: string, date_to: string} $extra
|
||||||
*/
|
*/
|
||||||
private function renderFilterForm(array $filters, array $extra): void
|
private function renderFilterControls(array $filters, array $extra): void
|
||||||
{
|
{
|
||||||
echo '<form method="get">';
|
self::filterLabel('ability', __('Nach Tool filtern', 'wp-lit-mcp'));
|
||||||
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
|
echo '<select name="ability" id="lit-mcp-filter-ability"><option value="">'
|
||||||
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
|
. esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
|
||||||
|
|
||||||
echo '<select name="ability"><option value="">' . esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
|
|
||||||
foreach ($this->log->abilities() as $ability) {
|
foreach ($this->log->abilities() as $ability) {
|
||||||
printf(
|
printf(
|
||||||
'<option value="%1$s"%2$s>%1$s</option>',
|
'<option value="%1$s"%2$s>%1$s</option>',
|
||||||
@@ -133,37 +144,49 @@ final class LogTab implements AdminTab
|
|||||||
}
|
}
|
||||||
echo '</select>';
|
echo '</select>';
|
||||||
|
|
||||||
|
self::filterLabel('user-id', __('Nach Benutzer filtern', 'wp-lit-mcp'));
|
||||||
wp_dropdown_users([
|
wp_dropdown_users([
|
||||||
'name' => 'user_id',
|
'name' => 'user_id',
|
||||||
|
'id' => 'lit-mcp-filter-user-id',
|
||||||
'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'),
|
'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'),
|
||||||
'selected' => $filters['user_id'],
|
'selected' => $filters['user_id'],
|
||||||
]);
|
]);
|
||||||
echo ' ';
|
|
||||||
|
|
||||||
echo '<select name="result">';
|
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'));
|
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) {
|
foreach (self::RESULTS as $result) {
|
||||||
printf(
|
printf(
|
||||||
'<option value="%1$s"%2$s>%1$s</option>',
|
'<option value="%s"%s>%s</option>',
|
||||||
esc_attr($result),
|
esc_attr($result),
|
||||||
selected($extra['result'], $result, false)
|
selected($extra['result'], $result, false),
|
||||||
|
esc_html($labels[$result])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
echo '</select>';
|
echo '</select>';
|
||||||
|
|
||||||
|
self::filterLabel('date-from', __('Von Datum', 'wp-lit-mcp'));
|
||||||
printf(
|
printf(
|
||||||
'<label>%s <input type="date" name="date_from" value="%s"></label> ',
|
'<input type="date" name="date_from" id="lit-mcp-filter-date-from" value="%s">',
|
||||||
esc_html__('von', 'wp-lit-mcp'),
|
|
||||||
esc_attr($extra['date_from'])
|
esc_attr($extra['date_from'])
|
||||||
);
|
);
|
||||||
|
self::filterLabel('date-to', __('Bis Datum', 'wp-lit-mcp'));
|
||||||
printf(
|
printf(
|
||||||
'<label>%s <input type="date" name="date_to" value="%s"></label> ',
|
'<input type="date" name="date_to" id="lit-mcp-filter-date-to" value="%s">',
|
||||||
esc_html__('bis', 'wp-lit-mcp'),
|
|
||||||
esc_attr($extra['date_to'])
|
esc_attr($extra['date_to'])
|
||||||
);
|
);
|
||||||
|
|
||||||
submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false);
|
submit_button(__('Filtern', 'wp-lit-mcp'), '', 'filter_action', false, ['id' => 'lit-mcp-filter-submit']);
|
||||||
echo '</form>';
|
}
|
||||||
|
|
||||||
|
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
|
private function renderClearForm(): void
|
||||||
|
|||||||
+47
-11
@@ -11,15 +11,24 @@ final class SettingsPage
|
|||||||
{
|
{
|
||||||
public const SLUG = 'lit-mcp';
|
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> */
|
/** @var array<string, AdminTab> */
|
||||||
private array $tabs = [];
|
private array $tabs = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<AdminTab> $tabs The first tab is the default.
|
* @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 $logoUrl Loosen-IT logo shown top right of the page ('' = none).
|
||||||
|
* @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
|
||||||
|
* @param string $version Asset version for cache busting.
|
||||||
*/
|
*/
|
||||||
public function __construct(array $tabs, private string $logoUrl = '')
|
public function __construct(
|
||||||
{
|
array $tabs,
|
||||||
|
private string $logoUrl = '',
|
||||||
|
private string $assetsUrl = '',
|
||||||
|
private string $version = ''
|
||||||
|
) {
|
||||||
foreach ($tabs as $tab) {
|
foreach ($tabs as $tab) {
|
||||||
$this->tabs[$tab->slug()] = $tab;
|
$this->tabs[$tab->slug()] = $tab;
|
||||||
}
|
}
|
||||||
@@ -29,6 +38,7 @@ final class SettingsPage
|
|||||||
{
|
{
|
||||||
add_action('admin_menu', [$this, 'addMenu']);
|
add_action('admin_menu', [$this, 'addMenu']);
|
||||||
add_action('admin_init', [$this, 'registerSettings']);
|
add_action('admin_init', [$this, 'registerSettings']);
|
||||||
|
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
|
||||||
foreach ($this->tabs as $tab) {
|
foreach ($this->tabs as $tab) {
|
||||||
$tab->registerActions();
|
$tab->registerActions();
|
||||||
}
|
}
|
||||||
@@ -52,6 +62,29 @@ final class SettingsPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Own CSS and a small script, only on this page. The script builds on WordPress' own
|
||||||
|
* clipboard.js and wp.a11y.speak (as the media modal's "Copy URL") and postbox.js.
|
||||||
|
*/
|
||||||
|
public function enqueueAssets(string $hook): void
|
||||||
|
{
|
||||||
|
if ($hook !== self::SCREEN_HOOK || $this->assetsUrl === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = rtrim($this->assetsUrl, '/');
|
||||||
|
wp_enqueue_style('lit-mcp-admin', $base . '/css/admin.css', [], $this->version);
|
||||||
|
$deps = ['clipboard', 'wp-a11y'];
|
||||||
|
if ($this->currentTab() === 'tools') {
|
||||||
|
// Collapsible tool groups use WordPress' own postbox toggles (remembered per user).
|
||||||
|
$deps[] = 'postbox';
|
||||||
|
}
|
||||||
|
wp_enqueue_script('lit-mcp-admin', $base . '/js/admin.js', $deps, $this->version, true);
|
||||||
|
wp_localize_script('lit-mcp-admin', 'litMcpAdmin', [
|
||||||
|
'copied' => __('Die URL wurde in die Zwischenablage kopiert.', 'wp-lit-mcp'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public function tabSlugs(): array
|
public function tabSlugs(): array
|
||||||
{
|
{
|
||||||
@@ -81,16 +114,19 @@ final class SettingsPage
|
|||||||
}
|
}
|
||||||
|
|
||||||
$current = $this->currentTab();
|
$current = $this->currentTab();
|
||||||
echo '<div class="wrap">';
|
echo '<div class="wrap lit-mcp">';
|
||||||
|
echo '<div class="lit-mcp-header"><h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
|
||||||
$this->renderLogo();
|
$this->renderLogo();
|
||||||
echo '<h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
|
// Admin notices of other plugins are moved right after this marker – above the tabs.
|
||||||
|
echo '</div><hr class="wp-header-end">';
|
||||||
$this->renderNotice();
|
$this->renderNotice();
|
||||||
echo '<nav class="nav-tab-wrapper">';
|
printf('<nav class="nav-tab-wrapper wp-clearfix" aria-label="%s">', esc_attr__('Bereiche', 'wp-lit-mcp'));
|
||||||
foreach ($this->tabs as $slug => $tab) {
|
foreach ($this->tabs as $slug => $tab) {
|
||||||
printf(
|
printf(
|
||||||
'<a href="%s" class="nav-tab%s">%s</a>',
|
'<a href="%s" class="nav-tab%s"%s>%s</a>',
|
||||||
esc_url(self::url($slug)),
|
esc_url(self::url($slug)),
|
||||||
$slug === $current ? ' nav-tab-active' : '',
|
$slug === $current ? ' nav-tab-active' : '',
|
||||||
|
$slug === $current ? ' aria-current="page"' : '',
|
||||||
esc_html($tab->label())
|
esc_html($tab->label())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,11 +142,11 @@ final class SettingsPage
|
|||||||
}
|
}
|
||||||
|
|
||||||
printf(
|
printf(
|
||||||
'<div class="lit-mcp-header"><a href="https://loosen-it.de" target="_blank" rel="noopener">'
|
'<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"></a></div>'
|
. '<img src="%s" alt="Loosen-IT Logo" class="lit-mcp-logo">'
|
||||||
. '<style>.lit-mcp-header{position:relative}.lit-mcp-logo{position:absolute;padding:10px;right:0;'
|
. '<span class="screen-reader-text"> %s</span></a>',
|
||||||
. 'top:0;height:30px;width:auto}@media (max-width:782px){.lit-mcp-logo{height:24px}}</style>',
|
esc_url($this->logoUrl),
|
||||||
esc_url($this->logoUrl)
|
esc_html__('(öffnet in einem neuen Tab)', 'wp-lit-mcp')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+199
-70
@@ -61,79 +61,177 @@ final class ToolsTab implements AdminTab
|
|||||||
|
|
||||||
public function render(): void
|
public function render(): void
|
||||||
{
|
{
|
||||||
echo '<p>'
|
|
||||||
. esc_html__('Direkt: sofort als eigenes Tool sichtbar.', 'wp-lit-mcp') . ' '
|
|
||||||
. esc_html__('Indirekt: nur über die Meta-Tools (discover/execute) erreichbar.', 'wp-lit-mcp') . ' '
|
|
||||||
. esc_html__('Deaktiviert: gar nicht verfügbar.', 'wp-lit-mcp')
|
|
||||||
. '</p>';
|
|
||||||
echo '<p>'
|
|
||||||
. esc_html__(
|
|
||||||
'Die Server-Anweisungen beim Verbinden nennen immer die aktuell direkt sichtbaren Tools.',
|
|
||||||
'wp-lit-mcp'
|
|
||||||
) . ' '
|
|
||||||
. esc_html__(
|
|
||||||
'Änderungen gelten also ab der nächsten Verbindung; in claude.ai ggf. den Connector neu verbinden.',
|
|
||||||
'wp-lit-mcp'
|
|
||||||
)
|
|
||||||
. '</p>';
|
|
||||||
echo '<p>' . esc_html__(
|
|
||||||
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
|
|
||||||
'wp-lit-mcp'
|
|
||||||
) . '</p>';
|
|
||||||
$policy = $this->policy->withGroups($this->registry->groupMap());
|
$policy = $this->policy->withGroups($this->registry->groupMap());
|
||||||
|
$groups = $this->registry->grouped();
|
||||||
|
|
||||||
|
$this->renderIntro(isset($groups['divi']));
|
||||||
$this->renderCounter(count($policy->directNames($this->registry->abilities())));
|
$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">';
|
echo '<form method="post" action="options.php">';
|
||||||
settings_fields(self::GROUP);
|
settings_fields(self::GROUP);
|
||||||
foreach ($this->registry->grouped() as $group => $abilities) {
|
submit_button(null, 'primary', 'submit-top');
|
||||||
$this->renderGroup($policy, $group, $abilities);
|
echo '<div class="lit-mcp-groups">';
|
||||||
|
$closed = $this->savedClosedBoxes();
|
||||||
|
foreach ($groups as $group => $abilities) {
|
||||||
|
$this->renderGroup($policy, $group, $abilities, $closed);
|
||||||
}
|
}
|
||||||
|
echo '</div>';
|
||||||
submit_button();
|
submit_button();
|
||||||
echo '</form>';
|
echo '</form>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function renderIntro(bool $diviAvailable): void
|
||||||
|
{
|
||||||
|
echo '<p class="description">'
|
||||||
|
. esc_html__('Lege fest, welche Tools Claude nutzen kann.', 'wp-lit-mcp') . ' '
|
||||||
|
. esc_html__('Änderungen gelten ab der nächsten Verbindung.', 'wp-lit-mcp') . ' '
|
||||||
|
. esc_html__('In claude.ai den Connector dafür gegebenenfalls neu verbinden.', 'wp-lit-mcp')
|
||||||
|
. '</p>';
|
||||||
|
|
||||||
|
echo '<dl class="lit-mcp-legend">';
|
||||||
|
$legend = [
|
||||||
|
__('Direkt', 'wp-lit-mcp') => __('Claude sieht das Tool sofort.', 'wp-lit-mcp'),
|
||||||
|
__('Indirekt', 'wp-lit-mcp') => __('Claude 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>';
|
||||||
|
|
||||||
|
if ($diviAvailable) {
|
||||||
|
echo '<p class="description">' . esc_html__(
|
||||||
|
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
) . '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function renderCounter(int $direct): void
|
private function renderCounter(int $direct): void
|
||||||
{
|
{
|
||||||
echo '<p><strong>' . esc_html(sprintf(
|
$warn = $direct > self::DIRECT_WARNING_THRESHOLD;
|
||||||
|
$counter = sprintf(
|
||||||
/* translators: %d: number of direct tools */
|
/* translators: %d: number of direct tools */
|
||||||
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
||||||
$direct
|
$direct
|
||||||
)) . '</strong></p>';
|
);
|
||||||
|
|
||||||
if ($direct > self::DIRECT_WARNING_THRESHOLD) {
|
|
||||||
echo '<p class="description"><strong>' . esc_html__('Achtung:', 'wp-lit-mcp') . '</strong> '
|
|
||||||
. esc_html__(
|
|
||||||
'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.',
|
|
||||||
'wp-lit-mcp'
|
|
||||||
) . ' '
|
|
||||||
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp')
|
|
||||||
. '</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param list<Ability> $abilities */
|
|
||||||
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void
|
|
||||||
{
|
|
||||||
$count = count($abilities);
|
|
||||||
$mixed = false;
|
|
||||||
foreach ($abilities as $ability) {
|
|
||||||
$mixed = $mixed || $policy->hasToolOverride($ability->name());
|
|
||||||
}
|
|
||||||
|
|
||||||
printf(
|
printf(
|
||||||
'<details open class="lit-mcp-tool-group"><summary>%s</summary>',
|
'<div class="notice notice-%s inline"><p><strong>%s</strong>',
|
||||||
esc_html(sprintf(
|
$warn ? 'warning' : 'info',
|
||||||
/* translators: 1: group label, 2: number of tools */
|
esc_html($counter)
|
||||||
_n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'),
|
|
||||||
$this->groupLabel($group),
|
|
||||||
$count
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$groupMode = $policy->groupMode($group);
|
if ($warn) {
|
||||||
|
echo ' – '
|
||||||
|
. esc_html__('Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.', 'wp-lit-mcp')
|
||||||
|
. ' '
|
||||||
|
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
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, array $abilities, ?array $closedBoxes): void
|
||||||
|
{
|
||||||
|
$count = count($abilities);
|
||||||
|
$label = $this->groupLabel($group);
|
||||||
|
$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(
|
printf(
|
||||||
'<p><label>%s <select name="%s[%s]">',
|
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
|
||||||
esc_html__('Ganze Gruppe:', 'wp-lit-mcp'),
|
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::OPTION),
|
||||||
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
|
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
|
||||||
);
|
);
|
||||||
@@ -150,25 +248,20 @@ final class ToolsTab implements AdminTab
|
|||||||
esc_html($this->modeLabel($mode))
|
esc_html($this->modeLabel($mode))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
echo '</select></label> ';
|
echo '</select></span>';
|
||||||
printf(
|
printf(
|
||||||
'<label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label>',
|
'<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::OPTION),
|
||||||
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
||||||
checked($policy->isReadonly($group), true, false),
|
checked($policy->isReadonly($group), true, false),
|
||||||
esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp')
|
esc_html__('Nur lesen', 'wp-lit-mcp')
|
||||||
);
|
);
|
||||||
if ($mixed) {
|
if ($mixed) {
|
||||||
echo ' <em>' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . '</em>';
|
echo '<span class="description">'
|
||||||
|
. esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp')
|
||||||
|
. '</span>';
|
||||||
}
|
}
|
||||||
echo '</p>';
|
echo '</div>';
|
||||||
|
|
||||||
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Tool', 'wp-lit-mcp') . '</th><th>'
|
|
||||||
. esc_html__('Einstellung', 'wp-lit-mcp') . '</th></tr></thead><tbody>';
|
|
||||||
foreach ($abilities as $ability) {
|
|
||||||
$this->renderTool($policy, $ability, $groupMode !== null);
|
|
||||||
}
|
|
||||||
echo '</tbody></table></details>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
|
private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
|
||||||
@@ -177,11 +270,23 @@ final class ToolsTab implements AdminTab
|
|||||||
$own = $policy->toolOverride($name);
|
$own = $policy->toolOverride($name);
|
||||||
$locked = $policy->disabledByReadonly($ability);
|
$locked = $policy->disabledByReadonly($ability);
|
||||||
$required = in_array($name, ExposurePolicy::REQUIRED, true);
|
$required = in_array($name, ExposurePolicy::REQUIRED, true);
|
||||||
|
$selectId = 'lit-mcp-tool-' . self::idPart($name);
|
||||||
|
|
||||||
printf(
|
printf(
|
||||||
'<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]"%s>',
|
'<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($ability->label()),
|
||||||
esc_html($name),
|
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(ExposurePolicy::OPTION),
|
||||||
esc_attr($name),
|
esc_attr($name),
|
||||||
$locked ? ' disabled' : ''
|
$locked ? ' disabled' : ''
|
||||||
@@ -220,17 +325,41 @@ final class ToolsTab implements AdminTab
|
|||||||
}
|
}
|
||||||
|
|
||||||
$note = match (true) {
|
$note = match (true) {
|
||||||
$locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'),
|
$locked => __('Durch „Nur lesen“ aus', 'wp-lit-mcp'),
|
||||||
$own === null && $policy->groupModeAdjusted($ability)
|
$own === null && $policy->groupModeAdjusted($ability)
|
||||||
=> __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
|
=> __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
|
||||||
default => '',
|
default => '',
|
||||||
};
|
};
|
||||||
if ($note !== '') {
|
if ($note !== '') {
|
||||||
echo ' <em>' . esc_html($note) . '</em>';
|
echo '<p class="description">' . esc_html($note) . '</p>';
|
||||||
}
|
}
|
||||||
echo '</td></tr>';
|
echo '</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "liest" / "schreibt" / "löscht" from the ability's annotations, with a dashicon. */
|
||||||
|
private function kind(Ability $ability): string
|
||||||
|
{
|
||||||
|
$annotations = $ability->annotations();
|
||||||
|
[$icon, $text] = match (true) {
|
||||||
|
($annotations['destructive'] ?? false) === true => ['trash', __('löscht', 'wp-lit-mcp')],
|
||||||
|
($annotations['readonly'] ?? false) === true => ['visibility', __('liest', 'wp-lit-mcp')],
|
||||||
|
default => ['edit', __('schreibt', 'wp-lit-mcp')],
|
||||||
|
};
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<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 groupLabel(string $group): string
|
private function groupLabel(string $group): string
|
||||||
{
|
{
|
||||||
return match ($group) {
|
return match ($group) {
|
||||||
@@ -248,7 +377,7 @@ final class ToolsTab implements AdminTab
|
|||||||
return match ($mode) {
|
return match ($mode) {
|
||||||
ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
|
ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
|
||||||
ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
|
ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
|
||||||
default => __('deaktiviert', 'wp-lit-mcp'),
|
default => __('aus', 'wp-lit-mcp'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -11,6 +11,7 @@ use LIT\MCP\Admin\AdminNotices;
|
|||||||
use LIT\MCP\Admin\ConnectionRepository;
|
use LIT\MCP\Admin\ConnectionRepository;
|
||||||
use LIT\MCP\Admin\ConnectionStatus;
|
use LIT\MCP\Admin\ConnectionStatus;
|
||||||
use LIT\MCP\Admin\ConnectionTab;
|
use LIT\MCP\Admin\ConnectionTab;
|
||||||
|
use LIT\MCP\Admin\ConnectionUsage;
|
||||||
use LIT\MCP\Admin\LimitsTab;
|
use LIT\MCP\Admin\LimitsTab;
|
||||||
use LIT\MCP\Admin\LogTab;
|
use LIT\MCP\Admin\LogTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
@@ -87,13 +88,20 @@ final class Plugin
|
|||||||
|
|
||||||
$status = new ConnectionStatus();
|
$status = new ConnectionStatus();
|
||||||
(new SiteHealth($status))->register();
|
(new SiteHealth($status))->register();
|
||||||
self::$settingsPage = new SettingsPage([
|
$tabs = [
|
||||||
new ConnectionTab($status),
|
new ConnectionTab($status),
|
||||||
new AccessTab($access, new ConnectionRepository()),
|
new AccessTab($access, new ConnectionRepository()),
|
||||||
new ToolsTab($registry, $policy),
|
new ToolsTab($registry, $policy),
|
||||||
new LogTab($changeLog, $limits),
|
new LogTab($changeLog, $limits),
|
||||||
new LimitsTab(),
|
new LimitsTab(),
|
||||||
], plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile));
|
];
|
||||||
|
self::$settingsPage = new SettingsPage(
|
||||||
|
$tabs,
|
||||||
|
plugins_url('assets/img/loosen-it-logo.svg', self::$pluginFile),
|
||||||
|
plugins_url('assets', self::$pluginFile),
|
||||||
|
self::VERSION
|
||||||
|
);
|
||||||
|
(new ConnectionUsage())->register();
|
||||||
if (is_admin()) {
|
if (is_admin()) {
|
||||||
self::$settingsPage->register();
|
self::$settingsPage->register();
|
||||||
(new AdminNotices($status))->register();
|
(new AdminNotices($status))->register();
|
||||||
|
|||||||
@@ -83,7 +83,14 @@ if (! class_exists('WP_List_Table')) {
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Like the real class: the top table nav (with extra_tablenav()) comes before the rows. */
|
||||||
public function display(): void
|
public function display(): void
|
||||||
|
{
|
||||||
|
$this->extra_tablenav('top');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param string $which */
|
||||||
|
protected function extra_tablenav($which)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,21 +171,36 @@ final class AccessTabTest extends TestCase
|
|||||||
(new AccessTab(new Access(), $connections))->handleRevoke();
|
(new AccessTab(new Access(), $connections))->handleRevoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenderShowsRolesAndConnections(): void
|
/** @param mixed $roles value of the roles option */
|
||||||
|
private function stubRender(mixed $roles): void
|
||||||
{
|
{
|
||||||
Functions\when('get_option')->justReturn(['editor']);
|
Functions\when('get_option')->alias(static fn (string $key, $default = false) => match ($key) {
|
||||||
|
'lit_mcp_allowed_roles' => $roles,
|
||||||
|
'date_format' => 'd.m.Y',
|
||||||
|
'time_format' => 'H:i',
|
||||||
|
default => $default,
|
||||||
|
});
|
||||||
Functions\when('translate_user_role')->returnArg();
|
Functions\when('translate_user_role')->returnArg();
|
||||||
Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : '');
|
Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : '');
|
||||||
Functions\when('settings_fields')->justReturn(null);
|
Functions\when('settings_fields')->justReturn(null);
|
||||||
Functions\when('submit_button')->alias(static function (): void {
|
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = ''): void {
|
||||||
echo '<button>Speichern</button>';
|
$attrs = is_array($other) ? implode(' ', array_map(
|
||||||
|
static fn ($k, $v): string => sprintf('%s="%s"', $k, $v),
|
||||||
|
array_keys($other),
|
||||||
|
$other
|
||||||
|
)) : '';
|
||||||
|
echo '<button ' . $attrs . '>' . ($text ?? 'Speichern') . '</button>';
|
||||||
});
|
});
|
||||||
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
|
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
|
||||||
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
||||||
});
|
});
|
||||||
Functions\when('wp_date')->justReturn('25.09.2026 10:00');
|
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
|
||||||
$connections = Mockery::mock(ConnectionRepository::class);
|
}
|
||||||
$connections->allows('all')->andReturn([[
|
|
||||||
|
/** @param array<string, mixed> $overrides */
|
||||||
|
private function connection(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return $overrides + [
|
||||||
'user_id' => 3,
|
'user_id' => 3,
|
||||||
'user_login' => 'redaktion<b>',
|
'user_login' => 'redaktion<b>',
|
||||||
'uuid' => 'uuid-1',
|
'uuid' => 'uuid-1',
|
||||||
@@ -193,38 +208,73 @@ final class AccessTabTest extends TestCase
|
|||||||
'app_id' => 'https://claude.ai/x',
|
'app_id' => 'https://claude.ai/x',
|
||||||
'created' => 1790000000,
|
'created' => 1790000000,
|
||||||
'last_used' => null,
|
'last_used' => null,
|
||||||
]]);
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTab(array $connections): string
|
||||||
|
{
|
||||||
|
$repository = Mockery::mock(ConnectionRepository::class);
|
||||||
|
$repository->allows('all')->andReturn($connections);
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
(new AccessTab(new Access(), $connections))->render();
|
(new AccessTab(new Access(), $repository))->render();
|
||||||
$html = (string) ob_get_clean();
|
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderShowsRolesAndConnections(): void
|
||||||
|
{
|
||||||
|
$this->stubRender(['editor']);
|
||||||
|
|
||||||
|
$html = $this->renderTab([$this->connection()]);
|
||||||
|
|
||||||
self::assertStringContainsString('value="editor" checked="checked"', $html);
|
self::assertStringContainsString('value="editor" checked="checked"', $html);
|
||||||
self::assertStringNotContainsString('value="administrator" checked', $html);
|
self::assertStringNotContainsString('value="administrator" checked', $html);
|
||||||
self::assertStringContainsString('Claude <script>', $html);
|
self::assertStringContainsString('<strong>Claude <script></strong>', $html);
|
||||||
self::assertStringContainsString('redaktion<b>', $html);
|
self::assertStringContainsString('redaktion<b>', $html);
|
||||||
self::assertStringNotContainsString('<script>', $html);
|
self::assertStringNotContainsString('<script>', $html);
|
||||||
|
self::assertStringNotContainsString('<code>', $html);
|
||||||
self::assertStringContainsString('value="uuid-1"', $html);
|
self::assertStringContainsString('value="uuid-1"', $html);
|
||||||
self::assertStringContainsString('name="user_id" value="3"', $html);
|
self::assertStringContainsString('name="user_id" value="3"', $html);
|
||||||
self::assertStringContainsString('value="lit_mcp_revoke"', $html);
|
self::assertStringContainsString('value="lit_mcp_revoke"', $html);
|
||||||
self::assertStringContainsString('nonce-lit_mcp_revoke_uuid-1', $html);
|
self::assertStringContainsString('nonce-lit_mcp_revoke_uuid-1', $html);
|
||||||
|
self::assertStringContainsString('confirm(', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenderWithoutConnections(): void
|
public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_option')->justReturn(['administrator']);
|
$this->stubRender(['editor']);
|
||||||
Functions\when('translate_user_role')->returnArg();
|
|
||||||
Functions\when('checked')->justReturn('');
|
|
||||||
Functions\when('settings_fields')->justReturn(null);
|
|
||||||
Functions\when('submit_button')->justReturn(null);
|
|
||||||
$connections = Mockery::mock(ConnectionRepository::class);
|
|
||||||
$connections->allows('all')->andReturn([]);
|
|
||||||
|
|
||||||
ob_start();
|
$html = $this->renderTab([]);
|
||||||
(new AccessTab(new Access(), $connections))->render();
|
|
||||||
$html = (string) ob_get_clean();
|
|
||||||
|
|
||||||
self::assertStringContainsString('Keine aktiven Verbindungen.', $html);
|
self::assertStringContainsString('<table class="form-table" role="presentation">', $html);
|
||||||
self::assertStringNotContainsString('<table', $html);
|
self::assertStringContainsString('<th scope="row">Rollen mit Zugriff</th>', $html);
|
||||||
|
self::assertStringContainsString('<fieldset><legend class="screen-reader-text"><span>Rollen mit Zugriff</span></legend>', $html);
|
||||||
|
self::assertStringContainsString('<p class="description">Nur Benutzer dieser Rollen können Claude verbinden.', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDatesUseTheSiteDateAndTimeFormat(): void
|
||||||
|
{
|
||||||
|
$this->stubRender(['editor']);
|
||||||
|
|
||||||
|
$html = $this->renderTab([
|
||||||
|
$this->connection(['uuid' => 'a', 'last_used' => null]),
|
||||||
|
$this->connection(['uuid' => 'b', 'last_used' => 1790050000]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('d.m.Y H:i@1790000000', $html);
|
||||||
|
self::assertStringContainsString('d.m.Y@1790050000', $html);
|
||||||
|
self::assertStringContainsString('<td class="column-last-used">–</td>', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderWithoutConnectionsShowsAnEmptyStateRowLinkingToTheConnectionTab(): void
|
||||||
|
{
|
||||||
|
$this->stubRender(['administrator']);
|
||||||
|
|
||||||
|
$html = $this->renderTab([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('<tr class="no-items"><td class="colspanchange" colspan="5">', $html);
|
||||||
|
self::assertStringContainsString('Noch keine Verbindung.', $html);
|
||||||
|
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=verbindung"', $html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,28 @@ final class ConnectionStatusTest extends TestCase
|
|||||||
$byLabel = array_column($checks, null, 'label');
|
$byLabel = array_column($checks, null, 'label');
|
||||||
self::assertFalse($byLabel['OAuth-Server']['ok']);
|
self::assertFalse($byLabel['OAuth-Server']['ok']);
|
||||||
self::assertFalse($byLabel['HTTPS']['ok']);
|
self::assertFalse($byLabel['HTTPS']['ok']);
|
||||||
self::assertNull($byLabel['Discovery (/.well-known/)']['ok']);
|
self::assertNull($byLabel['OAuth-Discovery']['ok']);
|
||||||
self::assertNull($byLabel['Endpunkt ohne Token']['ok']);
|
self::assertNull($byLabel['MCP-Endpunkt']['ok']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDiscoveryDetailIsGermanInsteadOfTheLibrarysEnglishLabel(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_transient')->alias(static fn (string $key) => $key === 'lit_mcp_endpoint_check'
|
||||||
|
? false
|
||||||
|
: ['label' => 'MCP OAuth discovery documents', 'status' => 'good']);
|
||||||
|
Functions\when('home_url')->justReturn('https://kunde.test');
|
||||||
|
|
||||||
|
$good = array_column((new ConnectionStatus(static fn (): bool => true))->checks(false), null, 'label');
|
||||||
|
self::assertTrue($good['OAuth-Discovery']['ok']);
|
||||||
|
self::assertSame('Discovery-Dokumente erreichbar', $good['OAuth-Discovery']['message']);
|
||||||
|
|
||||||
|
Functions\when('get_transient')->alias(static fn (string $key) => $key === 'lit_mcp_endpoint_check'
|
||||||
|
? false
|
||||||
|
: ['label' => 'MCP OAuth discovery documents', 'status' => 'critical']);
|
||||||
|
$bad = array_column((new ConnectionStatus(static fn (): bool => true))->checks(false), null, 'label');
|
||||||
|
self::assertFalse($bad['OAuth-Discovery']['ok']);
|
||||||
|
self::assertStringContainsString('nicht erreichbar', $bad['OAuth-Discovery']['message']);
|
||||||
|
self::assertStringNotContainsString('documents', $bad['OAuth-Discovery']['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPermalinksPrettyReflectsThePermalinkStructureOption(): void
|
public function testPermalinksPrettyReflectsThePermalinkStructureOption(): void
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Admin\ConnectionStatus;
|
||||||
|
use LIT\MCP\Admin\ConnectionTab;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
final class ConnectionTabTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @param list<array{label: string, ok: ?bool, message: string}> $checks */
|
||||||
|
private function render(array $checks): string
|
||||||
|
{
|
||||||
|
Functions\stubEscapeFunctions();
|
||||||
|
Functions\stubTranslationFunctions();
|
||||||
|
Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server');
|
||||||
|
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||||
|
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
|
||||||
|
$status = Mockery::mock(ConnectionStatus::class);
|
||||||
|
$status->allows('checks')->with(true)->andReturn($checks);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new ConnectionTab($status))->render();
|
||||||
|
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExplainsTheConnectionAsAnOrderedSequenceLinkingToTheAccessTab(): void
|
||||||
|
{
|
||||||
|
$html = $this->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('So verbindest du Claude', $html);
|
||||||
|
self::assertSame(3, substr_count($html, '<li>'));
|
||||||
|
self::assertStringContainsString('<ol', $html);
|
||||||
|
self::assertStringContainsString('„Add custom connector“', $html);
|
||||||
|
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=zugriff"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServerUrlIsReadonlyWithACopyButton(): void
|
||||||
|
{
|
||||||
|
$html = $this->render([]);
|
||||||
|
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'#<input[^>]+id="lit-mcp-url"[^>]+readonly[^>]+value="https://kunde.de/wp-json/mcp/mcp-oauth-server"#',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('<label for="lit-mcp-url"', $html);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'<button type="button" class="button lit-mcp-copy" data-clipboard-target="#lit-mcp-url">URL kopieren</button>',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('<span class="success hidden" aria-hidden="true">Kopiert!</span>', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusIsASiteHealthStyleListWithIconsPerState(): void
|
||||||
|
{
|
||||||
|
$html = $this->render([
|
||||||
|
['label' => 'HTTPS', 'ok' => true, 'message' => 'aktiv'],
|
||||||
|
['label' => 'Discovery', 'ok' => null, 'message' => 'noch nicht geprüft'],
|
||||||
|
['label' => 'OAuth <b>', 'ok' => false, 'message' => 'deaktiviert'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('<table', $html);
|
||||||
|
self::assertStringContainsString('<ul class="lit-mcp-status">', $html);
|
||||||
|
self::assertMatchesRegularExpression('#dashicons-yes-alt[^<]*"[^>]*></span>.*?HTTPS#s', $html);
|
||||||
|
self::assertMatchesRegularExpression('#dashicons-warning[^<]*"[^>]*></span>.*?Discovery#s', $html);
|
||||||
|
self::assertMatchesRegularExpression('#dashicons-dismiss[^<]*"[^>]*></span>.*?OAuth <b>#s', $html);
|
||||||
|
self::assertStringContainsString('<span class="screen-reader-text">In Ordnung:</span>', $html);
|
||||||
|
self::assertStringContainsString('<span class="screen-reader-text">Problem:</span>', $html);
|
||||||
|
self::assertStringContainsString('href="https://kunde.de/wp-admin/site-health.php"', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Admin\ConnectionUsage;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_REST_Request;
|
||||||
|
|
||||||
|
final class ConnectionUsageTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<array{int, string}> */
|
||||||
|
private array $recorded = [];
|
||||||
|
|
||||||
|
private function usage(?array $claims): ConnectionUsage
|
||||||
|
{
|
||||||
|
return new ConnectionUsage(
|
||||||
|
static fn (string $token): ?array => $token === 'valid-token' ? $claims : null,
|
||||||
|
function (int $userId, string $uuid): void {
|
||||||
|
$this->recorded[] = [$userId, $uuid];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $route = '/mcp/mcp-oauth-server', string $auth = 'Bearer valid-token'): WP_REST_Request
|
||||||
|
{
|
||||||
|
return new WP_REST_Request($route, ['authorization' => $auth]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegistersAfterTheRouteCallback(): void
|
||||||
|
{
|
||||||
|
$usage = $this->usage([]);
|
||||||
|
Functions\expect('add_filter')->once()->with('rest_request_after_callbacks', [$usage, 'record'], 10, 3);
|
||||||
|
|
||||||
|
$usage->register();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecordsUsageOfTheTokensApplicationPasswordForTheAuthenticatedUser(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_current_user_id')->justReturn(7);
|
||||||
|
$response = ['jsonrpc' => '2.0'];
|
||||||
|
|
||||||
|
$result = $this->usage(['sub' => 7, 'app_pass_id' => 'uuid-1'])->record($response, [], $this->request());
|
||||||
|
|
||||||
|
self::assertSame($response, $result);
|
||||||
|
self::assertSame([[7, 'uuid-1']], $this->recorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresOtherRoutesFailedRequestsAndForeignOrInvalidTokens(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_current_user_id')->justReturn(7);
|
||||||
|
$claims = ['sub' => 7, 'app_pass_id' => 'uuid-1'];
|
||||||
|
|
||||||
|
$this->usage($claims)->record([], [], $this->request('/wp/v2/posts'));
|
||||||
|
$this->usage($claims)->record(new WP_Error('unauthorized', 'x'), [], $this->request());
|
||||||
|
$this->usage($claims)->record([], [], $this->request('/mcp/mcp-oauth-server', 'Basic abc'));
|
||||||
|
$this->usage($claims)->record([], [], $this->request('/mcp/mcp-oauth-server', 'Bearer forged'));
|
||||||
|
$this->usage(['sub' => 8, 'app_pass_id' => 'uuid-1'])->record([], [], $this->request());
|
||||||
|
$this->usage(['sub' => 7])->record([], [], $this->request());
|
||||||
|
|
||||||
|
self::assertSame([], $this->recorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNeverBreaksTheResponseWhenDecodingOrRecordingFails(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_current_user_id')->justReturn(7);
|
||||||
|
$usage = new ConnectionUsage(
|
||||||
|
static fn (): ?array => throw new \RuntimeException('bad secret'),
|
||||||
|
static function (): void {
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['ok'], $usage->record(['ok'], [], $this->request()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,24 +24,35 @@ final class LimitsTabTest extends TestCase
|
|||||||
(new LimitsTab())->registerSettings();
|
(new LimitsTab())->registerSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRendersThreeNumberFieldsWithCurrentValues(): void
|
public function testRendersVisibleNumberFieldsWithShortLabelsUnitsAndDescriptions(): void
|
||||||
{
|
{
|
||||||
Functions\stubEscapeFunctions();
|
Functions\stubEscapeFunctions();
|
||||||
Functions\stubTranslationFunctions();
|
Functions\stubTranslationFunctions();
|
||||||
Functions\when('settings_fields')->justReturn(null);
|
Functions\when('settings_fields')->justReturn(null);
|
||||||
Functions\when('submit_button')->justReturn(null);
|
Functions\when('submit_button')->justReturn(null);
|
||||||
Functions\when('get_option')->justReturn(['upload_mb' => 8]);
|
Functions\when('get_option')->justReturn(['upload_mb' => 8, 'snapshot_count' => 42]);
|
||||||
Functions\when('wp_max_upload_size')->justReturn(64 * 1024 * 1024);
|
Functions\when('wp_max_upload_size')->justReturn(64 * 1024 * 1024);
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
(new LimitsTab())->render();
|
(new LimitsTab())->render();
|
||||||
$html = (string) ob_get_clean();
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
self::assertSame(3, substr_count($html, 'type="number"'));
|
self::assertSame(2, substr_count($html, 'type="number"'));
|
||||||
|
self::assertStringContainsString('<label for="lit-mcp-upload_mb">Maximale Dateigröße</label>', $html);
|
||||||
|
self::assertStringContainsString('<label for="lit-mcp-log_retention_days">Protokoll aufbewahren</label>', $html);
|
||||||
|
self::assertStringContainsString('class="small-text"', $html);
|
||||||
|
self::assertStringContainsString('<span class="lit-mcp-unit">MB</span>', $html);
|
||||||
|
self::assertStringContainsString('<span class="lit-mcp-unit">Tage</span>', $html);
|
||||||
|
self::assertStringContainsString('aria-describedby="lit-mcp-upload_mb-description"', $html);
|
||||||
|
self::assertStringContainsString('<p class="description" id="lit-mcp-upload_mb-description">Höchstens 32 MB; für Base64-Übertragungen höchstens 10 MB.</p>', $html);
|
||||||
|
// The snapshot setting is hidden until its feature exists, but its saved value survives a save.
|
||||||
|
self::assertStringNotContainsString('Designsystem', $html);
|
||||||
|
self::assertStringNotContainsString('Etappe', $html);
|
||||||
|
self::assertStringContainsString('<input type="hidden" name="lit_mcp_limits[snapshot_count]" value="42">', $html);
|
||||||
self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html);
|
self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html);
|
||||||
self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html);
|
self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html);
|
||||||
self::assertStringContainsString('min="1" max="32"', $html);
|
self::assertStringContainsString('min="1" max="32"', $html);
|
||||||
self::assertStringContainsString('höchstens 32', $html);
|
self::assertStringContainsString('Höchstens 32', $html);
|
||||||
self::assertStringContainsString('10 MB', $html);
|
self::assertStringContainsString('10 MB', $html);
|
||||||
self::assertStringContainsString('min="1" max="3650"', $html);
|
self::assertStringContainsString('min="1" max="3650"', $html);
|
||||||
}
|
}
|
||||||
@@ -59,7 +70,7 @@ final class LimitsTabTest extends TestCase
|
|||||||
(new LimitsTab())->render();
|
(new LimitsTab())->render();
|
||||||
$html = (string) ob_get_clean();
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
self::assertStringContainsString('class="description"', $html);
|
self::assertStringContainsString('notice-warning inline', $html);
|
||||||
self::assertMatchesRegularExpression('/20 MB.*2 MB/s', $html);
|
self::assertMatchesRegularExpression('/20 MB.*2 MB/s', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +87,6 @@ final class LimitsTabTest extends TestCase
|
|||||||
(new LimitsTab())->render();
|
(new LimitsTab())->render();
|
||||||
$html = (string) ob_get_clean();
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
self::assertStringNotContainsString('class="description"', $html);
|
self::assertStringNotContainsString('notice-warning', $html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,10 +191,72 @@ final class LogListTableTest extends TestCase
|
|||||||
$table->single_row(['result' => 'ok']);
|
$table->single_row(['result' => 'ok']);
|
||||||
$ok = (string) ob_get_clean();
|
$ok = (string) ob_get_clean();
|
||||||
|
|
||||||
self::assertStringContainsString('<tr style=', $partial);
|
self::assertStringContainsString('<tr class="lit-mcp-partial">', $partial);
|
||||||
self::assertSame('<tr>', substr($ok, 0, 4));
|
self::assertSame('<tr>', substr($ok, 0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTimeColumnShowsSiteTimeInWordPressFormatWithUtcAsTitle(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->alias(static fn (string $key) => $key === 'date_format' ? 'd.m.Y' : 'H:i');
|
||||||
|
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
|
||||||
|
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||||
|
|
||||||
|
self::assertSame('Zeit', $table->get_columns()['created_at']);
|
||||||
|
$html = $this->column($table, ['created_at' => '2026-09-26 10:39:13'], 'created_at');
|
||||||
|
|
||||||
|
self::assertStringContainsString('d.m.Y H:i@' . gmmktime(10, 39, 13, 9, 26, 2026), $html);
|
||||||
|
self::assertStringContainsString('title="UTC: 2026-09-26 10:39:13"', $html);
|
||||||
|
self::assertSame('–', $this->column($table, ['created_at' => 'kaputt'], 'created_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider results */
|
||||||
|
public function testResultColumnShowsIconAndWord(string $result, string $icon, string $word): void
|
||||||
|
{
|
||||||
|
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||||
|
|
||||||
|
$html = $this->column($table, ['result' => $result], 'result');
|
||||||
|
|
||||||
|
self::assertStringContainsString('dashicons-' . $icon, $html);
|
||||||
|
self::assertStringContainsString($word, $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<list<string>> */
|
||||||
|
public static function results(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['ok', 'yes-alt', 'erfolgreich'],
|
||||||
|
['partial', 'warning', 'teilweise'],
|
||||||
|
['error<b>', 'dismiss', 'error<b>'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToolColumnIsMutedTextAndTopNavRendersTheGivenFilters(): void
|
||||||
|
{
|
||||||
|
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0], static function (): void {
|
||||||
|
echo '<select name="ability"></select>';
|
||||||
|
});
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
'<span class="lit-mcp-tool-name">lit/x<</span>',
|
||||||
|
$this->column($table, ['ability' => 'lit/x<'], 'ability')
|
||||||
|
);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$table->display();
|
||||||
|
self::assertStringContainsString('<div class="alignleft actions"><select name="ability"></select></div>', (string) ob_get_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $item */
|
||||||
|
private function column(LogListTable $table, array $item, string $column): string
|
||||||
|
{
|
||||||
|
$method = 'column_' . $column;
|
||||||
|
if (method_exists($table, $method)) {
|
||||||
|
return (string) (new \ReflectionMethod($table, $method))->invoke($table, $item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->columnDefault($table, $item, $column);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $item */
|
/** @param array<string, mixed> $item */
|
||||||
private function columnDefault(LogListTable $table, array $item, string $column): string
|
private function columnDefault(LogListTable $table, array $item, string $column): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -130,10 +130,11 @@ final class LogTabTest extends TestCase
|
|||||||
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
||||||
});
|
});
|
||||||
Functions\when('wp_dropdown_users')->alias(static function (array $args): void {
|
Functions\when('wp_dropdown_users')->alias(static function (array $args): void {
|
||||||
echo '<select name="' . $args['name'] . '"><option>Alle</option></select>';
|
echo '<select name="' . $args['name'] . '" id="' . $args['id'] . '"><option>Alle</option></select>';
|
||||||
});
|
});
|
||||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||||
Functions\when('get_option')->justReturn([]);
|
Functions\when('get_option')->justReturn([]);
|
||||||
|
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
|
||||||
|
|
||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
|
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
|
||||||
@@ -151,5 +152,18 @@ final class LogTabTest extends TestCase
|
|||||||
self::assertStringContainsString('lit_mcp_clear_log', $html);
|
self::assertStringContainsString('lit_mcp_clear_log', $html);
|
||||||
self::assertStringContainsString('confirm(', $html);
|
self::assertStringContainsString('confirm(', $html);
|
||||||
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
|
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
|
||||||
|
|
||||||
|
// One intro line; the retention days link to the Limits tab.
|
||||||
|
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
|
||||||
|
// Every filter control has a (screen-reader) label.
|
||||||
|
foreach (['ability', 'user_id', 'result', 'date_from', 'date_to'] as $name) {
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'#<label class="screen-reader-text" for="lit-mcp-filter-' . str_replace('_', '-', $name) . '">#',
|
||||||
|
$html,
|
||||||
|
'label for ' . $name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
|
||||||
|
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ final class SettingsPageTest extends TestCase
|
|||||||
|
|
||||||
self::assertStringContainsString('Inhalt zugriff', $html);
|
self::assertStringContainsString('Inhalt zugriff', $html);
|
||||||
self::assertStringNotContainsString('Inhalt verbindung', $html);
|
self::assertStringNotContainsString('Inhalt verbindung', $html);
|
||||||
self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active"/', $html);
|
self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active" aria-current="page"/', $html);
|
||||||
|
self::assertMatchesRegularExpression('/tab=verbindung" class="nav-tab">/', $html);
|
||||||
self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
|
self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +71,53 @@ final class SettingsPageTest extends TestCase
|
|||||||
$withoutLogo = (string) ob_get_clean();
|
$withoutLogo = (string) ob_get_clean();
|
||||||
|
|
||||||
self::assertStringContainsString('<img src="https://example.test/logo.svg" alt="Loosen-IT Logo"', $withLogo);
|
self::assertStringContainsString('<img src="https://example.test/logo.svg" alt="Loosen-IT Logo"', $withLogo);
|
||||||
self::assertStringContainsString('right:0', $withLogo);
|
|
||||||
self::assertStringNotContainsString('lit-mcp-logo', $withoutLogo);
|
self::assertStringNotContainsString('lit-mcp-logo', $withoutLogo);
|
||||||
|
// Logo sits in the header next to the h1 (flex, no absolute positioning over the title).
|
||||||
|
self::assertLessThan(strpos($withLogo, 'lit-mcp-logo'), strpos($withLogo, '<h1'));
|
||||||
|
self::assertStringNotContainsString('position:absolute', $withLogo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeaderEndMarkerSitsBetweenTitleAndTabsWithNoticesAfterIt(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$_GET['lit_mcp_notice'] = 'revoked';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();
|
||||||
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
|
$h1 = (int) strpos($html, '<h1');
|
||||||
|
$end = strpos($html, '<hr class="wp-header-end">');
|
||||||
|
self::assertNotFalse($end);
|
||||||
|
self::assertLessThan($end, $h1);
|
||||||
|
self::assertLessThan(strpos($html, 'notice-success'), $end);
|
||||||
|
self::assertLessThan(strpos($html, 'nav-tab-wrapper'), (int) strpos($html, 'notice-success'));
|
||||||
|
self::assertStringContainsString('<nav class="nav-tab-wrapper wp-clearfix" aria-label="', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnqueuesStylesAndCopyScriptOnlyOnItsOwnScreen(): void
|
||||||
|
{
|
||||||
|
$page = new SettingsPage([$this->tab('verbindung', 'Verbindung')], '', 'https://example.test/assets', '1.2.3');
|
||||||
|
Functions\expect('wp_enqueue_style')->once()
|
||||||
|
->with('lit-mcp-admin', 'https://example.test/assets/css/admin.css', [], '1.2.3');
|
||||||
|
Functions\expect('wp_enqueue_script')->once()
|
||||||
|
->with('lit-mcp-admin', 'https://example.test/assets/js/admin.js', ['clipboard', 'wp-a11y'], '1.2.3', true);
|
||||||
|
Functions\when('wp_localize_script')->justReturn(true);
|
||||||
|
|
||||||
|
$page->enqueueAssets('settings_page_lit-mcp');
|
||||||
|
$page->enqueueAssets('options-general.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToolsTabAlsoLoadsWordPressPostboxScriptForCollapsibleGroups(): void
|
||||||
|
{
|
||||||
|
$_GET['tab'] = 'tools';
|
||||||
|
$page = new SettingsPage([$this->tab('verbindung', 'V'), $this->tab('tools', 'T')], '', 'https://example.test/assets/', '1');
|
||||||
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
|
Functions\expect('wp_enqueue_script')->once()
|
||||||
|
->with('lit-mcp-admin', 'https://example.test/assets/js/admin.js', ['clipboard', 'wp-a11y', 'postbox'], '1', true);
|
||||||
|
Functions\when('wp_localize_script')->justReturn(true);
|
||||||
|
|
||||||
|
$page->enqueueAssets('settings_page_lit-mcp');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnknownTabFallsBackToTheFirstAndNoticesAreShown(): void
|
public function testUnknownTabFallsBackToTheFirstAndNoticesAreShown(): void
|
||||||
@@ -108,6 +154,7 @@ final class SettingsPageTest extends TestCase
|
|||||||
$page = new SettingsPage([$tab]);
|
$page = new SettingsPage([$tab]);
|
||||||
Functions\expect('add_action')->once()->with('admin_menu', [$page, 'addMenu']);
|
Functions\expect('add_action')->once()->with('admin_menu', [$page, 'addMenu']);
|
||||||
Functions\expect('add_action')->once()->with('admin_init', [$page, 'registerSettings']);
|
Functions\expect('add_action')->once()->with('admin_init', [$page, 'registerSettings']);
|
||||||
|
Functions\expect('add_action')->once()->with('admin_enqueue_scripts', [$page, 'enqueueAssets']);
|
||||||
|
|
||||||
$page->register();
|
$page->register();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ use Mockery;
|
|||||||
|
|
||||||
final class ToolsTabTest extends TestCase
|
final class ToolsTabTest extends TestCase
|
||||||
{
|
{
|
||||||
/** @param list<array{string, bool, bool}> $abilities name, isDirect, readonly */
|
/** @param list<array{0: string, 1: bool, 2: bool, 3?: bool}> $abilities name, isDirect, readonly, destructive */
|
||||||
private function integration(string $id, array $abilities, bool $available = true): Integration
|
private function integration(string $id, array $abilities, bool $available = true): Integration
|
||||||
{
|
{
|
||||||
$mocks = [];
|
$mocks = [];
|
||||||
foreach ($abilities as [$name, $direct, $readonly]) {
|
foreach ($abilities as $spec) {
|
||||||
|
[$name, $direct, $readonly] = $spec;
|
||||||
$ability = Mockery::mock(Ability::class);
|
$ability = Mockery::mock(Ability::class);
|
||||||
$ability->allows('name')->andReturn($name);
|
$ability->allows('name')->andReturn($name);
|
||||||
$ability->allows('label')->andReturn('Label ' . $name);
|
$ability->allows('label')->andReturn('Label ' . $name);
|
||||||
$ability->allows('isDirect')->andReturn($direct);
|
$ability->allows('isDirect')->andReturn($direct);
|
||||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
|
$ability->allows('annotations')->andReturn(['readonly' => $readonly, 'destructive' => $spec[3] ?? false]);
|
||||||
$mocks[] = $ability;
|
$mocks[] = $ability;
|
||||||
}
|
}
|
||||||
$integration = Mockery::mock(Integration::class);
|
$integration = Mockery::mock(Integration::class);
|
||||||
@@ -45,13 +46,24 @@ final class ToolsTabTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, string|bool> $option */
|
/**
|
||||||
private function render(array $option, ?IntegrationRegistry $registry = null): string
|
* @param array<string, string|bool> $option
|
||||||
|
* @param mixed $closedBoxes the user's saved closed postboxes (false = never saved)
|
||||||
|
*/
|
||||||
|
private function render(array $option, ?IntegrationRegistry $registry = null, mixed $closedBoxes = false): string
|
||||||
{
|
{
|
||||||
Functions\stubEscapeFunctions();
|
Functions\stubEscapeFunctions();
|
||||||
Functions\stubTranslationFunctions();
|
Functions\stubTranslationFunctions();
|
||||||
Functions\when('settings_fields')->justReturn(null);
|
Functions\when('settings_fields')->justReturn(null);
|
||||||
Functions\when('submit_button')->justReturn(null);
|
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit'): void {
|
||||||
|
echo '<button name="' . $name . '">Speichern</button>';
|
||||||
|
});
|
||||||
|
Functions\when('wp_nonce_field')->alias(static function (string $action, string $name): string {
|
||||||
|
return '<input type="hidden" id="' . $name . '" name="' . $name . '" value="nonce-' . $action . '">';
|
||||||
|
});
|
||||||
|
Functions\when('get_user_option')->alias(
|
||||||
|
static fn (string $key) => $key === 'closedpostboxes_settings_page_lit-mcp' ? $closedBoxes : false
|
||||||
|
);
|
||||||
Functions\when('selected')->alias(
|
Functions\when('selected')->alias(
|
||||||
static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''
|
static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''
|
||||||
);
|
);
|
||||||
@@ -76,21 +88,22 @@ final class ToolsTabTest extends TestCase
|
|||||||
/** The table row of one tool (from its <tr> to </tr>). */
|
/** The table row of one tool (from its <tr> to </tr>). */
|
||||||
private function row(string $html, string $name): string
|
private function row(string $html, string $name): string
|
||||||
{
|
{
|
||||||
$at = strpos($html, '<code>' . $name . '</code>');
|
$at = strpos($html, '<span class="lit-mcp-tool-name">' . $name . '</span>');
|
||||||
self::assertNotFalse($at, 'No row for ' . $name);
|
self::assertNotFalse($at, 'No row for ' . $name);
|
||||||
$start = (int) strrpos(substr($html, 0, $at), '<tr');
|
$start = (int) strrpos(substr($html, 0, $at), '<tr');
|
||||||
|
|
||||||
return substr($html, $start, (int) strpos($html, '</tr>', $at) - $start);
|
return substr($html, $start, (int) strpos($html, '</tr>', $at) - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The <details> block of one group. */
|
/** The postbox of one group (from its opening div to the next group or the end). */
|
||||||
private function group(string $html, string $label): string
|
private function group(string $html, string $label): string
|
||||||
{
|
{
|
||||||
$at = strpos($html, '<summary>' . $label);
|
$at = strpos($html, '<h2 class="hndle">' . $label . ' ');
|
||||||
self::assertNotFalse($at, 'No group ' . $label);
|
self::assertNotFalse($at, 'No group ' . $label);
|
||||||
$start = (int) strrpos(substr($html, 0, $at), '<details');
|
$start = (int) strrpos(substr($html, 0, $at), '<div id="lit-mcp-group-');
|
||||||
|
$end = strpos($html, '<div id="lit-mcp-group-', $at);
|
||||||
|
|
||||||
return substr($html, $start, (int) strpos($html, '</details>', $at) - $start);
|
return substr($html, $start, ($end === false ? strlen($html) : $end) - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void
|
public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void
|
||||||
@@ -125,10 +138,10 @@ final class ToolsTabTest extends TestCase
|
|||||||
{
|
{
|
||||||
$html = $this->render(['lit/divi-update' => 'disabled']);
|
$html = $this->render(['lit/divi-update' => 'disabled']);
|
||||||
|
|
||||||
self::assertSame(3, substr_count($html, '<details open'));
|
self::assertSame(3, substr_count($html, 'class="postbox lit-mcp-group'));
|
||||||
self::assertStringContainsString('<summary>Grundlagen (2 Tools)', $html);
|
self::assertStringContainsString('<h2 class="hndle">Grundlagen <span class="count">(2 Tools)</span>', $html);
|
||||||
self::assertStringContainsString('<summary>Medien (2 Tools)', $html);
|
self::assertStringContainsString('<h2 class="hndle">Medien <span class="count">(2 Tools)</span>', $html);
|
||||||
self::assertStringContainsString('<summary>Divi (2 Tools)', $html);
|
self::assertStringContainsString('<h2 class="hndle">Divi <span class="count">(2 Tools)</span>', $html);
|
||||||
self::assertStringNotContainsString('pods', $html);
|
self::assertStringNotContainsString('pods', $html);
|
||||||
self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi'));
|
self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi'));
|
||||||
self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi'));
|
self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi'));
|
||||||
@@ -139,7 +152,10 @@ final class ToolsTabTest extends TestCase
|
|||||||
self::assertStringContainsString('value="' . $mode . '"', $groupSelect);
|
self::assertStringContainsString('value="' . $mode . '"', $groupSelect);
|
||||||
}
|
}
|
||||||
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html);
|
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html);
|
||||||
self::assertStringContainsString('nur lesen (schreibende Tools dieser Gruppe aus)', $html);
|
self::assertStringContainsString('<label for="lit-mcp-group-mode-divi">Ganze Gruppe</label>', $html);
|
||||||
|
self::assertStringContainsString('id="lit-mcp-group-mode-divi"', $groupSelect);
|
||||||
|
self::assertStringContainsString('Nur lesen', $this->group($html, 'Divi'));
|
||||||
|
self::assertStringContainsString('<option value="disabled">aus</option>', $groupSelect);
|
||||||
|
|
||||||
$guide = $this->select($html, 'lit/guide');
|
$guide = $this->select($html, 'lit/guide');
|
||||||
self::assertStringNotContainsString('value="disabled"', $guide);
|
self::assertStringNotContainsString('value="disabled"', $guide);
|
||||||
@@ -182,14 +198,14 @@ final class ToolsTabTest extends TestCase
|
|||||||
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
|
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
|
||||||
$update = $this->row($html, 'lit/divi-update');
|
$update = $this->row($html, 'lit/divi-update');
|
||||||
self::assertStringContainsString('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
|
self::assertStringContainsString('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
|
||||||
self::assertStringContainsString('durch ‚nur lesen‘ aus', $update);
|
self::assertStringContainsString('Durch „Nur lesen“ aus', $update);
|
||||||
self::assertStringContainsString(
|
self::assertStringContainsString(
|
||||||
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
|
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
|
||||||
$update
|
$update
|
||||||
);
|
);
|
||||||
$outline = $this->row($html, 'lit/divi-outline');
|
$outline = $this->row($html, 'lit/divi-outline');
|
||||||
self::assertStringNotContainsString('disabled>', $outline);
|
self::assertStringNotContainsString('disabled>', $outline);
|
||||||
self::assertStringNotContainsString('nur lesen', $outline);
|
self::assertStringNotContainsString('Nur lesen', $outline);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCountsDirectToolsUnderTheSavedSettings(): void
|
public function testCountsDirectToolsUnderTheSavedSettings(): void
|
||||||
@@ -199,7 +215,8 @@ final class ToolsTabTest extends TestCase
|
|||||||
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
|
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
|
||||||
self::assertStringContainsString('Direkt sichtbar: 3 Tools', $html);
|
self::assertStringContainsString('Direkt sichtbar: 3 Tools', $html);
|
||||||
self::assertStringNotContainsString('Kontext', $html);
|
self::assertStringNotContainsString('Kontext', $html);
|
||||||
self::assertLessThan(strpos($html, '<details'), strpos($html, 'Direkt sichtbar'));
|
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'Direkt sichtbar'));
|
||||||
|
self::assertStringContainsString('<div class="notice notice-info inline"><p><strong>Direkt sichtbar: 3 Tools', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
|
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
|
||||||
@@ -215,7 +232,9 @@ final class ToolsTabTest extends TestCase
|
|||||||
|
|
||||||
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
|
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
|
||||||
self::assertStringContainsString('Kontext', $warned);
|
self::assertStringContainsString('Kontext', $warned);
|
||||||
self::assertStringContainsString('<summary>WordPress (26 Tools)', $warned);
|
self::assertStringContainsString('<div class="notice notice-warning inline">', $warned);
|
||||||
|
self::assertStringNotContainsString('notice-warning', $notWarned);
|
||||||
|
self::assertStringContainsString('<h2 class="hndle">WordPress <span class="count">(26 Tools)</span>', $warned);
|
||||||
self::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
|
self::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
|
||||||
self::assertStringNotContainsString('Kontext', $notWarned);
|
self::assertStringNotContainsString('Kontext', $notWarned);
|
||||||
}
|
}
|
||||||
@@ -226,8 +245,101 @@ final class ToolsTabTest extends TestCase
|
|||||||
|
|
||||||
$html = $this->render([], $registry);
|
$html = $this->render([], $registry);
|
||||||
|
|
||||||
self::assertStringContainsString('<summary>shop (1 Tool)', $html);
|
self::assertStringContainsString('<h2 class="hndle">shop <span class="count">(1 Tool)</span>', $html);
|
||||||
self::assertStringNotContainsString('<b>', $html);
|
self::assertStringNotContainsString('<b>', $html);
|
||||||
self::assertStringContainsString('lit/shop-<b>', $html);
|
self::assertStringContainsString('lit/shop-<b>', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testArtColumnSaysWhetherAToolReadsWritesOrDeletes(): void
|
||||||
|
{
|
||||||
|
$registry = new IntegrationRegistry($this->integration('media', [
|
||||||
|
['lit/media-preview', true, true],
|
||||||
|
['lit/media-upload', false, false],
|
||||||
|
['lit/media-delete', false, false, true],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$html = $this->render([], $registry);
|
||||||
|
|
||||||
|
self::assertStringContainsString('<th scope="col" class="column-kind">Art</th>', $html);
|
||||||
|
self::assertStringContainsString('liest', $this->row($html, 'lit/media-preview'));
|
||||||
|
self::assertStringContainsString('schreibt', $this->row($html, 'lit/media-upload'));
|
||||||
|
self::assertStringContainsString('löscht', $this->row($html, 'lit/media-delete'));
|
||||||
|
self::assertStringNotContainsString('schreibt', $this->row($html, 'lit/media-delete'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToolCellShowsLabelAndTheNameAsMutedTextWithALabelledSelect(): void
|
||||||
|
{
|
||||||
|
$html = $this->render([]);
|
||||||
|
$row = $this->row($html, 'lit/guide');
|
||||||
|
|
||||||
|
self::assertStringContainsString('<strong>Label lit/guide</strong>', $row);
|
||||||
|
self::assertStringNotContainsString('<code>', $row);
|
||||||
|
self::assertMatchesRegularExpression('#<label class="screen-reader-text" for="([a-z0-9-]+)">[^<]*Label lit/guide</label>#', $row);
|
||||||
|
preg_match('#for="([a-z0-9-]+)"#', $row, $m);
|
||||||
|
self::assertStringContainsString('id="' . $m[1] . '"', $this->select($html, 'lit/guide'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIntroUsesPlainLanguageAndMentionsDiviOnlyWhenDiviIsAvailable(): void
|
||||||
|
{
|
||||||
|
$withDivi = $this->render([]);
|
||||||
|
$withoutDivi = $this->render([], new IntegrationRegistry($this->integration('core', [['lit/guide', true, true]])));
|
||||||
|
|
||||||
|
self::assertStringContainsString('Claude sieht das Tool sofort.', $withDivi);
|
||||||
|
self::assertStringContainsString('Claude findet es bei Bedarf über die Tool-Suche.', $withDivi);
|
||||||
|
self::assertStringNotContainsString('Meta-Tools', $withDivi);
|
||||||
|
self::assertStringNotContainsString('discover', $withDivi);
|
||||||
|
self::assertStringContainsString('Divi-Code-Modul', $withDivi);
|
||||||
|
self::assertStringNotContainsString('Divi-Code-Modul', $withoutDivi);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasASubmitButtonAboveAndBelowTheGroups(): void
|
||||||
|
{
|
||||||
|
$html = $this->render([]);
|
||||||
|
|
||||||
|
self::assertSame(2, substr_count($html, 'Speichern</button>'));
|
||||||
|
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'name="submit-top"'));
|
||||||
|
self::assertGreaterThan(strrpos($html, 'class="postbox'), strrpos($html, 'name="submit"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGroupsAreWordPressPostboxesWithAnAccessibleToggle(): void
|
||||||
|
{
|
||||||
|
$html = $this->render([]);
|
||||||
|
$divi = $this->group($html, 'Divi');
|
||||||
|
|
||||||
|
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $divi);
|
||||||
|
self::assertStringContainsString('<div class="postbox-header">', $divi);
|
||||||
|
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="true">', $divi);
|
||||||
|
self::assertStringContainsString('<span class="screen-reader-text">Gruppe ein-/ausblenden: Divi</span>', $divi);
|
||||||
|
self::assertStringContainsString('<span class="toggle-indicator" aria-hidden="true"></span>', $divi);
|
||||||
|
self::assertStringContainsString('<div class="inside">', $divi);
|
||||||
|
// The group controls live in the header but outside the clickable heading.
|
||||||
|
$heading = substr($divi, (int) strpos($divi, '<h2'), (int) strpos($divi, '</h2>') - (int) strpos($divi, '<h2'));
|
||||||
|
self::assertStringNotContainsString('<select', $heading);
|
||||||
|
self::assertStringNotContainsString('<input', $heading);
|
||||||
|
self::assertLessThan(strpos($divi, 'class="inside"'), strpos($divi, 'lit-mcp-group-mode-divi'));
|
||||||
|
// Nonce for WordPress' own "closed-postboxes" state saving.
|
||||||
|
self::assertStringContainsString('id="closedpostboxesnonce" name="closedpostboxesnonce" value="nonce-closedpostboxes"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeaderSummaryCountsEffectiveModes(): void
|
||||||
|
{
|
||||||
|
$html = $this->render(['@readonly:divi' => true]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('<span class="lit-mcp-group-summary">2 direkt · 0 indirekt · 0 aus</span>', $this->group($html, 'Grundlagen'));
|
||||||
|
self::assertStringContainsString('<span class="lit-mcp-group-summary">1 direkt · 1 indirekt · 0 aus</span>', $this->group($html, 'Medien'));
|
||||||
|
self::assertStringContainsString('<span class="lit-mcp-group-summary">nur lesen · 1 direkt · 0 indirekt · 1 aus</span>', $this->group($html, 'Divi'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAGroupWithAllToolsOffStartsClosedUnlessTheUserSavedOtherwise(): void
|
||||||
|
{
|
||||||
|
$default = $this->render(['@group:divi' => 'disabled']);
|
||||||
|
$saved = $this->render(['@group:divi' => 'disabled'], null, ['lit-mcp-group-media']);
|
||||||
|
|
||||||
|
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group closed">', $default);
|
||||||
|
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="false">', $this->group($default, 'Divi'));
|
||||||
|
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group">', $default);
|
||||||
|
|
||||||
|
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $saved);
|
||||||
|
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed">', $saved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user