'ditscfco_options', 'cleanflow_activity_log' => 'ditscfco_activity_log', 'cleanflow_product_redirects' => 'ditscfco_product_redirects', ]; /** * Legacy product metadata written before the WordPress.org prefix review. */ private const LEGACY_META_KEY_MAP = [ '_cleanflow_out_of_stock_since' => '_ditscfco_out_of_stock_since', '_cleanflow_out_of_stock_since_source' => '_ditscfco_out_of_stock_since_source', '_cleanflow_last_stock_status' => '_ditscfco_last_stock_status', '_cleanflow_product_original_path' => '_ditscfco_product_original_path', ]; private const CLEANUP_TYPES = [ 'revisions', 'auto_drafts', 'spam_comments', 'trash', 'expired_transients', 'oos_products', 'media', ]; public static function init(): void { add_action( 'init', [ __CLASS__, 'maybe_migrate_legacy_storage' ], 1 ); add_action( 'admin_menu', [ __CLASS__, 'register_admin_page' ] ); add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_admin_assets' ] ); add_action( 'admin_init', [ __CLASS__, 'handle_admin_actions' ] ); add_action( 'template_redirect', [ __CLASS__, 'maybe_redirect_removed_product' ], 1 ); add_action( 'untrashed_post', [ __CLASS__, 'maybe_disable_redirect_on_untrash' ], 10, 1 ); add_action( 'woocommerce_product_set_stock_status', [ __CLASS__, 'track_stock_status_change' ], 10, 3 ); add_action( 'woocommerce_variation_set_stock_status', [ __CLASS__, 'track_variation_stock_status_change' ], 10, 3 ); add_action( 'woocommerce_product_object_updated_props', [ __CLASS__, 'track_updated_product_props' ], 10, 2 ); add_action( 'woocommerce_update_product', [ __CLASS__, 'sync_product_stock_marker' ], 10, 1 ); add_action( 'save_post_product', [ __CLASS__, 'sync_product_stock_marker_after_save' ], 20, 3 ); } public static function activate(): void { self::maybe_migrate_legacy_storage(); add_option( self::OPTION_KEY, self::default_options(), '', false ); add_option( self::LOG_OPTION_KEY, [], '', false ); add_option( self::REDIRECTS_OPTION_KEY, [], '', false ); self::seed_missing_out_of_stock_markers( 200 ); } public static function uninstall(): void { delete_option( self::OPTION_KEY ); delete_option( self::LOG_OPTION_KEY ); delete_option( self::REDIRECTS_OPTION_KEY ); delete_option( self::PREFIX_MIGRATION_OPTION_KEY ); foreach ( self::LEGACY_OPTION_MAP as $legacy_option_key => $current_option_key ) { delete_option( $legacy_option_key ); } delete_post_meta_by_key( self::META_OUT_OF_STOCK_SINCE ); delete_post_meta_by_key( self::META_OUT_OF_STOCK_SINCE_SOURCE ); delete_post_meta_by_key( self::META_LAST_STOCK_STATUS ); delete_post_meta_by_key( self::META_PRODUCT_ORIGINAL_PATH ); foreach ( self::LEGACY_META_KEY_MAP as $legacy_meta_key => $current_meta_key ) { delete_post_meta_by_key( $legacy_meta_key ); } } /** * Move data from the short legacy prefix to the unique current prefix once. * * When both keys exist, the current key wins. This avoids overwriting data * created after the updated version was installed. */ public static function maybe_migrate_legacy_storage(): void { if ( '1' === get_option( self::PREFIX_MIGRATION_OPTION_KEY ) ) { return; } foreach ( self::LEGACY_OPTION_MAP as $legacy_option_key => $current_option_key ) { $current_value = get_option( $current_option_key, null ); if ( null === $current_value ) { $legacy_value = get_option( $legacy_option_key, null ); if ( null !== $legacy_value ) { update_option( $current_option_key, $legacy_value, false ); } } delete_option( $legacy_option_key ); } self::migrate_legacy_post_meta(); update_option( self::PREFIX_MIGRATION_OPTION_KEY, '1', false ); } /** * Rename legacy post meta keys and clear post caches after the one-time move. */ private static function migrate_legacy_post_meta(): void { global $wpdb; foreach ( self::LEGACY_META_KEY_MAP as $legacy_meta_key => $current_meta_key ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration needs affected post IDs so their caches can be cleared immediately afterward. $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", $legacy_meta_key ) ); if ( empty( $post_ids ) ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- One-time migration keeps the value already stored under the current unique key. $wpdb->query( $wpdb->prepare( "DELETE legacy_meta FROM {$wpdb->postmeta} AS legacy_meta INNER JOIN {$wpdb->postmeta} AS current_meta ON current_meta.post_id = legacy_meta.post_id AND current_meta.meta_key = %s WHERE legacy_meta.meta_key = %s", $current_meta_key, $legacy_meta_key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- One-time migration renames remaining legacy metadata to the current unique key. $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} SET meta_key = %s WHERE meta_key = %s", $current_meta_key, $legacy_meta_key ) ); foreach ( array_unique( array_map( 'absint', $post_ids ) ) as $post_id ) { clean_post_cache( $post_id ); } } } public static function register_admin_page(): void { add_menu_page( __( 'CleanFlow', 'cleanflow-cleanup-optimization' ), __( 'CleanFlow', 'cleanflow-cleanup-optimization' ), 'manage_options', self::ADMIN_SLUG, [ __CLASS__, 'render_admin_page' ], 'dashicons-filter', 58 ); } public static function enqueue_admin_assets( string $hook_suffix ): void { if ( 'toplevel_page_' . self::ADMIN_SLUG !== $hook_suffix ) { return; } wp_enqueue_style( 'ditscfco-admin', plugin_dir_url( __FILE__ ) . 'assets/css/admin.css', [], self::VERSION ); } public static function handle_admin_actions(): void { if ( ! is_admin() ) { return; } $action = self::request_post_key( 'ditscfco_action' ); if ( ! in_array( $action, self::ADMIN_ACTIONS, true ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to run CleanFlow.', 'cleanflow-cleanup-optimization' ) ); } check_admin_referer( 'ditscfco_admin' ); if ( 'exclude_result_items' === $action ) { $result = self::save_exclusions_from_result_request(); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => 'exclusions_saved', 'ditscfco_tab' => 'cleanup' ] ); } if ( 'save_redirect' === $action ) { $result = self::save_redirect_from_request(); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => empty( $result['errors'] ) ? 'redirect_saved' : 'redirect_error', 'ditscfco_tab' => 'redirects' ] ); } if ( 'toggle_redirect' === $action ) { $result = self::toggle_redirect_from_request(); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => empty( $result['errors'] ) ? 'redirect_updated' : 'redirect_error', 'ditscfco_tab' => 'redirects' ] ); } if ( 'delete_redirect' === $action ) { $result = self::delete_redirect_from_request(); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => empty( $result['errors'] ) ? 'redirect_deleted' : 'redirect_error', 'ditscfco_tab' => 'redirects' ] ); } if ( 'save_settings' === $action ) { self::save_settings_from_request(); self::redirect_admin( [ 'ditscfco_notice' => 'settings_saved', 'ditscfco_tab' => 'settings' ] ); } if ( 'scan' === $action ) { $result = self::scan_cleanup_candidates(); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => 'scan_ready', 'ditscfco_tab' => 'cleanup' ] ); } if ( 'dry_run' === $action ) { self::save_posted_result_exclusions(); $result = self::run_cleanup( true, self::parse_redirect_map_from_request() ); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => 'dry_run_ready', 'ditscfco_tab' => 'cleanup' ] ); } if ( 'run_cleanup' === $action ) { if ( ! self::request_post_flag( 'ditscfco_confirm_cleanup' ) ) { self::store_admin_result( self::result_with_error( __( 'Cleanup was not started because the confirmation box was not checked.', 'cleanflow-cleanup-optimization' ) ) ); self::redirect_admin( [ 'ditscfco_notice' => 'confirm_required', 'ditscfco_tab' => 'cleanup' ] ); } self::save_posted_result_exclusions(); $result = self::run_cleanup( false, self::parse_redirect_map_from_request() ); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => 'cleanup_done', 'ditscfco_tab' => 'cleanup' ] ); } if ( 'preview_trashed_products' === $action ) { self::save_posted_result_exclusions(); $result = self::delete_trashed_products( true, self::parse_redirect_map_from_request() ); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => 'scan_ready', 'ditscfco_tab' => 'cleanup' ] ); } if ( 'delete_trashed_products' === $action ) { if ( ! self::request_post_flag( 'ditscfco_confirm_product_delete' ) ) { self::store_admin_result( self::result_with_error( __( 'Permanent product deletion was not started because the confirmation box was not checked.', 'cleanflow-cleanup-optimization' ) ) ); self::redirect_admin( [ 'ditscfco_notice' => 'confirm_required', 'ditscfco_tab' => 'cleanup' ] ); } self::save_posted_result_exclusions(); $result = self::delete_trashed_products( false, self::parse_redirect_map_from_request() ); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => 'cleanup_done', 'ditscfco_tab' => 'cleanup' ] ); } if ( 'refresh_product_tracking' === $action ) { $result = self::refresh_product_tracking(); self::store_admin_result( $result ); self::redirect_admin( [ 'ditscfco_notice' => 'tracking_refreshed', 'ditscfco_tab' => 'cleanup' ] ); } if ( 'export_log' === $action ) { self::export_log(); } if ( 'clear_log' === $action ) { if ( ! self::request_post_flag( 'ditscfco_confirm_clear_log' ) ) { self::store_admin_result( self::result_with_error( __( 'Cleanup history was not cleared because the confirmation box was not checked.', 'cleanflow-cleanup-optimization' ) ) ); self::redirect_admin( [ 'ditscfco_notice' => 'log_clear_confirm_required', 'ditscfco_tab' => 'log' ] ); } update_option( self::LOG_OPTION_KEY, [], false ); self::redirect_admin( [ 'ditscfco_notice' => 'log_cleared', 'ditscfco_tab' => 'log' ] ); } } private static function default_options(): array { return [ 'cleanup_types' => [ 'revisions' => 1, 'auto_drafts' => 1, 'spam_comments' => 1, 'trash' => 1, 'expired_transients' => 1, 'oos_products' => 1, 'media' => 1, ], 'revision_keep_count' => 5, 'revision_retention_days' => 0, 'auto_draft_retention_days' => 7, 'spam_retention_days' => 15, 'trash_retention_days' => 30, 'product_days_threshold' => 60, 'media_retention_days' => 30, 'batch_size' => 50, 'excluded_post_ids' => '', 'excluded_comment_ids' => '', 'excluded_product_ids' => '', 'excluded_product_category_ids' => '', 'excluded_attachment_ids' => '', ]; } private static function get_options(): array { $saved = get_option( self::OPTION_KEY, [] ); $saved = is_array( $saved ) ? $saved : []; $defaults = self::default_options(); $options = wp_parse_args( $saved, $defaults ); $saved_types = isset( $saved['cleanup_types'] ) && is_array( $saved['cleanup_types'] ) ? $saved['cleanup_types'] : []; $options['cleanup_types'] = wp_parse_args( $saved_types, $defaults['cleanup_types'] ); foreach ( self::CLEANUP_TYPES as $type ) { $options['cleanup_types'][ $type ] = empty( $options['cleanup_types'][ $type ] ) ? 0 : 1; } $options['revision_keep_count'] = min( 50, max( 0, (int) $options['revision_keep_count'] ) ); $options['revision_retention_days'] = min( 3650, max( 0, (int) $options['revision_retention_days'] ) ); $options['auto_draft_retention_days'] = min( 3650, max( 0, (int) $options['auto_draft_retention_days'] ) ); $options['spam_retention_days'] = min( 3650, max( 0, (int) $options['spam_retention_days'] ) ); $options['trash_retention_days'] = min( 3650, max( 0, (int) $options['trash_retention_days'] ) ); $options['product_days_threshold'] = min( 3650, max( 1, (int) $options['product_days_threshold'] ) ); $options['media_retention_days'] = min( 3650, max( 0, (int) $options['media_retention_days'] ) ); $options['batch_size'] = min( 500, max( 1, (int) $options['batch_size'] ) ); foreach ( [ 'excluded_post_ids', 'excluded_comment_ids', 'excluded_product_ids', 'excluded_product_category_ids', 'excluded_attachment_ids' ] as $key ) { $options[ $key ] = sanitize_text_field( (string) $options[ $key ] ); } return $options; } private static function save_settings_from_request(): void { $types = []; $posted_types = self::request_post_array( 'ditscfco_types' ); foreach ( self::CLEANUP_TYPES as $type ) { $types[ $type ] = isset( $posted_types[ $type ] ) && '1' === $posted_types[ $type ] ? 1 : 0; } $options = [ 'cleanup_types' => $types, 'revision_keep_count' => self::request_int( 'ditscfco_revision_keep_count', 5, 0, 50 ), 'revision_retention_days' => self::request_int( 'ditscfco_revision_retention_days', 0, 0, 3650 ), 'auto_draft_retention_days' => self::request_int( 'ditscfco_auto_draft_retention_days', 7, 0, 3650 ), 'spam_retention_days' => self::request_int( 'ditscfco_spam_retention_days', 15, 0, 3650 ), 'trash_retention_days' => self::request_int( 'ditscfco_trash_retention_days', 30, 0, 3650 ), 'product_days_threshold' => self::request_int( 'ditscfco_product_days_threshold', 60, 1, 3650 ), 'media_retention_days' => self::request_int( 'ditscfco_media_retention_days', 30, 0, 3650 ), 'batch_size' => self::request_int( 'ditscfco_batch_size', 50, 1, 500 ), 'excluded_post_ids' => self::request_post_text( 'ditscfco_excluded_post_ids' ), 'excluded_comment_ids' => self::request_post_text( 'ditscfco_excluded_comment_ids' ), 'excluded_product_ids' => self::request_post_text( 'ditscfco_excluded_product_ids' ), 'excluded_product_category_ids' => self::request_post_text( 'ditscfco_excluded_product_category_ids' ), 'excluded_attachment_ids' => self::request_post_text( 'ditscfco_excluded_attachment_ids' ), ]; update_option( self::OPTION_KEY, $options, false ); } private static function request_int( string $key, int $default, int $min, int $max ): int { $value = self::request_post_text( $key ); if ( '' === $value || ! preg_match( '/^-?\d+$/', $value ) ) { return $default; } return min( $max, max( $min, (int) $value ) ); } private static function request_post_text( string $key ): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This branch only validates the input type; the returned value is sanitized and mutation callers verify the nonce in handle_admin_actions(). if ( ! isset( $_POST[ $key ] ) || ! is_string( $_POST[ $key ] ) ) { return ''; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The returned value is immediately unslashed and sanitized; callers with mutations verify the nonce in handle_admin_actions(). return sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) ); } private static function request_post_key( string $key ): string { return sanitize_key( self::request_post_text( $key ) ); } private static function request_post_flag( string $key ): bool { return '1' === self::request_post_text( $key ); } private static function request_post_array( string $key ): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This branch only validates the input type; array values are sanitized before return and mutation callers verify the nonce in handle_admin_actions(). if ( ! isset( $_POST[ $key ] ) || ! is_array( $_POST[ $key ] ) ) { return []; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Every array value is unslashed and sanitized before this helper returns; callers with mutations verify the nonce in handle_admin_actions(). return map_deep( wp_unslash( $_POST[ $key ] ), 'sanitize_text_field' ); } private static function request_get_key( string $key ): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This branch only validates a read-only navigation or notice value before sanitization. if ( ! isset( $_GET[ $key ] ) || ! is_string( $_GET[ $key ] ) ) { return ''; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only navigation and notice values are immediately unslashed and sanitized. return sanitize_key( wp_unslash( $_GET[ $key ] ) ); } private static function request_server_text( string $key ): string { if ( ! isset( $_SERVER[ $key ] ) || ! is_string( $_SERVER[ $key ] ) ) { return ''; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The server value is immediately unslashed and sanitized before use. return sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); } private static function request_product_url_response_code( int $default ): int { $response_code = self::request_int( 'ditscfco_redirect_response', $default, 301, 410 ); return in_array( $response_code, self::PRODUCT_URL_RESPONSE_CODES, true ) ? $response_code : $default; } private static function save_exclusions_from_result_request(): array { $total = self::save_posted_result_exclusions(); $result = self::empty_result( __( 'Keep list updated', 'cleanflow-cleanup-optimization' ) ); $result['processed'] = $total; if ( $total > 0 ) { /* translators: %d: number of selected items added to the keep list. */ $result['notes'][] = sprintf( __( 'Added %d selected items to the keep list.', 'cleanflow-cleanup-optimization' ), $total ); } else { $result['notes'][] = __( 'No items were selected to keep.', 'cleanflow-cleanup-optimization' ); } return $result; } private static function save_posted_result_exclusions(): int { $raw_items = self::request_post_array( 'ditscfco_exclude_items' ); if ( empty( $raw_items ) ) { return 0; } $buckets = [ 'post' => [], 'comment' => [], 'product' => [], 'attachment' => [], ]; foreach ( $raw_items as $raw_item ) { if ( is_array( $raw_item ) ) { continue; } $item = (string) $raw_item; $parts = explode( ':', $item, 2 ); if ( 2 !== count( $parts ) ) { continue; } $bucket = sanitize_key( $parts[0] ); $id = absint( $parts[1] ); if ( $id > 0 && isset( $buckets[ $bucket ] ) ) { $buckets[ $bucket ][] = $id; } } $total = count( array_unique( $buckets['post'] ) ) + count( array_unique( $buckets['comment'] ) ) + count( array_unique( $buckets['product'] ) ) + count( array_unique( $buckets['attachment'] ) ); if ( $total <= 0 ) { return 0; } $options = self::get_options(); $options['excluded_post_ids'] = self::merge_id_list_text( (string) $options['excluded_post_ids'], $buckets['post'] ); $options['excluded_comment_ids'] = self::merge_id_list_text( (string) $options['excluded_comment_ids'], $buckets['comment'] ); $options['excluded_product_ids'] = self::merge_id_list_text( (string) $options['excluded_product_ids'], $buckets['product'] ); $options['excluded_attachment_ids'] = self::merge_id_list_text( (string) $options['excluded_attachment_ids'], $buckets['attachment'] ); update_option( self::OPTION_KEY, $options, false ); return $total; } private static function save_redirect_from_request(): array { $path = self::normalize_path( self::request_post_text( 'ditscfco_redirect_path' ) ); $target = esc_url_raw( self::request_post_text( 'ditscfco_redirect_target' ) ); $response_code = self::request_product_url_response_code( '' !== $target ? 301 : self::DEFAULT_REMOVED_PRODUCT_RESPONSE ); $result = self::empty_result( __( 'Old product URL rule', 'cleanflow-cleanup-optimization' ) ); if ( '' === $path || '/' === $path ) { $result['errors'][] = __( 'Old URL path is required.', 'cleanflow-cleanup-optimization' ); return $result; } if ( '' !== $target && ! self::is_safe_redirect_target( $target ) ) { $result['errors'][] = __( "The replacement URL must use this site's domain.", 'cleanflow-cleanup-optimization' ); return $result; } if ( 301 === $response_code && '' === $target ) { $result['errors'][] = __( 'A replacement URL is required for a 301 redirect.', 'cleanflow-cleanup-optimization' ); return $result; } $redirects = self::get_redirects(); $redirect = isset( $redirects[ $path ] ) && is_array( $redirects[ $path ] ) ? $redirects[ $path ] : []; $redirect['target'] = 301 === $response_code ? $target : ''; $redirect['response_code'] = $response_code; $redirect['created_at'] = empty( $redirect['created_at'] ) ? current_time( 'timestamp' ) : (int) $redirect['created_at']; $redirect['updated_at'] = current_time( 'timestamp' ); $redirect['disabled'] = empty( $redirect['disabled'] ) ? 0 : 1; $redirects[ $path ] = $redirect; self::trim_redirects( $redirects ); update_option( self::REDIRECTS_OPTION_KEY, $redirects, false ); $result['processed'] = 1; $result['notes'][] = __( 'Old product URL rule saved.', 'cleanflow-cleanup-optimization' ); return $result; } private static function is_safe_redirect_target( string $target ): bool { return '' === $target || $target === wp_validate_redirect( $target, '' ); } private static function toggle_redirect_from_request(): array { $path = self::normalize_path( self::request_post_text( 'ditscfco_redirect_path' ) ); $result = self::empty_result( __( 'Old product URL rule', 'cleanflow-cleanup-optimization' ) ); $redirects = self::get_redirects(); if ( '' === $path || empty( $redirects[ $path ] ) || ! is_array( $redirects[ $path ] ) ) { $result['errors'][] = __( 'Old product URL rule was not found.', 'cleanflow-cleanup-optimization' ); return $result; } $redirects[ $path ]['disabled'] = empty( $redirects[ $path ]['disabled'] ) ? 1 : 0; $redirects[ $path ]['updated_at'] = current_time( 'timestamp' ); update_option( self::REDIRECTS_OPTION_KEY, $redirects, false ); $result['processed'] = 1; $result['notes'][] = empty( $redirects[ $path ]['disabled'] ) ? __( 'Old product URL rule enabled.', 'cleanflow-cleanup-optimization' ) : __( 'Old product URL rule disabled.', 'cleanflow-cleanup-optimization' ); return $result; } private static function delete_redirect_from_request(): array { $path = self::normalize_path( self::request_post_text( 'ditscfco_redirect_path' ) ); $result = self::empty_result( __( 'Old product URL rule', 'cleanflow-cleanup-optimization' ) ); $redirects = self::get_redirects(); if ( '' === $path || empty( $redirects[ $path ] ) ) { $result['errors'][] = __( 'Old product URL rule was not found.', 'cleanflow-cleanup-optimization' ); return $result; } unset( $redirects[ $path ] ); update_option( self::REDIRECTS_OPTION_KEY, $redirects, false ); $result['processed'] = 1; $result['notes'][] = __( 'Old product URL rule deleted.', 'cleanflow-cleanup-optimization' ); return $result; } private static function redirect_admin( array $args = [] ): void { if ( empty( $args['ditscfco_tab'] ) ) { $args['ditscfco_tab'] = self::get_current_admin_tab(); } $args['ditscfco_tab'] = self::sanitize_admin_tab( (string) $args['ditscfco_tab'] ); $url = add_query_arg( array_merge( [ 'page' => self::ADMIN_SLUG ], $args ), admin_url( 'admin.php' ) ); wp_safe_redirect( $url ); exit; } private static function get_current_admin_tab(): string { return self::sanitize_admin_tab( self::request_get_key( 'ditscfco_tab' ) ); } private static function sanitize_admin_tab( string $tab ): string { $tab = sanitize_key( $tab ); return in_array( $tab, self::ADMIN_TABS, true ) ? $tab : self::DEFAULT_ADMIN_TAB; } private static function admin_tab_url( string $tab ): string { return add_query_arg( [ 'page' => self::ADMIN_SLUG, 'ditscfco_tab' => self::sanitize_admin_tab( $tab ), ], admin_url( 'admin.php' ) ); } private static function store_admin_result( array $result ): void { set_transient( self::RESULT_TRANSIENT_PREFIX . get_current_user_id(), $result, 10 * MINUTE_IN_SECONDS ); } private static function pull_admin_result(): ?array { $key = self::RESULT_TRANSIENT_PREFIX . get_current_user_id(); $result = get_transient( $key ); delete_transient( $key ); return is_array( $result ) ? $result : null; } public static function render_admin_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $options = self::get_options(); $result = self::pull_admin_result(); $log = self::get_log(); $product_stats = self::get_product_tracking_stats(); $active_tab = self::get_current_admin_tab(); ?>

__( 'Cleanup workflow', 'cleanflow-cleanup-optimization' ), 'settings' => __( 'Settings', 'cleanflow-cleanup-optimization' ), 'redirects' => __( 'Old product URLs', 'cleanflow-cleanup-optimization' ), 'log' => __( 'Cleanup history', 'cleanflow-cleanup-optimization' ), ]; ?>

__( 'Settings saved.', 'cleanflow-cleanup-optimization' ), 'scan_ready' => __( 'Scan finished.', 'cleanflow-cleanup-optimization' ), 'dry_run_ready' => __( 'Preview finished.', 'cleanflow-cleanup-optimization' ), 'cleanup_done' => __( 'Cleanup complete.', 'cleanflow-cleanup-optimization' ), 'confirm_required' => __( 'Confirmation is required before cleanup.', 'cleanflow-cleanup-optimization' ), 'tracking_refreshed' => __( 'Product tracking updated.', 'cleanflow-cleanup-optimization' ), 'log_cleared' => __( 'Cleanup history cleared.', 'cleanflow-cleanup-optimization' ), 'log_clear_confirm_required' => __( 'Confirmation is required before clearing cleanup history.', 'cleanflow-cleanup-optimization' ), 'exclusions_saved' => __( 'Selected items will be kept.', 'cleanflow-cleanup-optimization' ), 'redirect_saved' => __( 'Old product URL rule saved.', 'cleanflow-cleanup-optimization' ), 'redirect_updated' => __( 'Old product URL rule updated.', 'cleanflow-cleanup-optimization' ), 'redirect_deleted' => __( 'Old product URL rule deleted.', 'cleanflow-cleanup-optimization' ), 'redirect_error' => __( 'Old product URL rule could not be updated.', 'cleanflow-cleanup-optimization' ), ]; if ( empty( $messages[ $notice ] ) ) { return; } $warning_notices = [ 'confirm_required', 'log_clear_confirm_required', 'redirect_error' ]; $class = in_array( $notice, $warning_notices, true ) ? 'notice notice-warning' : 'notice notice-success'; printf( '

%2$s

', esc_attr( $class ), esc_html( $messages[ $notice ] ) ); } private static function render_result( array $result ): void { $items = isset( $result['items'] ) && is_array( $result['items'] ) ? $result['items'] : []; $errors = isset( $result['errors'] ) && is_array( $result['errors'] ) ? $result['errors'] : []; $notes = isset( $result['notes'] ) && is_array( $result['notes'] ) ? $result['notes'] : []; ?>

, , , .

-


(int) ( $left['created_at'] ?? 0 ); } ); ?>

$redirect ) : ?>

#

-'; return; } ?>




count( $result['items'] ) ) { $result['notes'][] = sprintf( /* translators: 1: visible rows, 2: total rows */ __( 'Showing the first %1$d items out of %2$d found.', 'cleanflow-cleanup-optimization' ), count( $result['items'] ), count( $candidates ) ); } return $result; } private static function run_cleanup( bool $dry_run, array $product_redirects ): array { $candidates = self::collect_cleanup_candidates(); $result = self::empty_result( $dry_run ? __( 'Preview results', 'cleanflow-cleanup-optimization' ) : __( 'Cleanup results', 'cleanflow-cleanup-optimization' ) ); $result['found'] = count( $candidates ); self::add_media_analysis_note( $result, $candidates ); foreach ( $candidates as $candidate ) { if ( $dry_run ) { $candidate['status'] = __( 'Would clean', 'cleanflow-cleanup-optimization' ); self::append_result_item( $result, $candidate ); continue; } $processed = self::execute_candidate( $candidate, $product_redirects, $result ); if ( $processed ) { $result['processed']++; } else { $result['skipped']++; } self::append_result_item( $result, $candidate ); } if ( ! $dry_run ) { self::add_log_entry( 'manual_cleanup', $result ); } return $result; } private static function delete_trashed_products( bool $dry_run, array $product_redirects ): array { $options = self::get_options(); if ( empty( $options['cleanup_types']['oos_products'] ) ) { $result = self::empty_result( __( 'Permanent product deletion', 'cleanflow-cleanup-optimization' ) ); $result['notes'][] = __( 'Turn on out-of-stock product cleanup in Settings before permanently deleting products.', 'cleanflow-cleanup-optimization' ); return $result; } $candidates = self::get_oos_product_candidates( true ); $result = self::empty_result( $dry_run ? __( 'Product deletion preview', 'cleanflow-cleanup-optimization' ) : __( 'Permanent product deletion', 'cleanflow-cleanup-optimization' ) ); $result['found'] = count( $candidates ); foreach ( $candidates as $candidate ) { if ( $dry_run ) { $candidate['status'] = __( 'Would permanently delete', 'cleanflow-cleanup-optimization' ); self::append_result_item( $result, $candidate ); continue; } $processed = self::execute_product_delete( $candidate, $product_redirects, $result ); if ( $processed ) { $result['processed']++; } else { $result['skipped']++; } self::append_result_item( $result, $candidate ); } if ( ! $dry_run ) { self::add_log_entry( 'permanent_product_delete', $result ); } return $result; } private static function collect_cleanup_candidates(): array { $options = self::get_options(); $types = $options['cleanup_types']; $candidates = []; if ( ! empty( $types['revisions'] ) ) { $candidates = array_merge( $candidates, self::get_revision_candidates() ); } if ( ! empty( $types['auto_drafts'] ) ) { $candidates = array_merge( $candidates, self::get_auto_draft_candidates() ); } if ( ! empty( $types['spam_comments'] ) ) { $candidates = array_merge( $candidates, self::get_spam_comment_candidates() ); } if ( ! empty( $types['trash'] ) ) { $candidates = array_merge( $candidates, self::get_trash_post_candidates() ); $candidates = array_merge( $candidates, self::get_trash_comment_candidates() ); } if ( ! empty( $types['expired_transients'] ) ) { $candidates = array_merge( $candidates, self::get_expired_transient_candidates() ); } if ( ! empty( $types['oos_products'] ) ) { self::seed_missing_out_of_stock_markers( min( 200, (int) $options['batch_size'] ) ); self::clear_restocked_markers( min( 200, (int) $options['batch_size'] ) ); $candidates = array_merge( $candidates, self::get_oos_product_candidates( false ) ); } if ( ! empty( $types['media'] ) ) { $candidates = array_merge( $candidates, self::get_media_candidates() ); } return $candidates; } private static function get_revision_candidates(): array { global $wpdb; $options = self::get_options(); $batch_size = (int) $options['batch_size']; $keep = (int) $options['revision_keep_count']; $excluded_posts = self::parse_id_list( (string) $options['excluded_post_ids'] ); $cleanup_post_types = self::get_cleanup_post_types(); $candidates = []; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup scan needs current revision counts before showing deletion candidates. $parent_ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_parent FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_parent > 0 GROUP BY post_parent HAVING COUNT(ID) > %d ORDER BY MAX(post_modified_gmt) DESC LIMIT %d", $keep, $batch_size ) ); foreach ( $parent_ids as $parent_id ) { $parent_id = (int) $parent_id; if ( in_array( $parent_id, $excluded_posts, true ) || ! in_array( get_post_type( $parent_id ), $cleanup_post_types, true ) ) { continue; } $revisions = wp_get_post_revisions( $parent_id, [ 'orderby' => 'date', 'order' => 'DESC', ] ); $position = 0; foreach ( $revisions as $revision ) { $position++; if ( $position <= $keep ) { continue; } if ( ! self::revision_is_old_enough( (int) $revision->ID ) ) { continue; } $candidates[] = [ 'type' => 'revision', 'type_label' => __( 'Saved revision', 'cleanflow-cleanup-optimization' ), 'id' => (int) $revision->ID, 'parent_id' => $parent_id, 'title' => sprintf( '%s (%s)', get_the_title( $parent_id ), get_post_type( $parent_id ) ), 'reason' => sprintf( /* translators: %d: number of kept revisions */ __( 'This is older than the %d newest revisions kept for this item.', 'cleanflow-cleanup-optimization' ), $keep ), 'action_label' => __( 'Delete this revision permanently', 'cleanflow-cleanup-optimization' ), ]; if ( count( $candidates ) >= $batch_size ) { break 2; } } } return $candidates; } private static function get_auto_draft_candidates(): array { $options = self::get_options(); $query = new WP_Query( [ 'post_type' => self::get_cleanup_post_types(), 'post_status' => 'auto-draft', 'posts_per_page' => (int) $options['batch_size'], 'fields' => 'ids', 'no_found_rows' => true, 'orderby' => 'modified', 'order' => 'ASC', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- User keep-list exclusions are intentionally applied to this bounded cleanup scan. 'post__not_in' => self::parse_id_list( (string) $options['excluded_post_ids'] ), 'date_query' => [ [ 'column' => 'post_modified_gmt', 'before' => self::cutoff_mysql_gmt( (int) $options['auto_draft_retention_days'] ), ], ], ] ); return self::post_candidates_from_ids( array_map( 'intval', $query->posts ), 'auto_draft', __( 'Auto-saved draft', 'cleanflow-cleanup-optimization' ), __( 'This auto-saved draft is older than your age limit.', 'cleanflow-cleanup-optimization' ), __( 'Delete this auto-saved draft permanently', 'cleanflow-cleanup-optimization' ) ); } private static function get_trash_post_candidates(): array { $options = self::get_options(); $query = new WP_Query( [ 'post_type' => self::get_cleanup_post_types(), 'post_status' => 'trash', 'posts_per_page' => (int) $options['batch_size'], 'fields' => 'ids', 'no_found_rows' => true, 'orderby' => 'modified', 'order' => 'ASC', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- User keep-list exclusions are intentionally applied to this bounded cleanup scan. 'post__not_in' => self::parse_id_list( (string) $options['excluded_post_ids'] ), 'date_query' => [ [ 'column' => 'post_modified_gmt', 'before' => self::cutoff_mysql_gmt( (int) $options['trash_retention_days'] ), ], ], ] ); return self::post_candidates_from_ids( array_map( 'intval', $query->posts ), 'trash_post', __( 'Item in Trash', 'cleanflow-cleanup-optimization' ), __( 'This item has been in Trash longer than your age limit.', 'cleanflow-cleanup-optimization' ), __( 'Delete this trashed item permanently', 'cleanflow-cleanup-optimization' ) ); } private static function post_candidates_from_ids( array $ids, string $type, string $type_label, string $reason, string $action_label ): array { $candidates = []; foreach ( $ids as $post_id ) { $candidates[] = [ 'type' => $type, 'type_label' => $type_label, 'id' => (int) $post_id, 'title' => get_the_title( (int) $post_id ), 'reason' => $reason, 'action_label' => $action_label, ]; } return $candidates; } private static function get_spam_comment_candidates(): array { $options = self::get_options(); $ids = get_comments( [ 'status' => 'spam', 'number' => (int) $options['batch_size'], 'fields' => 'ids', 'orderby' => 'comment_date_gmt', 'order' => 'ASC', 'comment__not_in' => self::parse_id_list( (string) $options['excluded_comment_ids'] ), 'date_query' => [ [ 'column' => 'comment_date_gmt', 'before' => self::cutoff_mysql_gmt( (int) $options['spam_retention_days'] ), ], ], ] ); return self::comment_candidates_from_ids( array_map( 'intval', $ids ), 'spam_comment', __( 'Spam comment', 'cleanflow-cleanup-optimization' ), __( 'This spam comment is older than your age limit.', 'cleanflow-cleanup-optimization' ), __( 'Delete this spam comment permanently', 'cleanflow-cleanup-optimization' ) ); } private static function get_trash_comment_candidates(): array { $options = self::get_options(); $ids = get_comments( [ 'status' => 'trash', 'number' => (int) $options['batch_size'], 'fields' => 'ids', 'orderby' => 'comment_date_gmt', 'order' => 'ASC', 'comment__not_in' => self::parse_id_list( (string) $options['excluded_comment_ids'] ), 'date_query' => [ [ 'column' => 'comment_date_gmt', 'before' => self::cutoff_mysql_gmt( (int) $options['trash_retention_days'] ), ], ], ] ); return self::comment_candidates_from_ids( array_map( 'intval', $ids ), 'trash_comment', __( 'Comment in Trash', 'cleanflow-cleanup-optimization' ), __( 'This comment has been in Trash longer than your age limit.', 'cleanflow-cleanup-optimization' ), __( 'Delete this trashed comment permanently', 'cleanflow-cleanup-optimization' ) ); } private static function comment_candidates_from_ids( array $ids, string $type, string $type_label, string $reason, string $action_label ): array { $candidates = []; foreach ( $ids as $comment_id ) { $comment = get_comment( (int) $comment_id ); $candidates[] = [ 'type' => $type, 'type_label' => $type_label, 'id' => (int) $comment_id, 'title' => $comment ? self::truncate_text( (string) $comment->comment_content, 90 ) : '', 'reason' => $reason, 'action_label' => $action_label, ]; } return $candidates; } private static function get_expired_transient_candidates(): array { global $wpdb; $options = self::get_options(); $limit = (int) $options['batch_size']; $now = time(); $prefixes = [ '_transient_timeout_', '_site_transient_timeout_' ]; $candidates = []; foreach ( $prefixes as $prefix ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Expired temporary data is stored in options and must be checked live before cleanup. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d ORDER BY option_id ASC LIMIT %d", $wpdb->esc_like( $prefix ) . '%', $now, max( 1, $limit - count( $candidates ) ) ), ARRAY_A ); foreach ( $rows as $row ) { $option_name = (string) $row['option_name']; $transient_name = substr( $option_name, strlen( $prefix ) ); $candidates[] = [ 'type' => 'expired_transient', 'type_label' => __( 'Expired temporary data', 'cleanflow-cleanup-optimization' ), 'id' => $option_name, 'title' => $transient_name, 'reason' => __( 'This temporary data has expired.', 'cleanflow-cleanup-optimization' ), 'action_label' => __( 'Delete this expired temporary data', 'cleanflow-cleanup-optimization' ), ]; if ( count( $candidates ) >= $limit ) { break 2; } } } return $candidates; } private static function get_media_candidates(): array { $options = self::get_options(); $batch_size = (int) $options['batch_size']; $excluded_attachments = self::parse_id_list( (string) $options['excluded_attachment_ids'] ); $query = new WP_Query( [ 'post_type' => 'attachment', 'post_status' => [ 'inherit', 'private' ], 'posts_per_page' => max( $batch_size, $batch_size * 3 ), 'fields' => 'ids', 'no_found_rows' => true, 'orderby' => 'date', 'order' => 'ASC', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- User keep-list exclusions are intentionally applied to this bounded media scan. 'post__not_in' => $excluded_attachments, 'date_query' => [ [ 'column' => 'post_date_gmt', 'before' => self::cutoff_mysql_gmt( (int) $options['media_retention_days'] ), ], ], ] ); $candidates = []; foreach ( $query->posts as $attachment_id ) { $attachment_id = (int) $attachment_id; if ( self::media_file_is_missing( $attachment_id ) ) { $candidates[] = self::media_candidate_from_attachment( $attachment_id, 'missing_media_file', __( 'This Media Library item points to a file that is no longer on the server.', 'cleanflow-cleanup-optimization' ), __( 'Remove this missing media record', 'cleanflow-cleanup-optimization' ) ); } elseif ( self::media_is_potentially_unused( $attachment_id ) ) { $candidates[] = self::media_candidate_from_attachment( $attachment_id, 'unused_media', __( 'This media item is unattached, and CleanFlow did not find it in supported places. Some builders, custom fields, or external tools may still use it.', 'cleanflow-cleanup-optimization' ), __( 'Delete this media item and its files', 'cleanflow-cleanup-optimization' ), [ __( 'No known links found', 'cleanflow-cleanup-optimization' ) ] ); } if ( count( $candidates ) >= $batch_size ) { break; } } return $candidates; } private static function media_candidate_from_attachment( int $attachment_id, string $type, string $reason, string $action_label, ?array $relations = null ): array { $file = (string) get_attached_file( $attachment_id ); $file_label = self::media_file_label( $file ); $title = get_the_title( $attachment_id ); if ( '' === $title ) { /* translators: %d: media attachment ID. */ $title = sprintf( __( 'Media item #%d', 'cleanflow-cleanup-optimization' ), $attachment_id ); } return [ 'type' => $type, 'type_label' => __( 'Media item', 'cleanflow-cleanup-optimization' ), 'id' => $attachment_id, 'title' => sprintf( '%1$s (%2$s)', $title, $file_label ), 'media_file' => $file_label, 'reason' => $reason, 'action_label' => $action_label, 'relations' => null === $relations ? self::get_media_candidate_relations( $attachment_id ) : $relations, ]; } private static function get_oos_product_candidates( bool $trashed_only ): array { $options = self::get_options(); $excluded_products = self::parse_id_list( (string) $options['excluded_product_ids'] ); $tax_query = [ 'relation' => 'AND', [ 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => [ 'simple' ], ], ]; $excluded_categories = self::parse_id_list( (string) $options['excluded_product_category_ids'] ); if ( $excluded_categories ) { $tax_query[] = [ 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => $excluded_categories, 'operator' => 'NOT IN', ]; } $query = new WP_Query( [ 'post_type' => 'product', 'post_status' => $trashed_only ? [ 'trash' ] : [ 'publish', 'private', 'draft', 'pending' ], 'posts_per_page' => (int) $options['batch_size'], 'fields' => 'ids', 'no_found_rows' => true, 'orderby' => 'meta_value_num', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Products are intentionally ordered by CleanFlow's indexed tracking meta in a bounded cleanup batch. 'meta_key' => self::META_OUT_OF_STOCK_SINCE, 'order' => 'ASC', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- User keep-list exclusions are intentionally applied to this bounded product scan. 'post__not_in' => $excluded_products, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Product type/category constraints are required to avoid cleaning unsupported product types. 'tax_query' => $tax_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Stock status and CleanFlow tracking meta are required cleanup criteria. 'meta_query' => [ [ 'key' => '_stock_status', 'value' => 'outofstock', 'compare' => '=', ], [ 'key' => self::META_OUT_OF_STOCK_SINCE, 'value' => self::product_cutoff_timestamp(), 'type' => 'NUMERIC', 'compare' => '<=', ], ], ] ); $candidates = []; foreach ( $query->posts as $product_id ) { $product_id = (int) $product_id; $since = (int) get_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE, true ); $candidates[] = [ 'type' => $trashed_only ? 'oos_product_delete' : 'oos_product_trash', 'type_label' => __( 'Out-of-stock product', 'cleanflow-cleanup-optimization' ), 'id' => $product_id, 'title' => get_the_title( $product_id ), 'reason' => sprintf( /* translators: 1: date, 2: days */ __( 'This simple product has been out of stock since %1$s, which is longer than your %2$d-day limit.', 'cleanflow-cleanup-optimization' ), self::format_timestamp( $since ), (int) $options['product_days_threshold'] ), 'action_label' => $trashed_only ? __( 'Permanently delete this product', 'cleanflow-cleanup-optimization' ) : __( 'Move this product to Trash', 'cleanflow-cleanup-optimization' ), 'relations' => self::get_product_relations( $product_id ), ]; } return $candidates; } private static function execute_candidate( array &$candidate, array $product_redirects, array &$result ): bool { switch ( $candidate['type'] ) { case 'revision': return self::execute_revision_delete( $candidate, $result ); case 'auto_draft': return self::execute_post_delete( $candidate, 'auto-draft', $result ); case 'trash_post': return self::execute_post_delete( $candidate, 'trash', $result ); case 'spam_comment': return self::execute_comment_delete( $candidate, 'spam', $result ); case 'trash_comment': return self::execute_comment_delete( $candidate, 'trash', $result ); case 'expired_transient': return self::execute_transient_delete( $candidate, $result ); case 'oos_product_trash': return self::execute_product_trash( $candidate, $product_redirects, $result ); case 'missing_media_file': case 'unused_media': return self::execute_media_delete( $candidate, $result ); } $candidate['status'] = __( 'Skipped: CleanFlow does not know how to clean this item', 'cleanflow-cleanup-optimization' ); return false; } private static function execute_revision_delete( array &$candidate, array &$result ): bool { $post_id = (int) $candidate['id']; if ( ! self::revision_is_deletable( $post_id ) ) { $candidate['status'] = __( 'Skipped: this revision no longer matches your settings', 'cleanflow-cleanup-optimization' ); return false; } $deleted = wp_delete_post_revision( $post_id ); if ( ! $deleted ) { $candidate['status'] = __( 'Error', 'cleanflow-cleanup-optimization' ); /* translators: %d: revision ID. */ $result['errors'][] = sprintf( __( 'Could not delete revision #%d.', 'cleanflow-cleanup-optimization' ), $post_id ); return false; } $candidate['status'] = __( 'Deleted', 'cleanflow-cleanup-optimization' ); return true; } private static function execute_post_delete( array &$candidate, string $expected_status, array &$result ): bool { $post_id = (int) $candidate['id']; if ( $expected_status !== get_post_status( $post_id ) ) { $candidate['status'] = __( 'Skipped: this item changed before cleanup', 'cleanflow-cleanup-optimization' ); return false; } if ( 'trash' === $expected_status && 'product' === get_post_type( $post_id ) ) { $candidate['status'] = __( 'Skipped: products require separate confirmation', 'cleanflow-cleanup-optimization' ); return false; } $deleted = wp_delete_post( $post_id, true ); if ( ! $deleted ) { $candidate['status'] = __( 'Error', 'cleanflow-cleanup-optimization' ); /* translators: %d: post or page ID. */ $result['errors'][] = sprintf( __( 'Could not delete item #%d.', 'cleanflow-cleanup-optimization' ), $post_id ); return false; } $candidate['status'] = __( 'Deleted', 'cleanflow-cleanup-optimization' ); return true; } private static function execute_comment_delete( array &$candidate, string $expected_status, array &$result ): bool { $comment_id = (int) $candidate['id']; $comment = get_comment( $comment_id ); if ( ! $comment || $expected_status !== wp_get_comment_status( $comment_id ) ) { $candidate['status'] = __( 'Skipped: this comment changed before cleanup', 'cleanflow-cleanup-optimization' ); return false; } $deleted = wp_delete_comment( $comment_id, true ); if ( ! $deleted ) { $candidate['status'] = __( 'Error', 'cleanflow-cleanup-optimization' ); /* translators: %d: comment ID. */ $result['errors'][] = sprintf( __( 'Could not delete comment #%d.', 'cleanflow-cleanup-optimization' ), $comment_id ); return false; } $candidate['status'] = __( 'Deleted', 'cleanflow-cleanup-optimization' ); return true; } private static function execute_transient_delete( array &$candidate, array &$result ): bool { $option_name = (string) $candidate['id']; if ( ! self::transient_is_expired( $option_name ) ) { $candidate['status'] = __( 'Skipped: this temporary data is no longer expired', 'cleanflow-cleanup-optimization' ); return false; } $deleted = self::delete_transient_by_timeout_option( $option_name ); if ( ! $deleted ) { $candidate['status'] = __( 'Skipped: this temporary data was already removed', 'cleanflow-cleanup-optimization' ); return false; } $candidate['status'] = __( 'Deleted', 'cleanflow-cleanup-optimization' ); return true; } private static function execute_product_trash( array &$candidate, array $product_redirects, array &$result ): bool { $product_id = (int) $candidate['id']; if ( ! self::product_is_eligible( $product_id, false ) ) { $candidate['status'] = __( 'Skipped: product no longer matches your settings', 'cleanflow-cleanup-optimization' ); return false; } self::remember_product_original_path( $product_id ); $redirect = self::maybe_add_product_redirect( $product_id, $product_redirects ); self::add_redirect_details_to_candidate( $candidate, $redirect ); $trashed = wp_trash_post( $product_id ); if ( ! $trashed ) { $candidate['status'] = __( 'Error', 'cleanflow-cleanup-optimization' ); /* translators: %d: product ID. */ $result['errors'][] = sprintf( __( 'Could not move product #%d to Trash.', 'cleanflow-cleanup-optimization' ), $product_id ); return false; } $candidate['status'] = __( 'Moved to Trash', 'cleanflow-cleanup-optimization' ); return true; } private static function execute_product_delete( array &$candidate, array $product_redirects, array &$result ): bool { $product_id = (int) $candidate['id']; if ( ! self::product_is_eligible( $product_id, true ) ) { $candidate['status'] = __( 'Skipped: product no longer matches your settings', 'cleanflow-cleanup-optimization' ); return false; } $redirect = self::maybe_add_product_redirect( $product_id, $product_redirects ); self::add_redirect_details_to_candidate( $candidate, $redirect ); $deleted = wp_delete_post( $product_id, true ); if ( ! $deleted ) { $candidate['status'] = __( 'Error', 'cleanflow-cleanup-optimization' ); /* translators: %d: product ID. */ $result['errors'][] = sprintf( __( 'Could not permanently delete product #%d.', 'cleanflow-cleanup-optimization' ), $product_id ); return false; } $candidate['status'] = __( 'Permanently deleted', 'cleanflow-cleanup-optimization' ); return true; } private static function execute_media_delete( array &$candidate, array &$result ): bool { $attachment_id = (int) $candidate['id']; $type = (string) ( $candidate['type'] ?? '' ); if ( ! self::media_candidate_is_deletable( $attachment_id, $type ) ) { $candidate['status'] = __( 'Skipped: media item no longer matches your settings', 'cleanflow-cleanup-optimization' ); return false; } $deleted = wp_delete_attachment( $attachment_id, true ); if ( ! $deleted ) { $candidate['status'] = __( 'Error', 'cleanflow-cleanup-optimization' ); /* translators: %d: media attachment ID. */ $result['errors'][] = sprintf( __( 'Could not delete media item #%d.', 'cleanflow-cleanup-optimization' ), $attachment_id ); return false; } $candidate['status'] = 'missing_media_file' === $type ? __( 'Missing media record removed', 'cleanflow-cleanup-optimization' ) : __( 'Media item deleted', 'cleanflow-cleanup-optimization' ); return true; } private static function media_candidate_is_deletable( int $attachment_id, string $type ): bool { $options = self::get_options(); if ( 'attachment' !== get_post_type( $attachment_id ) ) { return false; } if ( in_array( $attachment_id, self::parse_id_list( (string) $options['excluded_attachment_ids'] ), true ) ) { return false; } if ( ! self::media_is_old_enough( $attachment_id ) ) { return false; } if ( 'missing_media_file' === $type ) { return self::media_file_is_missing( $attachment_id ); } if ( 'unused_media' === $type ) { return self::media_is_potentially_unused( $attachment_id ); } return false; } private static function media_is_old_enough( int $attachment_id ): bool { $options = self::get_options(); $days = (int) $options['media_retention_days']; if ( $days <= 0 ) { return true; } $created_gmt = (string) get_post_field( 'post_date_gmt', $attachment_id ); if ( ! $created_gmt || '0000-00-00 00:00:00' === $created_gmt ) { return false; } $timestamp = strtotime( $created_gmt . ' UTC' ); return $timestamp && (int) $timestamp <= ( time() - ( $days * DAY_IN_SECONDS ) ); } private static function media_file_is_missing( int $attachment_id ): bool { $file = (string) get_attached_file( $attachment_id ); return '' === $file || ! file_exists( $file ); } private static function media_is_potentially_unused( int $attachment_id ): bool { if ( self::media_file_is_missing( $attachment_id ) ) { return false; } if ( (int) wp_get_post_parent_id( $attachment_id ) > 0 ) { return false; } return ! self::media_has_supported_usage( $attachment_id ); } private static function media_has_supported_usage( int $attachment_id ): bool { global $wpdb; $parent_id = (int) wp_get_post_parent_id( $attachment_id ); if ( $parent_id > 0 ) { $parent = get_post( $parent_id ); if ( $parent && ! in_array( (string) $parent->post_status, [ 'trash', 'auto-draft' ], true ) ) { return true; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Media usage scan must check featured-image postmeta before offering deletion. $thumbnail_user = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %s LIMIT 1", (string) $attachment_id ) ); if ( $thumbnail_user ) { return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Media usage scan must check WooCommerce gallery postmeta before offering deletion. $gallery_user = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_product_image_gallery' AND FIND_IN_SET( %d, meta_value ) LIMIT 1", $attachment_id ) ); if ( $gallery_user ) { return true; } foreach ( self::media_content_search_terms( $attachment_id ) as $term ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Media usage scan searches bounded content references for the current attachment. $content_user = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type <> 'attachment' AND post_status NOT IN ( 'trash', 'auto-draft' ) AND post_content LIKE %s LIMIT 1", '%' . $wpdb->esc_like( $term ) . '%' ) ); if ( $content_user ) { return true; } } $id_string = (string) $attachment_id; $serialized_int = '%i:' . $attachment_id . ';%'; $serialized_string = '%s:' . strlen( $id_string ) . ':"' . $id_string . '";%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Media usage scan checks common serialized/meta references before offering deletion. $meta_user = $wpdb->get_var( $wpdb->prepare( "SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.post_id <> %d AND p.post_status NOT IN ( 'trash', 'auto-draft' ) AND ( pm.meta_value = %s OR pm.meta_value LIKE %s OR pm.meta_value LIKE %s ) LIMIT 1", $attachment_id, $id_string, $serialized_int, $serialized_string ) ); return (bool) $meta_user; } private static function get_media_candidate_relations( int $attachment_id ): array { $relations = self::get_media_supported_relations( $attachment_id, 8 ); if ( $relations ) { return $relations; } return [ __( 'No known links found', 'cleanflow-cleanup-optimization' ) ]; } private static function get_media_supported_relations( int $attachment_id, int $limit = 8 ): array { global $wpdb; $limit = max( 1, $limit ); $relations = []; $parent_id = (int) wp_get_post_parent_id( $attachment_id ); if ( $parent_id > 0 ) { $parent = get_post( $parent_id ); if ( $parent && ! in_array( (string) $parent->post_status, [ 'trash', 'auto-draft' ], true ) ) { $relations[] = self::format_relation_post_label( $parent_id, /* translators: 1: post title, 2: post ID */ __( 'Attached to: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } } if ( count( $relations ) >= $limit ) { return self::unique_relation_labels( $relations, $limit ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Result details show current featured-image links for review. $thumbnail_users = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %s ORDER BY post_id ASC LIMIT %d", (string) $attachment_id, max( 1, $limit - count( $relations ) ) ) ); foreach ( $thumbnail_users as $post_id ) { $relations[] = self::format_relation_post_label( (int) $post_id, /* translators: 1: post title, 2: post ID */ __( 'Featured image for: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } if ( count( $relations ) >= $limit ) { return self::unique_relation_labels( $relations, $limit ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Result details show current WooCommerce gallery links for review. $gallery_users = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_product_image_gallery' AND FIND_IN_SET( %d, meta_value ) ORDER BY post_id ASC LIMIT %d", $attachment_id, max( 1, $limit - count( $relations ) ) ) ); foreach ( $gallery_users as $post_id ) { $relations[] = self::format_relation_post_label( (int) $post_id, /* translators: 1: post title, 2: post ID */ __( 'WooCommerce gallery for: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } foreach ( self::media_content_search_terms( $attachment_id ) as $term ) { if ( count( $relations ) >= $limit ) { return self::unique_relation_labels( $relations, $limit ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Result details show current content links for review. $content_users = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type <> 'attachment' AND post_status NOT IN ( 'trash', 'auto-draft' ) AND post_content LIKE %s ORDER BY ID ASC LIMIT %d", '%' . $wpdb->esc_like( $term ) . '%', max( 1, $limit - count( $relations ) ) ) ); foreach ( $content_users as $post_id ) { $relations[] = self::format_relation_post_label( (int) $post_id, /* translators: 1: post title, 2: post ID */ __( 'Content reference in: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } } if ( count( $relations ) >= $limit ) { return self::unique_relation_labels( $relations, $limit ); } $id_string = (string) $attachment_id; $serialized_int = '%i:' . $attachment_id . ';%'; $serialized_string = '%s:' . strlen( $id_string ) . ':"' . $id_string . '";%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Result details show current custom-field links for review. $meta_users = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.post_id <> %d AND p.post_status NOT IN ( 'trash', 'auto-draft' ) AND ( pm.meta_value = %s OR pm.meta_value LIKE %s OR pm.meta_value LIKE %s ) ORDER BY pm.post_id ASC LIMIT %d", $attachment_id, $id_string, $serialized_int, $serialized_string, max( 1, $limit - count( $relations ) ) ) ); foreach ( $meta_users as $post_id ) { $relations[] = self::format_relation_post_label( (int) $post_id, /* translators: 1: post title, 2: post ID */ __( 'Custom field reference in: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } return self::unique_relation_labels( $relations, $limit ); } private static function get_product_relations( int $product_id, int $limit = 8 ): array { $limit = max( 1, $limit ); $relations = []; $thumbnail_id = (int) get_post_thumbnail_id( $product_id ); if ( $thumbnail_id > 0 ) { $relations[] = self::format_relation_attachment_label( $thumbnail_id, /* translators: 1: attachment title, 2: attachment ID */ __( 'Featured image: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } $gallery_ids = self::parse_id_list( (string) get_post_meta( $product_id, '_product_image_gallery', true ) ); foreach ( $gallery_ids as $attachment_id ) { $relations[] = self::format_relation_attachment_label( $attachment_id, /* translators: 1: attachment title, 2: attachment ID */ __( 'Gallery image: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } if ( count( $relations ) < $limit ) { $attached_media = get_posts( [ 'post_type' => 'attachment', 'post_status' => 'any', 'post_parent' => $product_id, 'posts_per_page' => max( 1, $limit - count( $relations ) ), 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', ] ); foreach ( $attached_media as $attachment_id ) { $relations[] = self::format_relation_attachment_label( (int) $attachment_id, /* translators: 1: attachment title, 2: attachment ID */ __( 'Attached media: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } } $terms = self::product_reference_search_terms( $product_id ); if ( count( $relations ) < $limit ) { foreach ( self::get_post_ids_by_content_terms( $terms, $product_id, $limit - count( $relations ) ) as $post_id ) { $relations[] = self::format_relation_post_label( $post_id, /* translators: 1: post title, 2: post ID */ __( 'Linked from: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } } if ( count( $relations ) < $limit ) { foreach ( self::get_post_ids_by_meta_terms( $terms, $product_id, $limit - count( $relations ) ) as $post_id ) { $relations[] = self::format_relation_post_label( $post_id, /* translators: 1: post title, 2: post ID */ __( 'Custom field reference in: %1$s (#%2$d)', 'cleanflow-cleanup-optimization' ) ); } } $relations = self::unique_relation_labels( $relations, $limit ); if ( $relations ) { return $relations; } return [ __( 'No known links found', 'cleanflow-cleanup-optimization' ) ]; } private static function product_reference_search_terms( int $product_id ): array { $terms = [ 'product_page id="' . $product_id . '"', "product_page id='" . $product_id . "'", 'product id="' . $product_id . '"', "product id='" . $product_id . "'", 'ids="' . $product_id . '"', "ids='" . $product_id . "'", ]; $permalink = get_permalink( $product_id ); if ( is_string( $permalink ) && '' !== $permalink ) { $terms[] = $permalink; $path = wp_parse_url( $permalink, PHP_URL_PATH ); if ( is_string( $path ) && '' !== $path ) { $terms[] = ltrim( $path, '/' ); } } return array_values( array_unique( array_filter( array_map( static function ( $term ): string { return trim( (string) $term ); }, $terms ) ) ) ); } private static function get_post_ids_by_content_terms( array $terms, int $excluded_post_id, int $limit ): array { global $wpdb; $terms = self::normalize_relation_search_terms( $terms ); $limit = max( 1, $limit ); if ( empty( $terms ) ) { return []; } $ids = []; foreach ( $terms as $term ) { if ( count( $ids ) >= $limit ) { break; } $remaining = $limit - count( $ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Product/media relation scan searches bounded content references for review. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type <> 'attachment' AND post_status NOT IN ( 'trash', 'auto-draft' ) AND post_content LIKE %s AND ( %d = 0 OR ID <> %d ) ORDER BY ID ASC LIMIT %d", '%' . $wpdb->esc_like( $term ) . '%', $excluded_post_id, $excluded_post_id, $remaining ) ); foreach ( $rows as $post_id ) { $post_id = (int) $post_id; if ( $post_id > 0 && ! in_array( $post_id, $ids, true ) ) { $ids[] = $post_id; } } } return $ids; } private static function get_post_ids_by_meta_terms( array $terms, int $excluded_post_id, int $limit ): array { global $wpdb; $terms = self::normalize_relation_search_terms( $terms ); $limit = max( 1, $limit ); if ( empty( $terms ) ) { return []; } $ids = []; foreach ( $terms as $term ) { if ( count( $ids ) >= $limit ) { break; } $remaining = $limit - count( $ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Product/media relation scan searches bounded custom-field references for review. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type <> 'attachment' AND p.post_status NOT IN ( 'trash', 'auto-draft' ) AND pm.meta_value LIKE %s AND ( %d = 0 OR pm.post_id <> %d ) ORDER BY pm.post_id ASC LIMIT %d", '%' . $wpdb->esc_like( $term ) . '%', $excluded_post_id, $excluded_post_id, $remaining ) ); foreach ( $rows as $post_id ) { $post_id = (int) $post_id; if ( $post_id > 0 && ! in_array( $post_id, $ids, true ) ) { $ids[] = $post_id; } } } return $ids; } private static function normalize_relation_search_terms( array $terms ): array { return array_values( array_unique( array_filter( array_map( static function ( $term ): string { return trim( (string) $term ); }, $terms ) ) ) ); } private static function format_relation_post_label( int $post_id, string $template ): string { $title = get_the_title( $post_id ); $title = '' !== $title ? $title : __( 'Untitled', 'cleanflow-cleanup-optimization' ); return sprintf( $template, $title, $post_id ); } private static function format_relation_attachment_label( int $attachment_id, string $template ): string { $title = get_the_title( $attachment_id ); $title = '' !== $title ? $title : __( 'Untitled', 'cleanflow-cleanup-optimization' ); return sprintf( $template, $title, $attachment_id ); } private static function unique_relation_labels( array $relations, int $limit ): array { $limit = max( 1, $limit ); $unique = []; foreach ( $relations as $relation ) { $relation = trim( (string) $relation ); if ( '' === $relation || in_array( $relation, $unique, true ) ) { continue; } $unique[] = $relation; if ( count( $unique ) >= $limit ) { break; } } return $unique; } private static function media_content_search_terms( int $attachment_id ): array { $terms = [ 'wp-image-' . $attachment_id ]; $url = wp_get_attachment_url( $attachment_id ); if ( is_string( $url ) && '' !== $url ) { $terms[] = $url; $path = wp_parse_url( $url, PHP_URL_PATH ); if ( is_string( $path ) && '' !== $path ) { $terms[] = ltrim( $path, '/' ); } } $file = (string) get_attached_file( $attachment_id ); $uploads = wp_get_upload_dir(); $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : ''; if ( '' !== $file && '' !== $basedir ) { $normalized_file = str_replace( '\\', '/', $file ); $normalized_basedir = rtrim( str_replace( '\\', '/', $basedir ), '/' ); if ( 0 === strpos( $normalized_file, $normalized_basedir . '/' ) ) { $terms[] = ltrim( substr( $normalized_file, strlen( $normalized_basedir ) ), '/' ); } } return array_values( array_unique( array_filter( array_map( static function ( $term ): string { return trim( (string) $term ); }, $terms ), static function ( string $term ): bool { return strlen( $term ) > 3; } ) ) ); } private static function media_file_label( string $file ): string { if ( '' === $file ) { return __( 'No file path saved', 'cleanflow-cleanup-optimization' ); } $uploads = wp_get_upload_dir(); $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : ''; $normalized_file = str_replace( '\\', '/', $file ); $normalized_basedir = rtrim( str_replace( '\\', '/', $basedir ), '/' ); if ( '' !== $normalized_basedir && 0 === strpos( $normalized_file, $normalized_basedir . '/' ) ) { return ltrim( substr( $normalized_file, strlen( $normalized_basedir ) ), '/' ); } return basename( $file ); } private static function revision_is_deletable( int $revision_id ): bool { $revision = get_post( $revision_id ); if ( ! $revision || 'revision' !== $revision->post_type || (int) $revision->post_parent <= 0 ) { return false; } $options = self::get_options(); $keep = (int) $options['revision_keep_count']; $parent_id = (int) $revision->post_parent; if ( in_array( $parent_id, self::parse_id_list( (string) $options['excluded_post_ids'] ), true ) ) { return false; } if ( ! in_array( get_post_type( $parent_id ), self::get_cleanup_post_types(), true ) ) { return false; } if ( ! self::revision_is_old_enough( $revision_id ) ) { return false; } $revisions = wp_get_post_revisions( $parent_id, [ 'orderby' => 'date', 'order' => 'DESC', ] ); $position = 0; foreach ( $revisions as $revision_item ) { $position++; if ( (int) $revision_item->ID === $revision_id ) { return $position > $keep; } } return false; } private static function revision_is_old_enough( int $revision_id ): bool { $options = self::get_options(); $days = (int) $options['revision_retention_days']; if ( $days <= 0 ) { return true; } $modified = self::get_post_modified_timestamp( $revision_id ); return $modified > 0 && $modified <= ( time() - ( $days * DAY_IN_SECONDS ) ); } private static function transient_is_expired( string $option_name ): bool { $timeout = get_option( $option_name, null ); return null !== $timeout && (int) $timeout < time(); } private static function delete_transient_by_timeout_option( string $option_name ): bool { if ( 0 === strpos( $option_name, '_transient_timeout_' ) ) { $key = substr( $option_name, strlen( '_transient_timeout_' ) ); $deleted_timeout = delete_option( $option_name ); $deleted_value = delete_option( '_transient_' . $key ); delete_transient( $key ); return $deleted_timeout || $deleted_value; } if ( 0 === strpos( $option_name, '_site_transient_timeout_' ) ) { $key = substr( $option_name, strlen( '_site_transient_timeout_' ) ); $deleted_timeout = delete_option( $option_name ); $deleted_value = delete_option( '_site_transient_' . $key ); delete_site_transient( $key ); return $deleted_timeout || $deleted_value; } return false; } private static function product_is_eligible( int $product_id, bool $trashed_only ): bool { $options = self::get_options(); if ( 'product' !== get_post_type( $product_id ) ) { return false; } $status = get_post_status( $product_id ); $allowed_statuses = $trashed_only ? [ 'trash' ] : [ 'publish', 'private', 'draft', 'pending' ]; if ( ! in_array( $status, $allowed_statuses, true ) ) { return false; } if ( in_array( $product_id, self::parse_id_list( (string) $options['excluded_product_ids'] ), true ) ) { return false; } $excluded_categories = self::parse_id_list( (string) $options['excluded_product_category_ids'] ); if ( $excluded_categories && has_term( $excluded_categories, 'product_cat', $product_id ) ) { return false; } if ( ! self::product_is_simple( $product_id ) ) { return false; } $stock_status = self::get_current_product_stock_status( $product_id ); if ( 'outofstock' !== $stock_status ) { if ( '' !== $stock_status ) { self::sync_product_stock_marker( $product_id, $stock_status ); } return false; } if ( self::product_reports_in_stock( $product_id ) ) { self::sync_product_stock_marker( $product_id, 'instock' ); return false; } $since = (int) get_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE, true ); return $since > 0 && $since <= self::product_cutoff_timestamp(); } private static function product_is_simple( int $product_id ): bool { if ( function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); if ( ! $product ) { return false; } if ( $product && method_exists( $product, 'is_type' ) ) { return $product->is_type( 'simple' ); } } return has_term( 'simple', 'product_type', $product_id ); } private static function get_current_product_stock_status( int $product_id ): string { if ( function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); if ( $product && method_exists( $product, 'get_stock_status' ) ) { $stock_status = (string) $product->get_stock_status(); if ( '' !== $stock_status ) { return $stock_status; } } } return (string) get_post_meta( $product_id, '_stock_status', true ); } private static function product_reports_in_stock( int $product_id ): bool { if ( function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); if ( $product && method_exists( $product, 'is_in_stock' ) ) { return (bool) $product->is_in_stock(); } } return 'outofstock' !== (string) get_post_meta( $product_id, '_stock_status', true ); } private static function product_cutoff_timestamp(): int { $options = self::get_options(); return current_time( 'timestamp' ) - ( (int) $options['product_days_threshold'] * DAY_IN_SECONDS ); } private static function refresh_product_tracking(): array { $seeded = self::seed_missing_out_of_stock_markers( 1000 ); $cleared = self::clear_restocked_markers( 1000 ); $result = self::empty_result( __( 'Product tracking update', 'cleanflow-cleanup-optimization' ) ); $result['processed'] = $seeded + $cleared; $result['notes'][] = sprintf( /* translators: 1: seeded markers, 2: cleared markers */ __( 'Started tracking %1$d out-of-stock products and cleared tracking for %2$d products that are back in stock.', 'cleanflow-cleanup-optimization' ), $seeded, $cleared ); return $result; } private static function seed_missing_out_of_stock_markers( int $limit ): int { $query = new WP_Query( [ 'post_type' => 'product', 'post_status' => [ 'publish', 'private', 'draft', 'pending', 'trash' ], 'posts_per_page' => max( 1, $limit ), 'fields' => 'ids', 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Product type constraint is required before starting CleanFlow out-of-stock tracking. 'tax_query' => [ [ 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => [ 'simple' ], ], ], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Stock status and missing CleanFlow marker are required tracking criteria. 'meta_query' => [ [ 'key' => '_stock_status', 'value' => 'outofstock', 'compare' => '=', ], [ 'key' => self::META_OUT_OF_STOCK_SINCE, 'compare' => 'NOT EXISTS', ], ], ] ); $now = current_time( 'timestamp' ); foreach ( $query->posts as $product_id ) { update_post_meta( (int) $product_id, self::META_OUT_OF_STOCK_SINCE, $now ); update_post_meta( (int) $product_id, self::META_OUT_OF_STOCK_SINCE_SOURCE, 'first_check' ); update_post_meta( (int) $product_id, self::META_LAST_STOCK_STATUS, 'outofstock' ); } return count( $query->posts ); } private static function clear_restocked_markers( int $limit ): int { $query = new WP_Query( [ 'post_type' => 'product', 'post_status' => [ 'publish', 'private', 'draft', 'pending', 'trash' ], 'posts_per_page' => max( 1, $limit ), 'fields' => 'ids', 'no_found_rows' => true, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- CleanFlow marker lookup is required to clear tracking for restocked products. 'meta_query' => [ [ 'key' => self::META_OUT_OF_STOCK_SINCE, 'compare' => 'EXISTS', ], [ 'key' => '_stock_status', 'value' => 'outofstock', 'compare' => '!=', ], ], ] ); foreach ( $query->posts as $product_id ) { $product_id = (int) $product_id; delete_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE ); delete_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE_SOURCE ); update_post_meta( $product_id, self::META_LAST_STOCK_STATUS, get_post_meta( $product_id, '_stock_status', true ) ); self::disable_redirects_for_product( $product_id ); } return count( $query->posts ); } public static function track_stock_status_change( $product_id, $stock_status = null, $product = null ): void { self::sync_product_stock_marker( (int) $product_id, is_string( $stock_status ) ? $stock_status : null ); } public static function track_variation_stock_status_change( $variation_id, $stock_status = null, $variation = null ): void { $parent_id = wp_get_post_parent_id( (int) $variation_id ); if ( $parent_id > 0 ) { self::sync_product_stock_marker( $parent_id ); } } public static function track_updated_product_props( $product, $updated_props ): void { if ( ! is_object( $product ) || ! method_exists( $product, 'get_id' ) || ! is_array( $updated_props ) ) { return; } if ( in_array( 'stock_status', $updated_props, true ) ) { self::sync_product_stock_marker( (int) $product->get_id(), $product->get_stock_status() ); } } public static function sync_product_stock_marker_after_save( $post_id, $post, $update ): void { if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { return; } self::sync_product_stock_marker( (int) $post_id ); } public static function sync_product_stock_marker( $product_id, ?string $stock_status = null ): void { $product_id = (int) $product_id; if ( $product_id <= 0 || 'product' !== get_post_type( $product_id ) ) { return; } if ( null === $stock_status && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); $stock_status = $product ? $product->get_stock_status() : null; } if ( null === $stock_status ) { $stock_status = (string) get_post_meta( $product_id, '_stock_status', true ); } $previous_status = (string) get_post_meta( $product_id, self::META_LAST_STOCK_STATUS, true ); if ( 'outofstock' === $stock_status ) { if ( 'outofstock' !== $previous_status || ! get_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE, true ) ) { update_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE, current_time( 'timestamp' ) ); update_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE_SOURCE, 'tracked' ); } } else { delete_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE ); delete_post_meta( $product_id, self::META_OUT_OF_STOCK_SINCE_SOURCE ); self::disable_redirects_for_product( $product_id ); } update_post_meta( $product_id, self::META_LAST_STOCK_STATUS, $stock_status ); } public static function maybe_disable_redirect_on_untrash( $post_id ): void { $post_id = (int) $post_id; if ( $post_id > 0 && 'product' === get_post_type( $post_id ) && 'outofstock' !== (string) get_post_meta( $post_id, '_stock_status', true ) ) { self::disable_redirects_for_product( $post_id ); } } private static function maybe_add_product_redirect( int $product_id, array $product_redirects ): array { $path = (string) get_post_meta( $product_id, self::META_PRODUCT_ORIGINAL_PATH, true ); if ( '' === $path ) { $path = self::remember_product_original_path( $product_id ); } if ( '' === $path || '/' === $path ) { return []; } $target = empty( $product_redirects[ $product_id ] ) ? '' : esc_url_raw( (string) $product_redirects[ $product_id ] ); $redirects = self::get_redirects(); $existing = isset( $redirects[ $path ] ) && is_array( $redirects[ $path ] ) ? $redirects[ $path ] : []; $existing_target = (string) ( $existing['target'] ?? '' ); if ( '' === $target && '' !== $existing_target ) { return [ 'path' => $path, 'target' => $existing_target, 'response_code' => self::get_product_url_response_code( $existing ), 'status' => __( 'Existing old URL rule kept', 'cleanflow-cleanup-optimization' ), ]; } $response_code = '' !== $target ? 301 : self::DEFAULT_REMOVED_PRODUCT_RESPONSE; $redirects[ $path ] = [ 'product_id' => $product_id, 'product_title' => get_the_title( $product_id ), 'target' => $target, 'response_code' => $response_code, 'created_at' => empty( $existing['created_at'] ) ? current_time( 'timestamp' ) : (int) $existing['created_at'], 'updated_at' => current_time( 'timestamp' ), 'disabled' => 0, ]; self::trim_redirects( $redirects ); update_option( self::REDIRECTS_OPTION_KEY, $redirects, false ); return [ 'path' => $path, 'target' => $target, 'response_code' => $response_code, 'status' => 301 === $response_code ? __( 'Redirect rule saved', 'cleanflow-cleanup-optimization' ) : __( 'Removed-page rule saved', 'cleanflow-cleanup-optimization' ), ]; } private static function add_redirect_details_to_candidate( array &$candidate, array $redirect ): void { if ( $redirect ) { $candidate['redirect_path'] = (string) ( $redirect['path'] ?? '' ); $candidate['redirect_to'] = (string) ( $redirect['target'] ?? '' ); $candidate['redirect_response'] = (int) ( $redirect['response_code'] ?? self::DEFAULT_REMOVED_PRODUCT_RESPONSE ); $candidate['redirect_status'] = (string) ( $redirect['status'] ?? __( 'Redirect rule saved', 'cleanflow-cleanup-optimization' ) ); return; } $candidate['redirect_status'] = __( 'No replacement URL set', 'cleanflow-cleanup-optimization' ); } private static function remember_product_original_path( int $product_id ): string { $stored_path = (string) get_post_meta( $product_id, self::META_PRODUCT_ORIGINAL_PATH, true ); if ( '' !== $stored_path ) { return $stored_path; } $path = self::normalize_path( get_permalink( $product_id ) ); if ( '' !== $path && '/' !== $path ) { update_post_meta( $product_id, self::META_PRODUCT_ORIGINAL_PATH, $path ); } return $path; } public static function maybe_redirect_removed_product(): void { if ( ! is_404() ) { return; } $request_uri = self::request_server_text( 'REQUEST_URI' ); $path = self::normalize_path( $request_uri ); if ( '' === $path ) { return; } $redirects = self::get_redirects(); if ( empty( $redirects[ $path ] ) || ! is_array( $redirects[ $path ] ) || ! empty( $redirects[ $path ]['disabled'] ) ) { return; } $redirect = $redirects[ $path ]; $response_code = self::get_product_url_response_code( $redirect ); $target = wp_validate_redirect( esc_url_raw( (string) ( $redirect['target'] ?? '' ) ), '' ); if ( 301 === $response_code && '' !== $target ) { wp_safe_redirect( $target, 301 ); exit; } if ( 410 === $response_code ) { status_header( 410 ); nocache_headers(); self::render_removed_product_template(); exit; } status_header( 404 ); } private static function render_removed_product_template(): void { $template = get_404_template(); if ( $template ) { include $template; return; } wp_die( esc_html__( 'This product is no longer available.', 'cleanflow-cleanup-optimization' ), esc_html__( 'Product unavailable', 'cleanflow-cleanup-optimization' ), [ 'response' => 410 ] ); } private static function disable_redirects_for_product( int $product_id ): void { $redirects = self::get_redirects(); $changed = false; foreach ( $redirects as &$redirect ) { if ( (int) ( $redirect['product_id'] ?? 0 ) === $product_id && empty( $redirect['disabled'] ) ) { $redirect['disabled'] = 1; $changed = true; } } unset( $redirect ); if ( $changed ) { update_option( self::REDIRECTS_OPTION_KEY, $redirects, false ); } } private static function get_redirects(): array { $redirects = get_option( self::REDIRECTS_OPTION_KEY, [] ); return is_array( $redirects ) ? $redirects : []; } private static function get_product_url_response_code( array $redirect ): int { $response_code = isset( $redirect['response_code'] ) ? (int) $redirect['response_code'] : 0; if ( in_array( $response_code, self::PRODUCT_URL_RESPONSE_CODES, true ) ) { return $response_code; } return empty( $redirect['target'] ) ? self::DEFAULT_REMOVED_PRODUCT_RESPONSE : 301; } private static function get_product_url_response_label( array $redirect ): string { $response_code = self::get_product_url_response_code( $redirect ); if ( 301 === $response_code ) { return __( '301 redirect to replacement', 'cleanflow-cleanup-optimization' ); } if ( 404 === $response_code ) { return __( '404 not found page', 'cleanflow-cleanup-optimization' ); } return __( '410 removed page', 'cleanflow-cleanup-optimization' ); } private static function trim_redirects( array &$redirects ): void { if ( count( $redirects ) <= 2000 ) { return; } uasort( $redirects, static function ( array $left, array $right ): int { return (int) ( $right['created_at'] ?? 0 ) <=> (int) ( $left['created_at'] ?? 0 ); } ); $redirects = array_slice( $redirects, 0, 2000, true ); } private static function parse_redirect_map_from_request(): array { $map = []; $raw_targets = self::request_post_array( 'ditscfco_product_redirect_targets' ); foreach ( $raw_targets as $raw_product_id => $raw_url ) { if ( is_array( $raw_url ) ) { continue; } $product_id = absint( $raw_product_id ); $url = esc_url_raw( (string) $raw_url ); if ( $product_id > 0 && '' !== $url && self::is_safe_redirect_target( $url ) ) { $map[ $product_id ] = $url; } } $raw = self::request_post_text( 'ditscfco_product_redirects' ); if ( '' === $raw ) { return $map; } $lines = preg_split( '/\r\n|\r|\n/', $raw ); foreach ( $lines as $line ) { $line = trim( (string) $line ); if ( '' === $line ) { continue; } $parts = preg_split( '/\s*(?:=>|=|,|\s)\s*/', $line, 2 ); if ( ! is_array( $parts ) || count( $parts ) < 2 ) { continue; } $product_id = absint( $parts[0] ); $url = esc_url_raw( trim( (string) $parts[1] ) ); if ( $product_id > 0 && '' !== $url && self::is_safe_redirect_target( $url ) ) { $map[ $product_id ] = $url; } } return $map; } private static function get_product_tracking_stats(): array { return [ 'out_of_stock' => self::count_products( [ [ 'key' => '_stock_status', 'value' => 'outofstock', 'compare' => '=', ], ] ), 'tracked' => self::count_products( [ [ 'key' => '_stock_status', 'value' => 'outofstock', 'compare' => '=', ], [ 'key' => self::META_OUT_OF_STOCK_SINCE, 'compare' => 'EXISTS', ], ] ), 'eligible' => self::count_products( [ [ 'key' => '_stock_status', 'value' => 'outofstock', 'compare' => '=', ], [ 'key' => self::META_OUT_OF_STOCK_SINCE, 'value' => self::product_cutoff_timestamp(), 'type' => 'NUMERIC', 'compare' => '<=', ], ] ), ]; } private static function count_products( array $meta_query ): int { $query = new WP_Query( [ 'post_type' => 'product', 'post_status' => [ 'publish', 'private', 'draft', 'pending', 'trash' ], 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => false, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Product type constraint is required for the dashboard cleanup counters. 'tax_query' => [ [ 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => [ 'simple' ], ], ], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Dashboard counters intentionally count products by stock/tracking meta. 'meta_query' => $meta_query, ] ); return (int) $query->found_posts; } private static function add_log_entry( string $operation, array $result ): void { $log = self::get_log(); array_unshift( $log, [ 'time' => current_time( 'timestamp' ), 'operation' => $operation, 'found' => (int) ( $result['found'] ?? 0 ), 'processed' => (int) ( $result['processed'] ?? 0 ), 'skipped' => (int) ( $result['skipped'] ?? 0 ), 'errors' => isset( $result['errors'] ) && is_array( $result['errors'] ) ? count( $result['errors'] ) : 0, 'notes' => self::sanitize_log_strings( $result['notes'] ?? [] ), 'error_messages' => self::sanitize_log_strings( $result['errors'] ?? [] ), 'items' => self::sanitize_log_items( $result['items'] ?? [] ), ] ); update_option( self::LOG_OPTION_KEY, array_slice( $log, 0, 100 ), false ); } private static function get_log(): array { $log = get_option( self::LOG_OPTION_KEY, [] ); return is_array( $log ) ? $log : []; } private static function export_log(): void { $log = self::get_log(); $filename = 'ditscfco-activity-log-' . gmdate( 'Ymd-His' ) . '.csv'; nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); $output = fopen( 'php://output', 'w' ); if ( false !== $output ) { fputcsv( $output, [ __( 'Date', 'cleanflow-cleanup-optimization' ), __( 'Action', 'cleanflow-cleanup-optimization' ), __( 'Found', 'cleanflow-cleanup-optimization' ), __( 'Changed', 'cleanflow-cleanup-optimization' ), __( 'Skipped', 'cleanflow-cleanup-optimization' ), __( 'Issues', 'cleanflow-cleanup-optimization' ), __( 'Notes', 'cleanflow-cleanup-optimization' ), __( 'Issue details', 'cleanflow-cleanup-optimization' ), __( 'Items', 'cleanflow-cleanup-optimization' ), ] ); foreach ( $log as $entry ) { $entry = is_array( $entry ) ? $entry : []; fputcsv( $output, [ self::format_timestamp( (int) ( $entry['time'] ?? 0 ) ), (string) ( $entry['operation'] ?? '' ), (int) ( $entry['found'] ?? 0 ), (int) ( $entry['processed'] ?? 0 ), (int) ( $entry['skipped'] ?? 0 ), (int) ( $entry['errors'] ?? 0 ), self::json_for_csv( $entry['notes'] ?? [] ), self::json_for_csv( $entry['error_messages'] ?? [] ), self::json_for_csv( $entry['items'] ?? [] ), ] ); } } exit; } private static function json_for_csv( $value ): string { $json = wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); return is_string( $json ) ? $json : ''; } private static function sanitize_log_items( $items ): array { if ( ! is_array( $items ) ) { return []; } $allowed_keys = [ 'type', 'type_label', 'id', 'parent_id', 'title', 'reason', 'action_label', 'status', 'redirect_to', 'redirect_path', 'redirect_response', 'redirect_status', 'media_file', 'relations', ]; $clean_items = []; foreach ( array_slice( $items, 0, 200 ) as $item ) { if ( ! is_array( $item ) ) { continue; } $clean_item = []; foreach ( $allowed_keys as $key ) { if ( ! array_key_exists( $key, $item ) ) { continue; } if ( in_array( $key, [ 'id', 'parent_id', 'redirect_response' ], true ) ) { $clean_item[ $key ] = is_numeric( $item[ $key ] ) ? (int) $item[ $key ] : sanitize_text_field( (string) $item[ $key ] ); } elseif ( 'redirect_to' === $key ) { $clean_item[ $key ] = esc_url_raw( (string) $item[ $key ] ); } elseif ( 'relations' === $key ) { $clean_item[ $key ] = self::sanitize_log_strings( $item[ $key ] ); } else { $clean_item[ $key ] = sanitize_text_field( (string) $item[ $key ] ); } } if ( $clean_item ) { $clean_items[] = $clean_item; } } return $clean_items; } private static function sanitize_log_strings( $strings ): array { if ( ! is_array( $strings ) ) { return []; } return array_values( array_filter( array_map( static function ( $value ): string { return sanitize_text_field( (string) $value ); }, array_slice( $strings, 0, 50 ) ) ) ); } private static function empty_result( string $title ): array { return [ 'title' => $title, 'found' => 0, 'processed' => 0, 'skipped' => 0, 'items' => [], 'notes' => [], 'errors' => [], ]; } private static function result_with_error( string $message ): array { $result = self::empty_result( __( 'Action needed', 'cleanflow-cleanup-optimization' ) ); $result['errors'][] = $message; return $result; } private static function append_result_item( array &$result, array $item ): void { if ( count( $result['items'] ) >= 200 ) { return; } $result['items'][] = $item; } private static function add_media_analysis_note( array &$result, array $candidates ): void { foreach ( $candidates as $candidate ) { if ( is_array( $candidate ) && self::item_is_media_candidate( $candidate ) ) { $result['notes'][] = __( 'Media review checks known places only. Please review media items carefully before deleting them.', 'cleanflow-cleanup-optimization' ); return; } } } private static function item_is_media_candidate( array $item ): bool { return in_array( (string) ( $item['type'] ?? '' ), [ 'missing_media_file', 'unused_media' ], true ); } private static function result_has_excludable_items( array $items ): bool { foreach ( $items as $item ) { if ( is_array( $item ) && '' !== self::get_result_item_exclusion_payload( $item ) ) { return true; } } return false; } private static function result_has_redirect_info( array $items ): bool { foreach ( $items as $item ) { if ( is_array( $item ) && ( ! empty( $item['redirect_to'] ) || ! empty( $item['redirect_status'] ) || ! empty( $item['redirect_response'] ) ) ) { return true; } } return false; } private static function result_has_relations( array $items ): bool { foreach ( $items as $item ) { if ( is_array( $item ) && ! empty( $item['relations'] ) && is_array( $item['relations'] ) ) { return true; } } return false; } private static function render_item_relations( array $item ): void { $relations = isset( $item['relations'] ) && is_array( $item['relations'] ) ? $item['relations'] : []; $relations = self::unique_relation_labels( $relations, 8 ); if ( empty( $relations ) ) { echo '-'; return; } echo ''; } private static function get_result_redirect_response_label( array $item ): string { $response_code = absint( $item['redirect_response'] ?? 0 ); if ( ! in_array( $response_code, self::PRODUCT_URL_RESPONSE_CODES, true ) ) { return ''; } return self::get_product_url_response_label( [ 'target' => (string) ( $item['redirect_to'] ?? '' ), 'response_code' => $response_code, ] ); } private static function result_has_product_redirect_inputs( array $items ): bool { foreach ( $items as $item ) { if ( is_array( $item ) && self::item_accepts_product_redirect( $item ) ) { return true; } } return false; } private static function item_accepts_product_redirect( array $item ): bool { $type = (string) ( $item['type'] ?? '' ); $id = absint( $item['id'] ?? 0 ); return $id > 0 && in_array( $type, [ 'oos_product_trash', 'oos_product_delete' ], true ); } private static function get_result_item_exclusion_payload( array $item ): string { $type = (string) ( $item['type'] ?? '' ); $id = absint( $item['id'] ?? 0 ); switch ( $type ) { case 'revision': $parent_id = absint( $item['parent_id'] ?? 0 ); if ( $parent_id <= 0 && $id > 0 ) { $parent_id = (int) wp_get_post_parent_id( $id ); } return $parent_id > 0 ? 'post:' . $parent_id : ''; case 'auto_draft': case 'trash_post': return $id > 0 ? 'post:' . $id : ''; case 'spam_comment': case 'trash_comment': return $id > 0 ? 'comment:' . $id : ''; case 'oos_product_trash': case 'oos_product_delete': return $id > 0 ? 'product:' . $id : ''; case 'missing_media_file': case 'unused_media': return $id > 0 ? 'attachment:' . $id : ''; } return ''; } private static function parse_id_list( string $value ): array { $parts = preg_split( '/[\s,;]+/', $value ); $ids = []; foreach ( $parts as $part ) { $id = absint( $part ); if ( $id > 0 ) { $ids[] = $id; } } return array_values( array_unique( $ids ) ); } private static function merge_id_list_text( string $current, array $new_ids ): string { $ids = array_merge( self::parse_id_list( $current ), array_map( 'absint', $new_ids ) ); $ids = array_values( array_unique( array_filter( $ids ) ) ); sort( $ids, SORT_NUMERIC ); return implode( ', ', $ids ); } private static function get_cleanup_post_types(): array { $post_types = get_post_types( [ 'public' => true ], 'names' ); $post_types = is_array( $post_types ) ? $post_types : [ 'post', 'page' ]; $excluded = [ 'attachment', 'product', 'product_variation', 'shop_order', 'shop_coupon' ]; $post_types = array_values( array_diff( $post_types, $excluded ) ); return $post_types ? $post_types : [ 'post', 'page' ]; } private static function cutoff_mysql_gmt( int $days ): string { return gmdate( 'Y-m-d H:i:s', time() - ( max( 0, $days ) * DAY_IN_SECONDS ) ); } private static function get_post_modified_timestamp( int $post_id ): int { if ( function_exists( 'get_post_timestamp' ) ) { $timestamp = (int) get_post_timestamp( $post_id, 'modified' ); if ( $timestamp > 0 ) { return $timestamp; } } $modified_gmt = (string) get_post_field( 'post_modified_gmt', $post_id ); if ( $modified_gmt && '0000-00-00 00:00:00' !== $modified_gmt ) { $timestamp = strtotime( $modified_gmt . ' UTC' ); if ( $timestamp ) { return (int) $timestamp; } } return 0; } private static function format_timestamp( int $timestamp ): string { if ( $timestamp <= 0 ) { return '-'; } return wp_date( 'Y-m-d H:i:s', $timestamp ); } private static function truncate_text( string $text, int $length ): string { $text = trim( wp_strip_all_tags( $text ) ); if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { return mb_strlen( $text ) > $length ? mb_substr( $text, 0, $length - 3 ) . '...' : $text; } return strlen( $text ) > $length ? substr( $text, 0, $length - 3 ) . '...' : $text; } private static function normalize_path( string $url_or_path ): string { $path = wp_parse_url( $url_or_path, PHP_URL_PATH ); if ( ! is_string( $path ) || '' === $path ) { return ''; } $path = rawurldecode( $path ); $path = '/' . ltrim( $path, '/' ); $path = untrailingslashit( $path ); return strtolower( $path ); } } DITSCFCO_Plugin::init(); register_activation_hook( __FILE__, [ 'DITSCFCO_Plugin', 'activate' ] ); register_uninstall_hook( __FILE__, [ 'DITSCFCO_Plugin', 'uninstall' ] );