is_virtual = is_null( $post ); $this->virtual_data = $virtual_data; if ( ! $this->is_virtual ) { $this->post = $post; $this->meta = new MetaHandler( $post->ID ); } else { $this->uid = uniqid(); } } /** * Get the ID of this entity. * Returns 0 for virtual entities (those without a WP_Post). * * @return int|string */ public function get_id(): int|string { return $this->is_virtual ? $this->get_uid() : $this->post->ID; } /** * Returns the saved UID. * * @return string */ public function get_uid(): string { return $this->uid; } /** * Return the public ID of this entity. * * @return string */ public function get_public_id(): string { // Generated lazily: most entities scanned during repository get_all() never render and never need it. if ( ! isset( $this->public_id ) ) { $this->public_id = Util::create_random_id( 24 ); } return $this->public_id; } /** * @return string */ public function get_title(): string { return $this->post?->post_title ?: __( '(No Title)', 'adpresso' ); } /** * Checks if this entity can currently be displayed. * Takes Pro-Features like 'display_only_once' and internal backend validity into account. * * @return bool */ public function can_be_displayed(): bool { return $this->is_backend_valid; } /** * Marks this entity as rendered for the current request. * Used to prevent multiple outputs of the same ad if 'display_only_once' is active. * * @param bool $client_side True when only its JS config was shipped - the browser decides whether it shows. */ public function mark_as_rendered( bool $client_side = false ): void { } /** * Get the post object for this entity. * * @return WP_Post|null */ public function get_post(): ?WP_Post { return $this->post ?? null; } /** * Get a specific meta value for this entity. * If the entity is virtual, it will return the value from the virtual data array. * * @param string $key * @param mixed|null $default * @param bool $single * * @return mixed */ public function get_meta( string $key, mixed $default = null, bool $single = true ): mixed { if ( $this->is_virtual ) { return $this->virtual_data[ $key ] ?? $default; } return $this->meta->get( $key, $default, $single ); } /** * Get the corresponding type object (e.g., AdType, GroupType, PlacementType). * Must be implemented by child classes. * * @return object|null The type object instance or null if not found. */ abstract public function get_type_object(): ?object; /** * Duplicate this entity. * * @param array $new_post_args * * @return int|WP_Error */ public function duplicate( array $new_post_args = [] ): int|WP_Error { if ( ! $this->post ) { return new WP_Error( 'no_post', 'Post object not found' ); } $post_type_object = get_post_type_object( $this->post->post_type ); if ( ! $post_type_object || ! current_user_can( $post_type_object->cap->create_posts ) ) { return new WP_Error( 'permission_denied', 'User cannot create posts of this type.' ); } if ( ! current_user_can( 'edit_post', $this->get_id() ) ) { return new WP_Error( 'permission_denied', 'User cannot edit the source item.' ); } $current_time = current_time( 'mysql' ); $new_post_data = [ 'post_author' => get_current_user_id(), 'post_content' => $this->post->post_content, 'post_title' => sprintf( '%s (Copy %s)', $this->post->post_title, $current_time ), /** * Filters the post status used when creating a duplicated post. * * By default, duplicated posts are saved as 'draft' to prevent them from * being accidentally published. Developers can override this behavior * if their workflow requires a different default status (e.g. 'pending'). * * @param string $status The default status for duplicated posts. * @param WP_Post $post The original post being duplicated. * * @since 1.0.0 * */ 'post_status' => apply_filters( 'adpresso_duplicate_post_status', 'draft', $this->post ), 'post_type' => $this->post->post_type, 'comment_status' => $this->post->comment_status, 'ping_status' => $this->post->ping_status, 'post_parent' => $this->post->post_parent, 'menu_order' => $this->post->menu_order, 'post_mime_type' => $this->post->post_mime_type, ]; $final_post_data = wp_parse_args( $new_post_args, $new_post_data ); // wp_insert_post() and add_post_meta() expect slashed data. $new_post_id = wp_insert_post( wp_slash( $final_post_data ), true ); if ( is_wp_error( $new_post_id ) ) { return $new_post_id; } $meta_data = get_post_meta( $this->get_id() ); foreach ( $meta_data as $meta_key => $meta_values ) { if ( str_starts_with( $meta_key, '_' ) && ! str_starts_with( $meta_key, Constants::META_PREFIX ) ) { continue; } // The copy is not part of the import; rolling that back must not delete it. if ( Constants::META_PREFIX . 'import_session_key' === $meta_key ) { continue; } foreach ( $meta_values as $meta_value ) { add_post_meta( $new_post_id, $meta_key, wp_slash( maybe_unserialize( $meta_value ) ) ); } } $taxonomies = get_object_taxonomies( $this->post->post_type ); foreach ( $taxonomies as $taxonomy ) { $terms = wp_get_object_terms( $this->get_id(), $taxonomy, [ 'fields' => 'slugs' ] ); wp_set_object_terms( $new_post_id, $terms, $taxonomy, false ); } do_action( 'adpresso_after_duplicate_entity', $new_post_id, $this->get_id() ); return $new_post_id; } /** * Get the layout styles as a string for this entity. * * @return string */ public function get_layout_styles_string(): string { $styles = []; $layout_settings = $this->get_meta( 'layout_settings', [] ); if ( empty( $layout_settings ) ) { return ''; } $margins = $layout_settings['margin'] ?? []; foreach ( [ 'top', 'right', 'bottom', 'left' ] as $direction ) { if ( isset( $margins[ $direction ] ) && is_numeric( $margins[ $direction ] ) ) { $styles[] = sprintf( 'margin-%s: %dpx;', $direction, (int) $margins[ $direction ] ); } } $position = $layout_settings['position'] ?? 'none'; $needs_clearfix_behavior = false; switch ( $position ) { case 'left_float': $styles[] = 'float: left;'; $needs_clearfix_behavior = true; break; case 'right_float': $styles[] = 'float: right;'; $needs_clearfix_behavior = true; break; // Block positions must not float, the following content starts below. case 'left_nofloat': $styles[] = 'display: block;'; $styles[] = 'margin-right: auto;'; $styles[] = 'text-align: left;'; break; case 'right_nofloat': $styles[] = 'display: block;'; $styles[] = 'margin-left: auto;'; $styles[] = 'text-align: right;'; break; case 'center_nofloat': $styles[] = 'display: block;'; $styles[] = 'margin-left: auto;'; $styles[] = 'margin-right: auto;'; // text-align: center is for the content of the wrapper, not the wrapper itself. $styles[] = 'text-align: center;'; break; case 'none': default: break; } return trim( implode( ' ', $styles ) ); } /** * Prepares the backend data for this entity, so it's ready to get processed in its specific entity class. * * @param array $context_info * * @return void */ protected function prepare_backend_data( array $context_info = [] ): void { $this->is_backend_valid = true; $this->skip_reason = null; $this->requires_frontend_logic = false; $this->frontend_config_data = []; $this->frontend_conditions_json = null; if ( ! $this->is_virtual && $this->post ) { if ( $this->post->post_status !== 'publish' && ! is_preview() ) { $this->is_backend_valid = false; /* translators: %s: the post status of the entity, e.g. draft or pending. */ $this->skip_reason = sprintf( __( 'Entity is not published (Status: %s).', 'adpresso' ), $this->post->post_status ); $this->backend_processed = true; return; } } $this->conditions_json = $this->get_meta( 'conditions_tree' ); $registry = Condition_Registry::get(); $evaluator = new ConditionEvaluator( $registry, $this, ConditionInterface::CONTEXT_BACKEND ); $conditions_met = $evaluator->evaluate_tree( $this->conditions_json, $this->get_id() ); $conditions_met = apply_filters( 'adpresso_placement_backend_conditions_met', $conditions_met, $this, $context_info ); if ( ! $conditions_met ) { // Read before resolve_tree(), which evaluates every leaf and would overwrite the reason. $this->is_backend_valid = false; $this->skip_reason = __( 'Backend conditions not met', 'adpresso' ) . ': ' . $evaluator->get_reason(); $this->backend_processed = true; return; } // Only for an entity that can still show: the browser gets the tree with the backend results filled in. $this->frontend_conditions_json = ConditionHelper::tree_contains_frontend_conditions( $this->conditions_json ) ? $evaluator->resolve_tree( $this->conditions_json ) : null; } /** * The conditions tree to send to the browser: backend conditions replaced by their result, * so an OR with a frontend condition isn't always true there. * * @return string|null */ public function get_frontend_conditions_json(): ?string { return $this->frontend_conditions_json ?? $this->conditions_json; } /** * Prepares the entity's data for a REST API response. * This base method gathers common fields. Child classes should extend this * to add their specific data. * * @param bool $include_edit_only_data Whether to include data only the edit modal needs. * * @return array The prepared data array for the REST response. */ public function prepare_for_rest_response( bool $include_edit_only_data = true ): array { if ( ! $this->post && ! $this->is_virtual ) { return [ 'id' => 0, 'error' => 'Post object not loaded.' ]; } $list_slug = $this->get_list_slug(); $data = [ 'id' => $this->get_id(), 'title' => $this->get_title(), 'content' => $this->post ? $this->post->post_content : '', 'readable_status' => $this->post ? ( get_post_status_object( $this->post->post_status )->label ?? $this->post->post_status ) : __( 'Virtual', 'adpresso' ), 'status' => $this->post ? $this->post->post_status : 'virtual', 'date_modified' => $this->post ? mysql2date( 'Y-m-d\TH:i:s', $this->post->post_modified ) : current_time( 'Y-m-d\TH:i:s' ), 'author' => $this->post ? get_the_author_meta( 'display_name', $this->post->post_author ) : '', 'edit_link' => $this->post ? html_entity_decode( Util::get_entity_edit_url($this->get_list_slug(), $this->get_id(), $this->get_list_slug() ) ) : '', 'can_edit' => $this->can_edit(), ]; $conditions_summary = []; if ( ! empty( $this->conditions_json ) ) { $tree = json_decode( $this->conditions_json, true ); if ( json_last_error() === JSON_ERROR_NONE && is_array( $tree ) ) { $conditions_summary = ConditionHelper::summarize_conditions_recursive( $tree ); } } $data['meta_data'] = $this->get_all_meta_for_response(); $data['conditions'] = $conditions_summary; if ( $include_edit_only_data ) { $data['edit_ui_sections'] = $this->get_edit_ui_sections(); $data['condition_builder_data'] = Condition_Registry::get()->get_all_condition_configs(); } $data['quick_links'] = $this->get_quick_links(); return $data; } /** * Return the list slug for the entity. * * @return string */ private function get_list_slug( ):string { if($this instanceof Ad){ return 'ads'; } elseif ($this instanceof Group){ return 'groups'; } return 'placements'; } /** * Returns default meta data for a virtual entity. * * @return array */ public function get_default_meta_data(): array { $defaults = []; // Get all UI sections. $sections = $this->get_edit_ui_sections(); foreach ( $sections as $section ) { if ( ! empty( $section['fields'] ) ) { foreach ( $section['fields'] as $field ) { if ( isset( $field['default'] ) ) { $defaults[ $field['id'] ] = $field['default']; } // Special case for complex fields like conditions. if ( $field['id'] === 'conditions_tree' ) { $defaults['conditions_tree'] = wp_json_encode( [ 'outer_connector' => 'AND', 'groups' => [ [ 'id' => uniqid( 'g_' ), 'conditions' => [] ] ], ] ); } } } } return $defaults; } /** * Whether the current user may edit this entity (false for virtual ones). * * @return bool */ public function can_edit(): bool { $post_type_object = $this->post ? get_post_type_object( $this->post->post_type ) : null; return $post_type_object && current_user_can( $post_type_object->cap->edit_post, $this->get_id() ); } /** * Creates quick links for the list table. * Get extended by the child classes. * * @return array */ protected function get_quick_links(): array { if ( ! $this->post ) { return []; } $post_id = $this->get_id(); $post_type_object = get_post_type_object( $this->post->post_type ); if ( ! $post_type_object ) { return []; } $links = []; if ( $this->can_edit() ) { $links['edit'] = [ 'key' => 'edit', 'label' => __( 'Edit', 'adpresso' ), 'action' => 'open_modal', 'variant' => 'button-active', ]; } if ( current_user_can( $post_type_object->cap->delete_post, $post_id ) ) { $links['trash'] = [ 'key' => 'trash', 'label' => __( 'Trash', 'adpresso' ), 'action' => 'trash', 'variant' => 'destructive', ]; } if ( $this->can_edit() ) { $links['duplicate'] = [ 'key' => 'duplicate', 'label' => __( 'Clone', 'adpresso' ), 'action' => 'duplicate', 'premium' => true, 'disabled' => true, ]; } $links['shortcode'] = [ 'key' => 'usage', 'label' => __( 'Shortcode', 'adpresso' ), 'action' => 'copy_shortcode', 'variant' => 'button-active', ]; /** * Filters the quick-links for an entity in the REST response. * * @param array $links The array with the link definitions. * @param Entity $this The current entity object. */ return apply_filters( 'adpresso_entity_quick_links', $links, $this ); } /** * Gathers the UI "blueprint" for the edit modal for this entity type. * It fetches the field and section definitions from the respective registries. * * @return array The structured UI configuration. */ protected function get_edit_ui_sections(): array { $sections = []; if ( $this instanceof Ad ) { $orchestrator = new Ad_Metaboxes_Orchestrator(); } elseif ( $this instanceof Group ) { $orchestrator = new Group_Metaboxes_Orchestrator(); } elseif ( $this instanceof Placement ) { $orchestrator = new Placement_Metaboxes_Orchestrator(); } else { return []; } foreach ( $orchestrator->get_metabox_classes() as $metabox_class ) { /** @var AbstractMetaboxHandler $metabox */ $metabox = new $metabox_class(); $sections[] = [ 'id' => $metabox->get_box_id(), 'title' => $metabox->get_box_title(), 'fields' => $metabox->get_fields( $this ), 'summary_callback' => $metabox->get_summary_callback(), 'controls_dependencies' => $metabox->controls_dependencies(), 'receives_dependencies' => $metabox->receives_dependencies(), ]; } return self::conditions_after_basics( $sections, $orchestrator ); } /** * Display order only: Conditions right after the basic sections. The box list itself keeps its order, it is * also the save order (Scheduling writes into the conditions tree before Conditions saves it). * * @param array $sections Sections in box order. * @param object $orchestrator The entity's metabox orchestrator. * * @return array */ private static function conditions_after_basics( array $sections, object $orchestrator ): array { $anchors = [ Ad_Metaboxes_Orchestrator::class => 'adpresso_ad_assignment', Group_Metaboxes_Orchestrator::class => 'adpresso_group_banner_blindness', Placement_Metaboxes_Orchestrator::class => 'adpresso_placement_general', ]; $ids = array_column( $sections, 'id' ); $conditions = array_search( 'adpresso_conditions', $ids, true ); $anchor = array_search( $anchors[ get_class( $orchestrator ) ] ?? '', $ids, true ); if ( false === $conditions || false === $anchor ) { return $sections; } $moved = array_splice( $sections, $conditions, 1 ); $ids = array_column( $sections, 'id' ); array_splice( $sections, (int) array_search( $anchors[ get_class( $orchestrator ) ], $ids, true ) + 1, 0, $moved ); return $sections; } /** * Gathers all AdPresso-related metadata for a given entity. * * @return array A key-value array of all metadata (without prefix). */ protected function get_all_meta_for_response(): array { if ( $this->is_virtual ) { return $this->virtual_data; } $all_meta = get_post_meta( $this->get_id() ); $prepared_meta = []; if ( empty( $all_meta ) ) { return $prepared_meta; } foreach ( $all_meta as $key => $value ) { $prefix = Constants::META_PREFIX; if ( str_starts_with( $key, $prefix ) ) { $clean_key = str_replace( $prefix, '', $key ); $prepared_meta[ $clean_key ] = maybe_unserialize( $value[0] ); } } return $prepared_meta; } }