Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
218 lines
8.4 KiB
PHP
218 lines
8.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Content;
|
||
|
||
use LIT\MCP\Abilities\JsonMap;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Output format of an entry for the content tools. Not final so abilities can be
|
||
* unit-tested with a mock.
|
||
*
|
||
* Layering exception: diviNote()/diviHint() call DiviModules::isAvailable() (Divi
|
||
* integration) to decide whether the Divi tools exist – the WordPress integration
|
||
* otherwise never depends on Divi. Kept because the alternative (injecting
|
||
* availability through every constructor that reaches PostPresenter) would spread the
|
||
* same one-line check across content-get, content-update and revisions-list for no
|
||
* behavioral gain; $toolsAvailable can still be passed in explicitly for tests.
|
||
*/
|
||
class PostPresenter
|
||
{
|
||
/** How Divi content is read and changed with the Divi tools. */
|
||
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit '
|
||
. 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, '
|
||
. 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.';
|
||
|
||
/** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */
|
||
public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine '
|
||
. 'Divi-Werkzeuge; Divi-Inhalte nur im Divi-Builder bearbeiten.';
|
||
|
||
/** $toolsAvailable defaults to whether the Divi integration is available (Divi 5 loaded). */
|
||
public static function diviNote(?bool $toolsAvailable = null): string
|
||
{
|
||
return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE;
|
||
}
|
||
|
||
public static function diviHint(?bool $toolsAvailable = null): string
|
||
{
|
||
return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. '
|
||
. self::diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update '
|
||
. '(direkt: lit-content-update) änderbar.';
|
||
}
|
||
|
||
public function __construct(private ContentSchema $schema)
|
||
{
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function summary(WP_Post $post): array
|
||
{
|
||
return [
|
||
'id' => (int) $post->ID,
|
||
'type' => (string) $post->post_type,
|
||
'status' => (string) $post->post_status,
|
||
'title' => (string) $post->post_title,
|
||
'slug' => (string) $post->post_name,
|
||
'parent' => (int) $post->post_parent,
|
||
'date' => (string) $post->post_date,
|
||
'modified' => (string) $post->post_modified,
|
||
'url' => (string) get_permalink($post),
|
||
'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
|
||
'divi' => self::usesDivi($post),
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function full(WP_Post $post): array
|
||
{
|
||
$divi = self::usesDivi($post);
|
||
|
||
return array_merge($this->summary($post), [
|
||
'excerpt' => (string) $post->post_excerpt,
|
||
'content' => $divi ? null : (string) $post->post_content,
|
||
'menu_order' => (int) $post->menu_order,
|
||
'author' => [
|
||
'id' => (int) $post->post_author,
|
||
'name' => (string) get_the_author_meta('display_name', (int) $post->post_author),
|
||
],
|
||
'template' => (string) get_page_template_slug($post),
|
||
'preview_url' => (string) get_preview_post_link($post),
|
||
'terms' => JsonMap::of($this->terms($post)),
|
||
'fields' => JsonMap::of($this->fields($post)),
|
||
'featured_image' => $this->featuredImage($post),
|
||
'rev' => Rev::ofPost($post),
|
||
'hints' => $divi ? [self::diviHint()] : [],
|
||
]);
|
||
}
|
||
|
||
public static function usesDivi(WP_Post $post): bool
|
||
{
|
||
return DiviPages::usesBuilder((int) $post->ID)
|
||
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function summarySchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer'],
|
||
'type' => ['type' => 'string'],
|
||
'status' => ['type' => 'string'],
|
||
'title' => ['type' => 'string'],
|
||
'slug' => ['type' => 'string'],
|
||
'parent' => ['type' => 'integer'],
|
||
'date' => ['type' => 'string'],
|
||
'modified' => ['type' => 'string'],
|
||
'url' => ['type' => 'string'],
|
||
'edit_url' => ['type' => 'string'],
|
||
'divi' => ['type' => 'boolean'],
|
||
],
|
||
'required' => ['id', 'type', 'status', 'title', 'url', 'divi'],
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function fullSchema(): array
|
||
{
|
||
$schema = self::summarySchema();
|
||
$schema['properties'] = array_merge($schema['properties'], [
|
||
'excerpt' => ['type' => 'string'],
|
||
'content' => ['type' => ['string', 'null']],
|
||
'menu_order' => ['type' => 'integer'],
|
||
'author' => ['type' => 'object'],
|
||
'template' => ['type' => 'string'],
|
||
'preview_url' => ['type' => 'string'],
|
||
'terms' => ['type' => 'object'],
|
||
'fields' => ['type' => 'object'],
|
||
'featured_image' => ['type' => ['object', 'null']],
|
||
'rev' => ['type' => 'string'],
|
||
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
]);
|
||
$schema['required'] = array_merge($schema['required'], ['content', 'terms', 'fields', 'rev', 'hints']);
|
||
|
||
return $schema;
|
||
}
|
||
|
||
/** @return array<string, list<array{id: int, name: string, slug: string}>> */
|
||
private function terms(WP_Post $post): array
|
||
{
|
||
$result = [];
|
||
foreach ($this->schema->taxonomies((string) $post->post_type) as $taxonomy) {
|
||
$terms = wp_get_post_terms((int) $post->ID, $taxonomy['name']);
|
||
if (! is_array($terms)) {
|
||
continue;
|
||
}
|
||
|
||
$result[$taxonomy['name']] = array_map(
|
||
static fn (object $term): array => [
|
||
'id' => (int) $term->term_id,
|
||
'name' => (string) $term->name,
|
||
'slug' => (string) $term->slug,
|
||
],
|
||
$terms
|
||
);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function fields(WP_Post $post): array
|
||
{
|
||
$result = [];
|
||
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
|
||
if (self::isHiddenField($field)) {
|
||
continue;
|
||
}
|
||
|
||
$result[$key] = $this->schema->sourceOf($field)->read((int) $post->ID, $field);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Fields hidden for the current user (FieldDefinition::HIDDEN, e.g. restricted Pods fields),
|
||
* Pods password fields and readonly protected meta (WordPress' own "_"-prefixed
|
||
* internal keys) are never read out via lit-content-get/-query: neither belongs in
|
||
* front of the model, and both are one plugin registration away from leaking secrets
|
||
* or internal engine state. Public and static so ContentQueryAbility can apply the
|
||
* same rule to its `field` filter – a hidden field must be as unreachable through the
|
||
* query filter as it is through the fields map, or the filter becomes a probe.
|
||
*/
|
||
public static function isHiddenField(FieldDefinition $field): bool
|
||
{
|
||
if ($field->type === 'password' || $field->status === FieldDefinition::HIDDEN) {
|
||
return true;
|
||
}
|
||
|
||
return $field->status !== FieldDefinition::WRITABLE && str_starts_with($field->key, '_');
|
||
}
|
||
|
||
/** @return array{id: int, url: string, alt: string}|null */
|
||
private function featuredImage(WP_Post $post): ?array
|
||
{
|
||
$id = (int) get_post_thumbnail_id($post);
|
||
if ($id === 0) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => $id,
|
||
'url' => (string) wp_get_attachment_url($id),
|
||
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
|
||
];
|
||
}
|
||
}
|