Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
194 lines
5.9 KiB
PHP
194 lines
5.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\WordPress\Schema;
|
||
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LogicException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Detects the content model of this installation (spec §6.1): post types with a
|
||
* UI, their taxonomies and their fields merged from all field sources.
|
||
* Not final so abilities can be unit-tested with a mock.
|
||
*/
|
||
class ContentSchema
|
||
{
|
||
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
|
||
public const EXCLUDED = [
|
||
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
|
||
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face',
|
||
...DiviModules::INTERNAL_POST_TYPES,
|
||
];
|
||
|
||
private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
|
||
|
||
/** @var callable(string): bool */
|
||
private $diviEnabled;
|
||
|
||
/** @var array<string, array<string, FieldDefinition>> */
|
||
private array $fieldCache = [];
|
||
|
||
/**
|
||
* @param list<FieldSource> $sources Highest priority first.
|
||
*/
|
||
public function __construct(private array $sources, ?callable $diviEnabled = null)
|
||
{
|
||
$this->diviEnabled = $diviEnabled ?? DiviModules::builderEnabledFor(...);
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function postTypeNames(): array
|
||
{
|
||
$excluded = apply_filters('lit_mcp_excluded_post_types', self::EXCLUDED);
|
||
$excluded = is_array($excluded) ? $excluded : self::EXCLUDED;
|
||
$names = array_values(array_diff(
|
||
array_map('strval', array_keys(get_post_types(['show_ui' => true], 'names'))),
|
||
$excluded
|
||
));
|
||
sort($names);
|
||
|
||
return $names;
|
||
}
|
||
|
||
public function isManaged(string $postType): bool
|
||
{
|
||
return in_array($postType, $this->postTypeNames(), true);
|
||
}
|
||
|
||
/** @return list<array<string, mixed>> */
|
||
public function describePostTypes(): array
|
||
{
|
||
$types = [];
|
||
foreach ($this->postTypeNames() as $name) {
|
||
$description = $this->describe($name, false);
|
||
if ($description !== null) {
|
||
$types[] = $description;
|
||
}
|
||
}
|
||
|
||
return $types;
|
||
}
|
||
|
||
/** @return array<string, mixed>|null Null when the post type is not managed. */
|
||
public function describe(string $postType, bool $withFields): ?array
|
||
{
|
||
$object = $this->isManaged($postType) ? get_post_type_object($postType) : null;
|
||
if ($object === null) {
|
||
return null;
|
||
}
|
||
|
||
$description = [
|
||
'name' => $postType,
|
||
'label' => (string) $object->labels->name,
|
||
'singular_label' => (string) $object->labels->singular_name,
|
||
'hierarchical' => (bool) $object->hierarchical,
|
||
'supports' => array_map('strval', array_keys(get_all_post_type_supports($postType))),
|
||
'divi' => $this->diviEnabled($postType),
|
||
'count' => $this->count($postType),
|
||
'taxonomies' => $this->taxonomies($postType),
|
||
];
|
||
|
||
if ($withFields) {
|
||
$visible = array_filter(
|
||
$this->fields($postType),
|
||
static fn (FieldDefinition $field): bool => $field->status !== FieldDefinition::HIDDEN
|
||
);
|
||
$description['fields'] = array_values(array_map(
|
||
static fn (FieldDefinition $field): array => $field->toArray(),
|
||
$visible
|
||
));
|
||
}
|
||
|
||
return $description;
|
||
}
|
||
|
||
/** @return array<string, FieldDefinition> */
|
||
public function fields(string $postType): array
|
||
{
|
||
if (! isset($this->fieldCache[$postType])) {
|
||
$merged = [];
|
||
foreach ($this->sources as $source) {
|
||
foreach ($source->fieldsFor($postType) as $field) {
|
||
$merged[$field->key] ??= $field;
|
||
}
|
||
}
|
||
ksort($merged);
|
||
$this->fieldCache[$postType] = $merged;
|
||
}
|
||
|
||
return $this->fieldCache[$postType];
|
||
}
|
||
|
||
public function sourceOf(FieldDefinition $field): FieldSource
|
||
{
|
||
foreach ($this->sources as $source) {
|
||
if ($source->id() === $field->source) {
|
||
return $source;
|
||
}
|
||
}
|
||
|
||
throw new LogicException(sprintf('No field source "%s" registered.', $field->source));
|
||
}
|
||
|
||
/**
|
||
* Keys of fields that store attachment IDs (file fields, relations to media) over all
|
||
* managed post types, sorted – used by the media usage search.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function mediaFieldKeys(): array
|
||
{
|
||
$keys = [];
|
||
foreach ($this->postTypeNames() as $postType) {
|
||
foreach ($this->fields($postType) as $field) {
|
||
if ($field->type === 'file' || ($field->related['object_type'] ?? null) === 'media') {
|
||
$keys[$field->key] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
$keys = array_map('strval', array_keys($keys));
|
||
sort($keys);
|
||
|
||
return $keys;
|
||
}
|
||
|
||
/** @return list<array{name: string, label: string, hierarchical: bool}> */
|
||
public function taxonomies(string $postType): array
|
||
{
|
||
$result = [];
|
||
foreach (get_object_taxonomies($postType, 'objects') as $taxonomy) {
|
||
if (! $taxonomy->show_ui) {
|
||
continue;
|
||
}
|
||
|
||
$result[] = [
|
||
'name' => (string) $taxonomy->name,
|
||
'label' => (string) $taxonomy->labels->name,
|
||
'hierarchical' => (bool) $taxonomy->hierarchical,
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function diviEnabled(string $postType): bool
|
||
{
|
||
return ($this->diviEnabled)($postType);
|
||
}
|
||
|
||
private function count(string $postType): int
|
||
{
|
||
$counts = (array) wp_count_posts($postType);
|
||
$total = 0;
|
||
foreach (self::COUNTED_STATUSES as $status) {
|
||
$total += (int) ($counts[$status] ?? 0);
|
||
}
|
||
|
||
return $total;
|
||
}
|
||
}
|