Constants::POST_TYPE_AD, 'groups' => Constants::POST_TYPE_GROUP, 'placements' => Constants::POST_TYPE_PLACEMENT, ]; foreach ( $post_types as $key => $post_type ) { $rest_base = $key; register_rest_route( $this->namespace, '/' . $rest_base . '/bulk-trash', [ [ 'methods' => WP_REST_Server::CREATABLE, // = POST 'callback' => [ $this, 'bulk_trash_items' ], 'permission_callback' => [ $this, 'permissions_check' ], 'args' => [ 'ids' => [ 'required' => true, 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'validate_callback' => function( $param ) { return is_array( $param ) && ! empty( $param ); }, 'sanitize_callback' => function( $param ) { return array_map( 'absint', $param ); }, ], ], ], ] ); register_rest_route( $this->namespace, '/' . $rest_base . '/bulk-status', [ [ 'methods' => WP_REST_Server::CREATABLE, // = POST 'callback' => [ $this, 'bulk_update_status' ], 'permission_callback' => [ $this, 'permissions_check' ], 'args' => [ 'ids' => [ 'required' => true, 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'validate_callback' => function( $param ) { return is_array( $param ) && ! empty( $param ); }, 'sanitize_callback' => function( $param ) { return array_map( 'absint', $param ); }, ], 'status' => [ 'required' => true, 'type' => 'string', 'validate_callback' => function( $param ) { return in_array( $param, [ 'publish', 'draft', 'private', 'pending' ], true ); }, ], ], ], ] ); register_rest_route( $this->namespace, '/' . $rest_base . '/bulk-export-clipboard', [ [ 'methods' => WP_REST_Server::CREATABLE, // = POST 'callback' => function ( WP_REST_Request $request ) use ( $key ) { return $this->bulk_export_clipboard( $request, $key ); }, 'permission_callback' => [ $this, 'clipboard_export_permissions_check' ], 'args' => [ 'ids' => [ 'required' => true, 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'validate_callback' => function( $param ) { return is_array( $param ) && ! empty( $param ); }, 'sanitize_callback' => function( $param ) { return array_map( 'absint', $param ); }, ], ], ], ] ); register_rest_route( $this->namespace, '/' . $rest_base . '/bulk-import-clipboard', [ [ 'methods' => WP_REST_Server::CREATABLE, // = POST 'callback' => [ $this, 'bulk_import_clipboard' ], 'permission_callback' => [ $this, 'clipboard_import_permissions_check' ], 'args' => [ 'payload' => [ 'required' => true, 'type' => 'object', 'validate_callback' => function( $param ) { return is_array( $param ); }, ], ], ], ] ); register_rest_route( $this->namespace, '/(?P[a-z_]+)/empty-trash', [ [ 'methods' => WP_REST_Server::DELETABLE, // DELETE 'callback' => [ $this, 'empty_trash' ], 'permission_callback' => [ $this, 'permissions_check' ], 'args' => [ 'entity_type' => [ 'required' => true, 'validate_callback' => function( $param ) use ( $post_types ) { return array_key_exists( $param, $post_types ); }, ], 'delete_media' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'default' => [], 'sanitize_callback' => function( $param ) { return array_map( 'absint', (array) $param ); }, ], ], ], ] ); } // Registered once (not per entity type, unlike the routes above) register_rest_route( $this->namespace, '/(?P[a-z_]+)/orphaned-media', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_orphaned_media_for_trash' ], 'permission_callback' => [ $this, 'permissions_check' ], 'args' => [ 'entity_type' => [ 'required' => true, 'validate_callback' => function( $param ) use ( $post_types ) { return array_key_exists( $param, $post_types ); }, ], ], ], ] ); } /** * General permission check for all bulk actions. * * @param WP_REST_Request $request * * @return bool|WP_Error */ public function permissions_check( WP_REST_Request $request ): bool|WP_Error { $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error( 'rest_forbidden_nonce', 'Nonce verification failed.', [ 'status' => 403 ] ); } if ( ! current_user_can( $this->capability_for_route( $request ) ) ) { return new WP_Error( 'rest_forbidden_capability', __( 'You do not have permission to perform this action.', 'adpresso' ), [ 'status' => 403 ] ); } return true; } /** * Maps the entity segment of the route to its edit capability. * * @param WP_REST_Request $request The bulk request. * * @return string */ private function capability_for_route( WP_REST_Request $request ): string { $route = $request->get_route(); if ( str_contains( $route, '/groups/' ) ) { return adpresso_get_capability( 'edit_group' ); } if ( str_contains( $route, '/placements/' ) ) { return adpresso_get_capability( 'edit_placement' ); } return adpresso_get_capability( 'edit_ad' ); } /** * Maps the entity segment of the route to its post type. * * @param WP_REST_Request $request The bulk request. * * @return string */ private function post_type_for_route( WP_REST_Request $request ): string { $route = $request->get_route(); if ( str_contains( $route, '/groups/' ) ) { return Constants::POST_TYPE_GROUP; } if ( str_contains( $route, '/placements/' ) ) { return Constants::POST_TYPE_PLACEMENT; } return Constants::POST_TYPE_AD; } /** * Permission check for the clipboard export route. * * @param WP_REST_Request $request * * @return bool|WP_Error */ public function clipboard_export_permissions_check( WP_REST_Request $request ): bool|WP_Error { if ( ! $this->clipboard_tools_enabled() ) { return new WP_Error( 'rest_forbidden_disabled', __( 'Clipboard tools are not enabled.', 'adpresso' ), [ 'status' => 403 ] ); } return $this->permissions_check( $request ); } /** * Permission check for the clipboard import route. * * @param WP_REST_Request $request * * @return bool|WP_Error */ public function clipboard_import_permissions_check( WP_REST_Request $request ): bool|WP_Error { if ( ! $this->clipboard_tools_enabled() ) { return new WP_Error( 'rest_forbidden_disabled', __( 'Clipboard tools are not enabled.', 'adpresso' ), [ 'status' => 403 ] ); } $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error( 'rest_forbidden_nonce', 'Nonce verification failed.', [ 'status' => 403 ] ); } if ( ! current_user_can( adpresso_get_capability( 'use_tools' ) ) ) { return new WP_Error( 'rest_forbidden_capability', __( 'You do not have permission to perform this action.', 'adpresso' ), [ 'status' => 403 ] ); } return true; } /** * @return bool */ private function clipboard_tools_enabled(): bool { return defined( 'ADPRESSO_CLIPBOARD_TOOLS' ) && ADPRESSO_CLIPBOARD_TOOLS === true; } /** * Callback function to move multiple items to the trash. * * @param WP_REST_Request $request * * @return WP_REST_Response */ public function bulk_trash_items( WP_REST_Request $request ): WP_REST_Response { $ids = $request->get_param( 'ids' ); $post_type = $this->post_type_for_route( $request ); $trashed_ids = []; $failed_ids = []; foreach ( $ids as $id ) { if ( get_post_type( $id ) === $post_type && current_user_can( 'delete_post', $id ) ) { $result = wp_trash_post( $id ); if ( $result ) { $trashed_ids[] = $id; } else { $failed_ids[] = $id; } } else { $failed_ids[] = $id; } } if ( empty( $trashed_ids ) && ! empty( $failed_ids ) ) { return new WP_REST_Response( [ 'success' => false, 'failed' => $failed_ids, 'message' => 'Could not trash items. Permission denied or error.' ], 403 ); } return new WP_REST_Response( [ 'success' => true, 'trashed' => $trashed_ids, 'failed' => $failed_ids ], 200 ); } /** * Callback function to update status of multiple items. * * @param WP_REST_Request $request * * @return WP_REST_Response */ public function bulk_update_status( WP_REST_Request $request ): WP_REST_Response { $ids = $request->get_param( 'ids' ); $status = $request->get_param( 'status' ); $post_type = $this->post_type_for_route( $request ); $updated_ids = []; $failed_ids = []; foreach ( $ids as $id ) { if ( get_post_type( $id ) === $post_type && current_user_can( 'edit_post', $id ) ) { $result = wp_update_post( [ 'ID' => $id, 'post_status' => $status, ] ); if ( $result ) { $updated_ids[] = $id; } else { $failed_ids[] = $id; } } else { $failed_ids[] = $id; } } if ( empty( $updated_ids ) && ! empty( $failed_ids ) ) { return new WP_REST_Response( [ 'success' => false, 'failed' => $failed_ids, 'message' => 'Could not update status. Permission denied or error.' ], 403 ); } return new WP_REST_Response( [ 'success' => true, 'updated' => $updated_ids, 'failed' => $failed_ids ], 200 ); } /** * Builds a clipboard-ready export payload for the selected rows. * * @param WP_REST_Request $request * @param string $entity_type 'ads', 'groups', or 'placements'. * * @return WP_REST_Response */ public function bulk_export_clipboard( WP_REST_Request $request, string $entity_type ): WP_REST_Response { $ids = $request->get_param( 'ids' ); $exporter = new JSONExporter(); $payload = $exporter->generate_clipboard_payload( $entity_type, $ids ); return new WP_REST_Response( $payload, 200 ); } /** * Imports a pasted clipboard payload (same shape JSONExporter produces). * * @param WP_REST_Request $request * * @return WP_REST_Response|WP_Error */ public function bulk_import_clipboard( WP_REST_Request $request ): WP_REST_Response|WP_Error { $payload = $request->get_param( 'payload' ); if ( ! is_array( $payload ) ) { return new WP_Error( 'rest_invalid_param', __( 'The clipboard payload must be an object.', 'adpresso' ), [ 'status' => 400 ] ); } $importer = new NativeJSONImporter(); $result = $importer->import_from_array( $payload ); return new WP_REST_Response( $result, 200 ); } /** * Callback function to permanently empty the trash for a specific entity type. * * @param WP_REST_Request $request * * @return WP_REST_Response|WP_Error */ public function empty_trash( WP_REST_Request $request ): WP_REST_Response|WP_Error { $entity_type_slug = $request->get_param( 'entity_type' ); $post_types = [ 'ads' => Constants::POST_TYPE_AD, 'groups' => Constants::POST_TYPE_GROUP, 'placements' => Constants::POST_TYPE_PLACEMENT, ]; $post_type = $post_types[ $entity_type_slug ] ?? null; if ( ! $post_type ) { return new WP_Error( 'rest_invalid_type', 'Invalid entity type.', [ 'status' => 404 ] ); } $query = new WP_Query( [ 'post_type' => $post_type, 'post_status' => 'trash', 'posts_per_page' => -1, 'fields' => 'ids', ] ); if ( empty( $query->posts ) ) { return new WP_REST_Response( [ 'success' => true, 'deleted_count' => 0, 'message' => 'Trash was already empty.' ], 200 ); } $count = 0; $pt_obj = get_post_type_object( $post_type ); $delete_cap = $pt_obj->cap->delete_post ?? 'delete_post'; foreach ( $query->posts as $post_id ) { if ( current_user_can( $delete_cap, $post_id ) ) { wp_delete_post( $post_id, true ); $count++; } } $media_ids = (array) $request->get_param( 'delete_media' ); $deleted_media_count = 0; if ( $entity_type_slug === 'ads' ) { foreach ( $media_ids as $media_id ) { if ( $media_id && current_user_can( 'delete_post', (int) $media_id ) && $this->attachment_is_orphaned( (int) $media_id ) ) { wp_delete_attachment( (int) $media_id, true ); $deleted_media_count++; } } } return new WP_REST_Response( [ 'success' => true, 'deleted_count' => $count, 'deleted_media_count' => $deleted_media_count, 'message' => sprintf( '%d items permanently deleted.', $count ), ], 200 ); } /** * Lists image attachments that would become orphaned (used by nothing else) * * @param WP_REST_Request $request * * @return WP_REST_Response */ public function get_orphaned_media_for_trash( WP_REST_Request $request ): WP_REST_Response { if ( $request->get_param( 'entity_type' ) !== 'ads' ) { return new WP_REST_Response( [], 200 ); } $trashed_ad_ids = get_posts( [ 'post_type' => Constants::POST_TYPE_AD, 'post_status' => 'trash', 'posts_per_page' => -1, 'fields' => 'ids', ] ); if ( empty( $trashed_ad_ids ) ) { return new WP_REST_Response( [], 200 ); } $candidate_image_ids = []; foreach ( $trashed_ad_ids as $ad_id ) { if ( get_post_meta( $ad_id, Constants::META_PREFIX . 'ad_type', true ) !== 'image' ) { continue; } $image_id = (int) get_post_meta( $ad_id, Constants::META_PREFIX . 'image_id', true ); if ( $image_id ) { $candidate_image_ids[ $image_id ] = true; // de-duped: multiple trashed ads can share one image } } $orphaned = []; foreach ( array_keys( $candidate_image_ids ) as $image_id ) { $attachment = get_post( $image_id ); if ( ! $attachment || $attachment->post_parent ) { continue; // already gone, or attached to some other post - not something we'd be orphaning } // "Still used" only counts ads OUTSIDE this trashed batch - every ad // in the batch is about to disappear together regardless. $still_used_elsewhere = get_posts( [ 'post_type' => Constants::POST_TYPE_AD, 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'post__not_in' => $trashed_ad_ids, 'meta_query' => [ 'relation' => 'AND', [ 'key' => Constants::META_PREFIX . 'ad_type', 'value' => 'image' ], [ 'key' => Constants::META_PREFIX . 'image_id', 'value' => $image_id ], ], ] ); if ( ! empty( $still_used_elsewhere ) ) { continue; } $orphaned[] = [ 'id' => $image_id, 'title' => get_the_title( $image_id ) ?: basename( (string) get_attached_file( $image_id ) ), ]; } return new WP_REST_Response( $orphaned, 200 ); } /** * Whether an attachment is a still-existing, unattached image no longer * referenced by any image-type ad - used right before actually deleting it, * to re-verify nothing changed since an earlier /orphaned-media preview. * * @param int $attachment_id * * @return bool */ private function attachment_is_orphaned( int $attachment_id ): bool { $attachment = get_post( $attachment_id ); if ( ! $attachment || $attachment->post_type !== 'attachment' || $attachment->post_parent ) { return false; } $still_used = get_posts( [ 'post_type' => Constants::POST_TYPE_AD, 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'meta_query' => [ 'relation' => 'AND', [ 'key' => Constants::META_PREFIX . 'ad_type', 'value' => 'image' ], [ 'key' => Constants::META_PREFIX . 'image_id', 'value' => $attachment_id ], ], ] ); return empty( $still_used ); } }