0, 'migrated' => 0, 'failed' => 0, ); /** * Constructor. * * @param int $id Post ID of the slider. */ public function __construct( $id ) { $this->id = absint( $id ); } /** * Get the slider (post) ID. * * @return int */ public function get_id() { return $this->id; } /** * Whether the underlying post exists and is the correct post type. * * @return bool */ public function exists() { return self::is_valid_slider_post( $this->id ); } /** * Internal helper: confirm a post ID is a valid slider post. * * @param int $id Post ID. * @return bool */ protected static function is_valid_slider_post( $id ) { $post = get_post( $id ); return $post && DeveloperSD_LS_Post_Type::POST_TYPE === $post->post_type; } /** * Get the slider title. * * @return string */ public function get_title() { return get_the_title( $this->id ); } /** * Get the slider status. Returns 'active' or 'inactive'. * * @return string */ public function get_status() { $status = get_post_meta( $this->id, DeveloperSD_LS_Post_Type::META_STATUS, true ); return 'active' === $status ? 'active' : 'inactive'; } /** * Whether the slider is active. * * @return bool */ public function is_active() { return 'active' === $this->get_status(); } /** * Set the slider status. * * @param string $status 'active' or 'inactive'. * @return void */ public function set_status( $status ) { update_post_meta( $this->id, DeveloperSD_LS_Post_Type::META_STATUS, 'active' === $status ? 'active' : 'inactive' ); } /** * Get the sanitized logos array for this slider. * * @return array[] */ public function get_logos() { $raw = get_post_meta( $this->id, DeveloperSD_LS_Post_Type::META_LOGOS, true ); $logos = $raw ? json_decode( $raw, true ) : array(); $logos = DeveloperSD_LS_Security::sanitize_logos( is_array( $logos ) ? $logos : array() ); // Drop logos whose attachment no longer exists so the frontend // never renders a broken . $logos = array_values( array_filter( $logos, static function ( $logo ) { return wp_attachment_is_image( $logo['image_id'] ); } ) ); return $logos; } /** * Persist a logos array for this slider. * * @param array $logos Raw logos array (will be sanitized). * @return void */ public function save_logos( $logos ) { $clean = DeveloperSD_LS_Security::sanitize_logos( $logos ); update_post_meta( $this->id, DeveloperSD_LS_Post_Type::META_LOGOS, wp_json_encode( $clean ) ); } /** * Get the full, schema-completed settings array for this slider. * * @return array */ public function get_settings() { $raw = get_post_meta( $this->id, DeveloperSD_LS_Post_Type::META_SETTINGS, true ); $saved = $raw ? json_decode( $raw, true ) : array(); $saved = is_array( $saved ) ? $saved : array(); return DeveloperSD_LS_Security::sanitize_settings( $saved, self::default_settings() ); } /** * Persist a settings array for this slider. * * @param array $settings Raw settings array (will be sanitized). * @return void */ public function save_settings( $settings ) { $clean = DeveloperSD_LS_Security::sanitize_settings( $settings, self::default_settings() ); update_post_meta( $this->id, DeveloperSD_LS_Post_Type::META_SETTINGS, wp_json_encode( $clean ) ); } /** * The full default settings schema for a slider. Also used as the * sanitization whitelist, so any key added here becomes both a valid * setting and gets a default value. * * @return array */ public static function default_settings() { $global = get_option( 'developersd_logo_slider_settings', array() ); $global = is_array( $global ) ? $global : array(); return array( // General / layout. 'slides_desktop' => isset( $global['default_slides_desktop'] ) ? intval( $global['default_slides_desktop'] ) : 5, 'slides_tablet' => isset( $global['default_slides_tablet'] ) ? intval( $global['default_slides_tablet'] ) : 3, 'slides_mobile' => isset( $global['default_slides_mobile'] ) ? intval( $global['default_slides_mobile'] ) : 2, 'breakpoint_desktop' => isset( $global['breakpoint_desktop'] ) ? intval( $global['breakpoint_desktop'] ) : 1200, 'breakpoint_tablet' => isset( $global['breakpoint_tablet'] ) ? intval( $global['breakpoint_tablet'] ) : 768, 'breakpoint_mobile' => isset( $global['breakpoint_mobile'] ) ? intval( $global['breakpoint_mobile'] ) : 480, 'gap' => 30, 'alignment' => 'center', 'container_width' => 0, 'image_width' => 140, 'image_height' => 60, 'image_fit' => 'contain', // Navigation. 'nav_show' => true, 'nav_desktop' => true, 'nav_tablet' => true, 'nav_mobile' => false, 'nav_position' => 'inside', 'nav_size' => 40, 'nav_icon' => 'chevron', 'nav_icon_size' => 24, 'nav_style' => 'circle', // Pagination. 'dots_show' => false, 'dots_desktop' => true, 'dots_tablet' => true, 'dots_mobile' => true, 'dots_position' => 'bottom', 'dots_size' => 10, // Autoplay. 'autoplay' => true, 'autoplay_delay' => 3000, 'transition_speed' => 500, 'pause_on_hover' => true, 'pause_on_inactive_tab' => true, // Loop. 'loop' => true, // Interaction. 'swipe' => true, 'mouse_drag' => true, 'keyboard_nav' => true, // Logo display / effects. 'hover_effect' => 'grayscale', 'grayscale' => false, 'opacity' => 100, 'hover_opacity' => 100, 'border_radius' => 0, 'item_padding' => 10, 'item_background' => '', 'item_border' => '', 'box_shadow' => false, 'hover_scale' => 105, 'custom_class' => '', // Accessibility / motion. 'respect_reduced_motion' => true, ); } /** * Generate the shortcode string used to render this slider. * * @return string */ public function get_shortcode() { return sprintf( '[developersd_logo_slider id="%d"]', $this->id ); } /** * Duplicate this slider (title, logos, settings). The duplicate is * always created as inactive so it never accidentally goes live, and * media attachments are reused rather than copied. * * @return int|WP_Error New slider post ID, or WP_Error on failure. */ public function duplicate() { if ( ! $this->exists() ) { return new WP_Error( 'developersd_ls_invalid_slider', __( 'The slider being duplicated could not be found.', 'developersd-logo-slider' ) ); } $new_id = wp_insert_post( array( 'post_type' => DeveloperSD_LS_Post_Type::POST_TYPE, 'post_status' => 'publish', /* translators: %s: original slider title. */ 'post_title' => sprintf( __( '%s (Copy)', 'developersd-logo-slider' ), $this->get_title() ), ), true ); if ( is_wp_error( $new_id ) ) { return $new_id; } $new = new self( $new_id ); $new->save_logos( $this->get_logos() ); $new->save_settings( $this->get_settings() ); $new->set_status( 'inactive' ); return $new_id; } /** * Export this slider's configuration as an associative array suitable * for JSON encoding. * * @return array */ public function export() { $logos = $this->get_logos(); foreach ( $logos as &$logo ) { $image_id = absint( $logo['image_id'] ); $image_url = $image_id ? wp_get_attachment_url( $image_id ) : false; $logo['image_url'] = $image_url ? esc_url_raw( $image_url ) : ''; $logo['image_filename'] = $image_url ? sanitize_file_name( wp_basename( (string) wp_parse_url( $image_url, PHP_URL_PATH ) ) ) : ''; $logo['image_mime_type'] = $image_id ? sanitize_mime_type( (string) get_post_mime_type( $image_id ) ) : ''; } unset( $logo ); return array( 'plugin' => 'developersd-logo-slider', 'version' => DEVELOPERSD_LS_VERSION, 'title' => $this->get_title(), 'status' => $this->get_status(), 'logos' => $logos, 'settings' => $this->get_settings(), ); } /** * Import a slider from previously exported data, creating a new post. * All values pass back through the standard sanitizers, so malformed * or malicious import files cannot inject unsafe data. * * @param array $data Decoded export data. * @return int|WP_Error New slider post ID, or WP_Error on failure. */ public static function import( $data ) { self::$last_import_media_report = array( 'reused' => 0, 'migrated' => 0, 'failed' => 0, ); if ( ! is_array( $data ) ) { return new WP_Error( 'developersd_ls_invalid_import', __( 'The import file is not a valid DeveloperSD Logo Slider export.', 'developersd-logo-slider' ) ); } if ( isset( $data['plugin'] ) && 'developersd-logo-slider' !== $data['plugin'] ) { return new WP_Error( 'developersd_ls_wrong_plugin', __( 'This JSON file was not created by DeveloperSD Logo Slider.', 'developersd-logo-slider' ) ); } $title = isset( $data['title'] ) ? sanitize_text_field( (string) $data['title'] ) : ''; if ( '' === trim( $title ) ) { return new WP_Error( 'developersd_ls_invalid_import', __( 'The import file is not a valid DeveloperSD Logo Slider export.', 'developersd-logo-slider' ) ); } if ( isset( $data['logos'] ) && ! is_array( $data['logos'] ) ) { return new WP_Error( 'developersd_ls_invalid_logos', __( 'The imported slider contains invalid logo data.', 'developersd-logo-slider' ) ); } if ( isset( $data['settings'] ) && ! is_array( $data['settings'] ) ) { return new WP_Error( 'developersd_ls_invalid_settings', __( 'The imported slider contains invalid settings data.', 'developersd-logo-slider' ) ); } $new_id = wp_insert_post( array( 'post_type' => DeveloperSD_LS_Post_Type::POST_TYPE, 'post_status' => 'publish', 'post_title' => $title, ), true ); if ( is_wp_error( $new_id ) ) { return $new_id; } $raw_logos = isset( $data['logos'] ) && is_array( $data['logos'] ) ? $data['logos'] : array(); $logos = array(); foreach ( $raw_logos as $raw_logo ) { if ( ! is_array( $raw_logo ) ) { ++self::$last_import_media_report['failed']; continue; } $resolved_id = self::resolve_import_image( $raw_logo, $new_id ); if ( ! $resolved_id ) { ++self::$last_import_media_report['failed']; continue; } $raw_logo['image_id'] = $resolved_id; $logos[] = $raw_logo; } $slider = new self( $new_id ); $slider->save_logos( $logos ); $slider->save_settings( isset( $data['settings'] ) ? $data['settings'] : array() ); $slider->set_status( 'inactive' ); return $new_id; } /** * Get media migration counters from the most recent import operation. * * @return array */ public static function get_last_import_media_report() { return self::$last_import_media_report; } /** * Resolve an imported logo to a local Media Library attachment. * * Same-site imports reuse the existing attachment. Cross-site imports first * look for an already migrated copy and otherwise sideload the source image * into the destination Media Library. * * @param array $logo Exported logo data. * @param int $parent_post_id Newly created slider post ID. * @return int Local attachment ID, or 0 on failure. */ protected static function resolve_import_image( $logo, $parent_post_id ) { $old_id = isset( $logo['image_id'] ) ? absint( $logo['image_id'] ) : 0; $source_url = isset( $logo['image_url'] ) ? esc_url_raw( trim( (string) $logo['image_url'] ) ) : ''; // Legacy exports did not include a source URL. Preserve their historical // same-site behavior by reusing the attachment ID when it is valid. if ( '' === $source_url ) { if ( $old_id && wp_attachment_is_image( $old_id ) ) { ++self::$last_import_media_report['reused']; return $old_id; } return 0; } // A numeric attachment ID is only safe to reuse when its actual URL is // the same URL recorded in the export. This prevents cross-site ID // collisions from silently selecting an unrelated Media Library image. if ( $old_id && wp_attachment_is_image( $old_id ) ) { $current_url = wp_get_attachment_url( $old_id ); if ( $current_url && self::urls_match( $current_url, $source_url ) ) { ++self::$last_import_media_report['reused']; return $old_id; } } // WordPress can resolve local upload URLs directly back to attachments. $local_id = attachment_url_to_postid( $source_url ); if ( $local_id && wp_attachment_is_image( $local_id ) ) { ++self::$last_import_media_report['reused']; return $local_id; } // Reuse a file that this importer previously migrated from the exact // same source URL. The lookup uses a compact option index rather than an // attachment meta query, avoiding an unindexed meta_key/meta_value scan. $previous_id = self::get_indexed_migrated_attachment( $source_url ); if ( $previous_id ) { ++self::$last_import_media_report['reused']; return $previous_id; } $new_id = self::sideload_import_image( $source_url, $logo, $parent_post_id ); if ( is_wp_error( $new_id ) || ! $new_id ) { return 0; } update_post_meta( $new_id, '_developersd_ls_source_url', $source_url ); self::index_migrated_attachment( $source_url, $new_id ); ++self::$last_import_media_report['migrated']; return absint( $new_id ); } /** * Find a previously migrated attachment without querying post meta. * * The option stores only a SHA-256 hash of the remote source URL mapped to * the local attachment ID. The attachment meta value is then verified by * direct post ID lookup before the image is reused. * * @param string $source_url Remote source image URL. * @return int Local attachment ID, or 0 when no valid indexed image exists. */ protected static function get_indexed_migrated_attachment( $source_url ) { $index = get_option( 'developersd_ls_migrated_media_index', array() ); if ( ! is_array( $index ) ) { return 0; } $key = hash( 'sha256', $source_url ); if ( empty( $index[ $key ] ) ) { return 0; } $attachment_id = absint( $index[ $key ] ); $stored_url = $attachment_id ? get_post_meta( $attachment_id, '_developersd_ls_source_url', true ) : ''; if ( $attachment_id && wp_attachment_is_image( $attachment_id ) && self::urls_match( $stored_url, $source_url ) ) { return $attachment_id; } // Remove stale index entries when the attachment was deleted or changed. unset( $index[ $key ] ); update_option( 'developersd_ls_migrated_media_index', $index, false ); return 0; } /** * Add a migrated image to the source-URL lookup index. * * @param string $source_url Remote source image URL. * @param int $attachment_id Local attachment ID. * @return void */ protected static function index_migrated_attachment( $source_url, $attachment_id ) { $attachment_id = absint( $attachment_id ); if ( ! $attachment_id || '' === $source_url ) { return; } $index = get_option( 'developersd_ls_migrated_media_index', array() ); if ( ! is_array( $index ) ) { $index = array(); } $index[ hash( 'sha256', $source_url ) ] = $attachment_id; update_option( 'developersd_ls_migrated_media_index', $index, false ); } /** * Download a remote exported logo and create a local Media Library item. * * @param string $source_url Public source image URL. * @param array $logo Exported logo data. * @param int $parent_post_id Slider post to associate with the upload. * @return int|WP_Error */ protected static function sideload_import_image( $source_url, $logo, $parent_post_id ) { if ( ! wp_http_validate_url( $source_url ) ) { return new WP_Error( 'developersd_ls_invalid_image_url', __( 'An imported logo has an invalid source image URL.', 'developersd-logo-slider' ) ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $tmp = download_url( $source_url, 30 ); if ( is_wp_error( $tmp ) ) { return $tmp; } $path = (string) wp_parse_url( $source_url, PHP_URL_PATH ); $filename = isset( $logo['image_filename'] ) ? sanitize_file_name( (string) $logo['image_filename'] ) : ''; if ( '' === $filename ) { $filename = sanitize_file_name( wp_basename( $path ) ); } if ( '' === $filename || '' === pathinfo( $filename, PATHINFO_EXTENSION ) ) { $image_mime = wp_get_image_mime( $tmp ); $extensions = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/avif' => 'avif', ); if ( isset( $extensions[ $image_mime ] ) ) { $base = '' !== $filename ? pathinfo( $filename, PATHINFO_FILENAME ) : 'developersd-logo-image'; $filename = sanitize_file_name( $base . '.' . $extensions[ $image_mime ] ); } } if ( '' === $filename || '' === pathinfo( $filename, PATHINFO_EXTENSION ) ) { wp_delete_file( $tmp ); return new WP_Error( 'developersd_ls_unknown_image_type', __( 'An imported logo image has an unsupported file type.', 'developersd-logo-slider' ) ); } $file_array = array( 'name' => $filename, 'tmp_name' => $tmp, ); $post_data = array(); if ( ! empty( $logo['title'] ) ) { $post_data['post_title'] = sanitize_text_field( (string) $logo['title'] ); } $attachment_id = media_handle_sideload( $file_array, $parent_post_id, null, $post_data ); if ( is_wp_error( $attachment_id ) ) { wp_delete_file( $tmp ); return $attachment_id; } if ( ! empty( $logo['alt'] ) ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( (string) $logo['alt'] ) ); } return absint( $attachment_id ); } /** * Compare attachment URLs while ignoring harmless trailing-slash differences. * * @param string $first First URL. * @param string $second Second URL. * @return bool */ protected static function urls_match( $first, $second ) { return untrailingslashit( esc_url_raw( (string) $first ) ) === untrailingslashit( esc_url_raw( (string) $second ) ); } /** * Fetch all sliders (any status) ordered by title. * * @return WP_Post[] */ public static function get_all() { return get_posts( array( 'post_type' => DeveloperSD_LS_Post_Type::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); } }