Files
WP-LIT-MCP/src/Integrations/Divi/Library/DiviLibraryStore.php
T

230 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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))
: [];
}
}