Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
230 lines
8.7 KiB
PHP
230 lines
8.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter for the Divi Library (E4, E15): post type et_pb_layout, the taxonomies scope,
|
||
* layout_type, module_width, layout_category, layout_tag and the meta Divi's et_pb_submit_layout()
|
||
* writes (F1, F2, fixtures stage3). "Global" is Divi's own definition
|
||
* GlobalLayout::is_global_layout_template() (F5) with the scope term as fallback when the class is
|
||
* gone. find() filters by the scope term only, so an entry Divi counts as global through its D4
|
||
* meta fallback can show up under scope "normal" (preflight M-12, accepted). Library entries are
|
||
* always "publish" (F2). Not final so abilities can be tested with a mock.
|
||
*/
|
||
class DiviLibraryStore
|
||
{
|
||
public const POST_TYPE = 'et_pb_layout';
|
||
|
||
public const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout';
|
||
|
||
public const LAYOUT_TYPES = ['layout', 'section', 'row', 'module'];
|
||
|
||
/** Taxonomies every new entry needs (et_pb_submit_layout). */
|
||
public const TAXONOMIES = ['scope', 'layout_type', 'module_width'];
|
||
|
||
/** Client text when the terms or meta of a new entry could not be written. */
|
||
public const REASON_CREATE = 'Der Bibliothekseintrag konnte nicht vollständig angelegt werden.';
|
||
|
||
public function isGlobal(int $id): bool
|
||
{
|
||
if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) {
|
||
return (bool) call_user_func([self::GLOBAL_LAYOUT, 'is_global_layout_template'], $id);
|
||
}
|
||
|
||
return in_array('global', $this->termSlugs($id, 'scope'), true);
|
||
}
|
||
|
||
/** "layout", "section", "row" or "module"; null when the entry has no known type. */
|
||
public function layoutType(int $id): ?string
|
||
{
|
||
foreach ($this->termSlugs($id, 'layout_type') as $slug) {
|
||
if (in_array($slug, self::LAYOUT_TYPES, true)) {
|
||
return $slug;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function termSlugs(int $id, string $taxonomy): array
|
||
{
|
||
return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy));
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function termNames(int $id, string $taxonomy): array
|
||
{
|
||
return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy));
|
||
}
|
||
|
||
/**
|
||
* Published entries, $limit + 1 of them (Cursor::page() tells whether there are more).
|
||
*
|
||
* @param array{layout_type?: string, scope?: string, search?: string, category?: string, author?: int} $filters
|
||
* scope: "global" or "normal"; search: in the title; category: name of a layout_category
|
||
* term; author: only this user's entries.
|
||
* @return list<WP_Post>
|
||
*/
|
||
public function find(array $filters, int $limit, int $offset): array
|
||
{
|
||
$taxQuery = [];
|
||
if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) {
|
||
$taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]];
|
||
}
|
||
if (($filters['scope'] ?? '') === 'global') {
|
||
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']];
|
||
} elseif (($filters['scope'] ?? '') === 'normal') {
|
||
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'];
|
||
}
|
||
if (($filters['category'] ?? '') !== '') {
|
||
$taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]];
|
||
}
|
||
|
||
$args = [
|
||
'post_type' => self::POST_TYPE,
|
||
'post_status' => 'publish',
|
||
'posts_per_page' => $limit + 1,
|
||
'offset' => $offset,
|
||
'orderby' => 'title',
|
||
'order' => 'ASC',
|
||
'no_found_rows' => true,
|
||
'suppress_filters' => false,
|
||
];
|
||
if (($filters['search'] ?? '') !== '') {
|
||
$args['s'] = $filters['search'];
|
||
// Audit L3: the title only – content is block JSON, and entries the user may not read
|
||
// must not be findable by their content.
|
||
$args['search_columns'] = ['post_title'];
|
||
}
|
||
if (is_int($filters['author'] ?? null)) {
|
||
$args['author'] = $filters['author'];
|
||
}
|
||
if ($taxQuery !== []) {
|
||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
|
||
$args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery);
|
||
}
|
||
|
||
return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post));
|
||
}
|
||
|
||
/**
|
||
* @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string,
|
||
* categories: list<string>, tags: list<string>, modified: string}
|
||
*/
|
||
public function describe(WP_Post $post): array
|
||
{
|
||
$id = (int) $post->ID;
|
||
$moduleType = (string) get_post_meta($id, '_et_pb_module_type', true);
|
||
|
||
return [
|
||
'id' => $id,
|
||
'title' => (string) $post->post_title,
|
||
'layout_type' => $this->layoutType($id),
|
||
'scope' => $this->isGlobal($id) ? 'global' : 'normal',
|
||
'module_type' => $moduleType === '' ? null : $moduleType,
|
||
'categories' => $this->termNames($id, 'layout_category'),
|
||
'tags' => $this->termNames($id, 'layout_tag'),
|
||
'modified' => (string) $post->post_modified_gmt,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* A published entry with the terms and meta of $spec and empty content (E4; the layout follows
|
||
* through DiviSaver::saveNew()). All or nothing through $draft (ruling P6, the transaction of
|
||
* lit/content-create): a refused insert or missing taxonomies create nothing; a failed term or
|
||
* meta write deletes the entry again (write_failed), or reports partial_write and logs it
|
||
* when that delete fails; a throwing insert hook is a partial write without ID.
|
||
*
|
||
* @throws WriteFailed
|
||
*/
|
||
public function create(LibraryItemSpec $spec, NewDraft $draft): int
|
||
{
|
||
$missing = $this->missingTaxonomies();
|
||
if ($missing !== []) {
|
||
throw WriteFailed::rolledBack(
|
||
'Die Divi-Bibliothek ist in dieser Anfrage nicht geladen.',
|
||
detail: 'Divi Library taxonomies not registered: ' . implode(', ', $missing)
|
||
);
|
||
}
|
||
|
||
$id = $draft->insert([
|
||
'post_type' => self::POST_TYPE,
|
||
'post_status' => 'publish',
|
||
'post_title' => $spec->title,
|
||
'post_content' => '',
|
||
]);
|
||
if ($id instanceof WP_Error) {
|
||
throw WriteFailed::rolledBack(
|
||
'WordPress hat den Bibliothekseintrag abgelehnt.',
|
||
detail: 'wp_insert_post refused the library entry: ' . $id->get_error_message()
|
||
);
|
||
}
|
||
|
||
try {
|
||
$this->describeNew($id, $spec);
|
||
} catch (Throwable $e) {
|
||
$draft->discard($id, $e, self::REASON_CREATE);
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
/**
|
||
* Library taxonomies not registered in this request (F2: Divi registers them in
|
||
* ET_Builder_Library; fixture finding 6: not in every context).
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function missingTaxonomies(): array
|
||
{
|
||
return array_values(array_filter(
|
||
self::TAXONOMIES,
|
||
static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy)
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Terms and meta of $spec on the new entry $id, each checked.
|
||
*
|
||
* @throws RuntimeException
|
||
*/
|
||
private function describeNew(int $id, LibraryItemSpec $spec): void
|
||
{
|
||
foreach ($spec->terms() as $taxonomy => $terms) {
|
||
$result = wp_set_object_terms($id, $terms, $taxonomy);
|
||
if ($result instanceof WP_Error) {
|
||
throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message()));
|
||
}
|
||
}
|
||
foreach ($spec->meta() as $key => $value) {
|
||
update_post_meta($id, $key, $value);
|
||
if ((string) get_post_meta($id, $key, true) !== $value) {
|
||
throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id));
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @return list<WP_Term> */
|
||
private function terms(int $id, string $taxonomy): array
|
||
{
|
||
$terms = get_the_terms($id, $taxonomy);
|
||
|
||
return is_array($terms)
|
||
? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term))
|
||
: [];
|
||
}
|
||
}
|