diff --git a/src/Integrations/WordPress/Schema/ContentSchema.php b/src/Integrations/WordPress/Schema/ContentSchema.php new file mode 100644 index 0000000..5741a96 --- /dev/null +++ b/src/Integrations/WordPress/Schema/ContentSchema.php @@ -0,0 +1,167 @@ +> */ + private array $fieldCache = []; + + /** + * @param list $sources Highest priority first. + */ + public function __construct(private array $sources, ?callable $diviEnabled = null) + { + $this->diviEnabled = $diviEnabled ?? static function (string $postType): bool { + return function_exists('et_builder_enabled_for_post_type') + && (bool) et_builder_enabled_for_post_type($postType); + }; + } + + /** @return list */ + 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> */ + public function describePostTypes(): array + { + $types = []; + foreach ($this->postTypeNames() as $name) { + $description = $this->describe($name, false); + if ($description !== null) { + $types[] = $description; + } + } + + return $types; + } + + /** @return array|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) { + $description['fields'] = array_values(array_map( + static fn (FieldDefinition $field): array => $field->toArray(), + $this->fields($postType) + )); + } + + return $description; + } + + /** @return array */ + 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)); + } + + /** @return list */ + 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; + } +} diff --git a/src/Integrations/WordPress/Schema/FieldDefinition.php b/src/Integrations/WordPress/Schema/FieldDefinition.php new file mode 100644 index 0000000..87bbfbb --- /dev/null +++ b/src/Integrations/WordPress/Schema/FieldDefinition.php @@ -0,0 +1,50 @@ +|null $choices + * @param array{object_type: string, name: ?string}|null $related + */ + public function __construct( + public readonly string $key, + public readonly string $label, + public readonly string $type, + public readonly string $source, + public readonly string $status, + public readonly bool $required = false, + public readonly bool $multiple = false, + public readonly ?array $choices = null, + public readonly ?array $related = null, + public readonly string $description = '' + ) { + } + + /** @return array */ + public function toArray(): array + { + return [ + 'key' => $this->key, + 'label' => $this->label, + 'type' => $this->type, + 'source' => $this->source, + 'status' => $this->status, + 'required' => $this->required, + 'multiple' => $this->multiple, + 'choices' => $this->choices, + 'related' => $this->related, + 'description' => $this->description, + ]; + } +} diff --git a/src/Integrations/WordPress/Schema/FieldSource.php b/src/Integrations/WordPress/Schema/FieldSource.php new file mode 100644 index 0000000..4dd4531 --- /dev/null +++ b/src/Integrations/WordPress/Schema/FieldSource.php @@ -0,0 +1,28 @@ + */ + public function fieldsFor(string $postType): array; + + public function read(int $postId, FieldDefinition $field): mixed; + + /** + * @param array $values Validated values; null clears the field. + * @param array $definitions All definitions of the post type, by key. + * @throws RuntimeException When the value could not be stored. + */ + public function write(int $postId, string $postType, array $values, array $definitions): void; +} diff --git a/src/Integrations/WordPress/Schema/MetaFieldSource.php b/src/Integrations/WordPress/Schema/MetaFieldSource.php new file mode 100644 index 0000000..9470f6f --- /dev/null +++ b/src/Integrations/WordPress/Schema/MetaFieldSource.php @@ -0,0 +1,38 @@ +key, ! $field->multiple); + } + + public function write(int $postId, string $postType, array $values, array $definitions): void + { + foreach ($values as $key => $value) { + $key = (string) $key; + if ($value === null) { + delete_post_meta($postId, $key); + continue; + } + + $field = $definitions[$key] ?? null; + if ($field !== null && $field->multiple && is_array($value) && array_is_list($value)) { + delete_post_meta($postId, $key); + foreach ($value as $item) { + add_post_meta($postId, $key, wp_slash($item)); + } + continue; + } + + update_post_meta($postId, $key, wp_slash($value)); + } + } +} diff --git a/src/Integrations/WordPress/Schema/RegisteredMetaSource.php b/src/Integrations/WordPress/Schema/RegisteredMetaSource.php new file mode 100644 index 0000000..22d3a33 --- /dev/null +++ b/src/Integrations/WordPress/Schema/RegisteredMetaSource.php @@ -0,0 +1,45 @@ + $args) { + $key = (string) $key; + $args = is_array($args) ? $args : []; + $type = (string) ($args['type'] ?? 'string'); + $readonly = is_protected_meta($key, 'post') && empty($args['show_in_rest']); + + $fields[] = new FieldDefinition( + $key, + $key, + in_array($type, self::TYPES, true) ? $type : 'unknown', + 'meta', + $readonly ? FieldDefinition::READONLY : FieldDefinition::WRITABLE, + false, + ! (bool) ($args['single'] ?? false), + null, + null, + (string) ($args['description'] ?? '') + ); + } + + return $fields; + } +} diff --git a/src/Integrations/WordPress/Schema/SampledMetaSource.php b/src/Integrations/WordPress/Schema/SampledMetaSource.php new file mode 100644 index 0000000..7f6fb4c --- /dev/null +++ b/src/Integrations/WordPress/Schema/SampledMetaSource.php @@ -0,0 +1,54 @@ +db ?? $GLOBALS['wpdb']; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only core table names interpolated. + $keys = $wpdb->get_col($wpdb->prepare( + "SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm + INNER JOIN ( + SELECT ID FROM {$wpdb->posts} + WHERE post_type = %s AND post_status NOT IN ('auto-draft', 'trash') + ORDER BY ID DESC LIMIT %d + ) recent ON recent.ID = pm.post_id + ORDER BY pm.meta_key LIMIT %d", + $postType, + self::SAMPLE_POSTS, + self::MAX_KEYS + )); + + $fields = []; + foreach ((array) $keys as $key) { + $key = (string) $key; + if ($key === '' || str_starts_with($key, '_')) { + continue; + } + + $fields[] = new FieldDefinition($key, $key, 'unknown', 'sample', FieldDefinition::UNKNOWN); + } + + return $fields; + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php new file mode 100644 index 0000000..3cd768c --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php @@ -0,0 +1,97 @@ +allows('id')->andReturn($id); + $source->allows('fieldsFor')->andReturn($fields); + + return $source; + } + + private function stubPostTypes(): void + { + Functions\when('get_post_types')->justReturn([ + 'post' => 'post', + 'page' => 'page', + 'attachment' => 'attachment', + 'et_pb_layout' => 'et_pb_layout', + 'lit_person' => 'lit_person', + ]); + } + + public function testPostTypeNamesExcludeInternalTypesAndAreSorted(): void + { + $this->stubPostTypes(); + + $schema = new ContentSchema([]); + + self::assertSame(['lit_person', 'page', 'post'], $schema->postTypeNames()); + self::assertTrue($schema->isManaged('page')); + self::assertFalse($schema->isManaged('et_pb_layout')); + } + + public function testFieldsAreMergedByPriorityAndSorted(): void + { + $pods = $this->source('pods', [new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true)]); + $meta = $this->source('sample', [ + new FieldDefinition('role', 'role', 'unknown', 'sample', 'unknown'), + new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'), + ]); + $schema = new ContentSchema([$pods, $meta]); + + $fields = $schema->fields('lit_person'); + + self::assertSame(['city', 'role'], array_keys($fields)); + self::assertSame('pods', $fields['role']->source); + self::assertTrue($fields['role']->required); + self::assertSame($pods, $schema->sourceOf($fields['role'])); + self::assertSame($meta, $schema->sourceOf($fields['city'])); + } + + public function testDescribeIncludesTaxonomiesCountsDiviAndOptionalFields(): void + { + $this->stubPostTypes(); + Functions\when('get_post_type_object')->justReturn((object) [ + 'hierarchical' => true, + 'labels' => (object) ['name' => 'Seiten', 'singular_name' => 'Seite'], + ]); + Functions\when('get_all_post_type_supports')->justReturn(['title' => true, 'editor' => true]); + Functions\when('wp_count_posts')->justReturn((object) ['publish' => 3, 'draft' => 2, 'trash' => 9]); + Functions\when('get_object_taxonomies')->justReturn([ + (object) ['name' => 'category', 'show_ui' => true, 'hierarchical' => true, 'labels' => (object) ['name' => 'Kategorien']], + (object) ['name' => 'post_format', 'show_ui' => false, 'hierarchical' => false, 'labels' => (object) ['name' => 'Formate']], + ]); + $schema = new ContentSchema( + [$this->source('meta', [new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable')])], + static fn (string $type): bool => $type === 'page' + ); + + $withoutFields = $schema->describe('page', false); + $withFields = $schema->describe('page', true); + + self::assertSame('Seiten', $withoutFields['label']); + self::assertSame('Seite', $withoutFields['singular_label']); + self::assertTrue($withoutFields['hierarchical']); + self::assertSame(['title', 'editor'], $withoutFields['supports']); + self::assertTrue($withoutFields['divi']); + self::assertSame(5, $withoutFields['count']); + self::assertSame([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]], $withoutFields['taxonomies']); + self::assertArrayNotHasKey('fields', $withoutFields); + self::assertSame('footnotes', $withFields['fields'][0]['key']); + self::assertNull($schema->describe('et_pb_layout', false)); + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php b/tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php new file mode 100644 index 0000000..f4972a4 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php @@ -0,0 +1,92 @@ +> */ + private function fieldsByKey(): array + { + Functions\when('get_registered_meta_keys')->alias(static function (string $type, string $sub = ''): array { + if ($sub === '') { + return ['footnotes' => ['type' => 'string', 'single' => true, 'show_in_rest' => true]]; + } + + return [ + '_internal' => ['type' => 'integer', 'single' => true, 'show_in_rest' => false], + 'tags_list' => ['type' => 'array', 'single' => false, 'description' => 'Liste'], + ]; + }); + Functions\when('is_protected_meta')->alias(static fn (string $key): bool => str_starts_with($key, '_')); + + $fields = (new RegisteredMetaSource())->fieldsFor('event'); + + return array_column(array_map(static fn (FieldDefinition $f): array => $f->toArray(), $fields), null, 'key'); + } + + public function testDescribesGlobalAndSubtypeMetaKeys(): void + { + $fields = $this->fieldsByKey(); + + self::assertSame(['footnotes', '_internal', 'tags_list'], array_keys($fields)); + self::assertSame('string', $fields['footnotes']['type']); + self::assertSame(FieldDefinition::WRITABLE, $fields['footnotes']['status']); + self::assertSame('meta', $fields['footnotes']['source']); + self::assertFalse($fields['footnotes']['multiple']); + } + + public function testProtectedMetaHiddenFromRestIsReadonly(): void + { + $fields = $this->fieldsByKey(); + + self::assertSame(FieldDefinition::READONLY, $fields['_internal']['status']); + self::assertSame('integer', $fields['_internal']['type']); + } + + public function testNonSingleMetaIsMultiple(): void + { + $fields = $this->fieldsByKey(); + + self::assertTrue($fields['tags_list']['multiple']); + self::assertSame('Liste', $fields['tags_list']['description']); + } + + public function testReadUsesSingleOrMultipleMeta(): void + { + Functions\expect('get_post_meta')->once()->with(5, 'footnotes', true)->andReturn('x'); + Functions\expect('get_post_meta')->once()->with(5, 'tags_list', false)->andReturn(['a', 'b']); + $source = new RegisteredMetaSource(); + + self::assertSame('x', $source->read(5, new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable'))); + self::assertSame( + ['a', 'b'], + $source->read(5, new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true)) + ); + } + + public function testWriteUpdatesReplacesListsAndDeletesNull(): void + { + $single = new FieldDefinition('footnotes', 'footnotes', 'string', 'meta', 'writable'); + $multi = new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true); + $gone = new FieldDefinition('old', 'old', 'string', 'meta', 'writable'); + Functions\expect('update_post_meta')->once()->with(5, 'footnotes', 'neu'); + Functions\expect('delete_post_meta')->once()->with(5, 'tags_list'); + Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'a'); + Functions\expect('add_post_meta')->once()->with(5, 'tags_list', 'b'); + Functions\expect('delete_post_meta')->once()->with(5, 'old'); + + (new RegisteredMetaSource())->write( + 5, + 'event', + ['footnotes' => 'neu', 'tags_list' => ['a', 'b'], 'old' => null], + ['footnotes' => $single, 'tags_list' => $multi, 'old' => $gone] + ); + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/SampledMetaSourceTest.php b/tests/Unit/Integrations/WordPress/Schema/SampledMetaSourceTest.php new file mode 100644 index 0000000..d1fef9b --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Schema/SampledMetaSourceTest.php @@ -0,0 +1,29 @@ +makePartial(); + $db->expects('get_col') + ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, "post_type = 'lit_person'") + && str_contains($sql, 'LIMIT 50'))) + ->andReturn(['_edit_lock', 'city', 'role']); + + $fields = (new SampledMetaSource($db))->fieldsFor('lit_person'); + + self::assertSame(['city', 'role'], array_map(static fn (FieldDefinition $f): string => $f->key, $fields)); + self::assertSame(FieldDefinition::UNKNOWN, $fields[0]->status); + self::assertSame('sample', $fields[0]->source); + self::assertSame('unknown', $fields[0]->type); + } +}