'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(
'',
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 );
}
);
?>
-';
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 '';
foreach ( $relations as $relation ) {
printf( '- %s
', esc_html( $relation ) );
}
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' ] );