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 */ 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 */ 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 */ 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, tags: list, 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 */ 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 */ 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)) : []; } }