• Resolved Hamed Sedaghat

    (@visualhost)


    Hi Iftiar,

    I have tested Post Media Cleanup 2.1.0 on a staging WordPress site, and I found an important issue with the media usage detection.

    I tested the following scenarios:

    1. Shared WooCommerce product image
      • An image is used by Product A.
      • The same image is also used by Product B.
      • Product A is permanently deleted.
      • Expected: The image must be preserved because Product B still uses it.
      • Actual: The image was deleted.
    2. Elementor
      • An image is used inside an Elementor page/template.
      • The original post containing the image is deleted.
      • Expected: The image must be preserved if Elementor still references it.
      • Actual: The image was deleted.
    3. Another post/article
      • An image is used in another WordPress post/article.
      • The original post is deleted.
      • Expected: The image must be preserved because another post still references it.
      • Actual: The image was deleted.

    So, in these three scenarios, the result was negative.

    However, I think the media reference detection should ideally consider a much broader range of real-world WordPress usage scenarios, including:

    • Featured images (_thumbnail_id)
    • WooCommerce product galleries (_product_image_gallery)
    • WooCommerce variation images
    • Images used in other products
    • Images embedded in post/page/CPT content
    • Classic Editor image URLs
    • Gutenberg image blocks
    • Gutenberg galleries
    • [gallery ids="..."] and similar gallery shortcodes
    • Elementor content and Elementor templates
    • Elementor _elementor_data, including escaped JSON URLs
    • ACF image fields
    • ACF gallery fields
    • ACF repeater/flexible content fields containing images
    • SCF image/gallery/repeater fields
    • Images stored in custom post meta
    • Real attachment URLs stored inside custom fields
    • Real attachment paths stored inside custom fields
    • URLs/paths nested inside JSON data
    • URLs/paths nested inside serialized PHP data
    • Images referenced by taxonomy/term metadata
    • Images referenced by widgets or theme options
    • Images referenced by menus or other WordPress configuration
    • PDF and other media files linked from content
    • Resized image URLs and registered WordPress image sizes
    • -scaled image variants
    • URL-encoded and JSON-escaped versions of URLs
    • Media referenced by other supported plugins or page builders
    • An attachment whose original parent post has already been deleted, but which is still genuinely referenced elsewhere

    At the same time, I believe the scanner should be conservative about what constitutes a reference.

    For example, simply finding a numeric value equal to an attachment ID should not necessarily mean that the media is being used. A generic custom field such as:

    image_id = 123

    or a JSON/serialized value containing:

    {"id":123}

    should not automatically be treated as a valid media reference unless the plugin knows that this field actually represents a media reference.

    The general rule I would suggest is:

    A confirmed real media reference → KEEP the attachment.
    No confirmed reference → candidate for deletion.

    Also, the scanner should ideally re-check the media immediately before deletion, because the site may change while a large scan is running.

    I understand that supporting every possible plugin and data structure is difficult, and I am not suggesting that every WordPress plugin in existence must be supported. I am mainly pointing out the reference types that I believe are important for a reliable general-purpose media cleanup tool.

    If you are interested, I would also be happy to share a small reference-detection implementation I developed for my own WordPress projects. It is designed around identifying actual media references rather than simply matching attachment IDs, and it may give you some ideas for improving the scanner.

    I would be glad to test a future version as well and provide detailed reproduction steps for any issues I find.

    Best regards,
    Hamed Sedaghat

Viewing 2 replies - 1 through 2 (of 2 total)
  • Plugin Author iftiarhossain

    (@iftiarhossain)

    Subject: Re: Post Media Cleanup — media detection issue

    Hi Hamed,

    Thanks for the detailed report and reproduction steps — really useful, and you’re right on all three counts.

    I’ve confirmed the root cause: the reference-detection logic only ever checked the featured-image meta and a literal URL match inside post content. That missed anything referenced by ID rather than by URL, which is exactly why a shared WooCommerce gallery image, an Elementor-only reference, and an image used in another post’s content could all get flagged and deleted.

    I’ve rebuilt the detection into a single shared checker used by both the real-time delete and the bulk scanner, so the two can’t disagree with each other. It now also checks:

    • WooCommerce product gallery (_product_image_gallery)
    • Elementor data (_elementor_data), including escaped JSON
    • ACF image/file/gallery/repeater/flexible-content fields
    • Gutenberg image/gallery blocks and [gallery ids="…"] / WPBakery shortcodes
    • Term meta (e.g. category thumbnails)
    • A few known site options (custom logo, site icon)

    On your conservative-detection point — agreed, and I built it that way: a bare numeric value that happens to match an attachment ID is never treated as a reference by itself. For ACF specifically, it only trusts a value once it’s confirmed against the actual field definition that the field is really an image/file/gallery type.

    I also added the re-check you suggested: the bulk scanner now re-verifies each attachment immediately before deleting it, rather than trusting the earlier scan result, so a long-running scan can’t delete something that became referenced in the meantime.

    This is going out as 2.2.0. I’d genuinely like to see that reference-detection implementation you mentioned — if you’re happy to share it, I’ll take a look and see if there’s anything worth folding in. And if you’re up for testing 2.2.0 against the same three scenarios (plus anything else you think of), I’d appreciate it — happy to turn around any follow-up quickly.

    Thanks again for taking the time to write this up properly.

    Best,
    Iftiar

    Thread Starter Hamed Sedaghat

    (@visualhost)

    Hi Iftiar,

    As promised, below is the reference implementation I mentioned.

    This is an earlier implementation I built for my own WordPress projects. The main idea behind it was to be conservative about what counts as a media reference:

    • A known, explicit media reference is treated as a valid reference.
    • A plain numeric value that happens to match an attachment ID is NOT considered a reference by itself.
    • URLs and actual file paths are treated as references when they are found in supported locations.
    • WooCommerce featured images and product galleries are checked explicitly.
    • Elementor _elementor_data is checked for actual image URLs/paths, including JSON-escaped URLs.
    • WordPress wp-image-ID references and [gallery ids="..."] are checked explicitly.
    • Generic post meta is only considered a reference when an actual URL/path is found; arbitrary numeric IDs are ignored.
    • The attachment’s own metadata and the post being deleted are excluded from the reference search.

    I originally built this mainly to solve false-positive media deletion in WooCommerce, Elementor, and custom-field-heavy sites.

    One important caveat: this implementation was designed for correctness of the reference logic rather than large-scale performance. In particular, the content/meta searches can become expensive when executed synchronously for many attachments on a large site. That performance concern was one of the reasons I started looking for a dedicated bulk-cleanup solution instead of continuing with this implementation.

    So please consider the code below as a reference for the detection logic and test cases, rather than as a production-ready scanner.

    I hope it gives you some useful ideas for 2.2.0 and future versions.

    <?php
    /**
    * Sedaghat Smart Media Delete
    *
    * Purpose:
    * When deleting a Post, delete its attached Attachments only when
    * no other valid and meaningful references to those Attachments exist.
    *
    * Scope:
    * - WordPress
    * - WooCommerce
    * - Woodmart
    * - Elementor / Elementor Pro
    * - Custom Fields
    *
    * Valid references:
    * - _thumbnail_id
    * - _product_image_gallery
    * - wp-image-ID in post_content
    * - [gallery ids="..."] in post_content
    * - Actual image URL
    * - Actual file path
    * - Actual URL / Path inside Elementor _elementor_data
    *
    * A plain numeric value inside a Custom Field is never considered
    * a valid media reference by itself.
    */

    add_action(
    'before_delete_post',
    'sedaghat_smart_delete_attached_media',
    10,
    2
    );

    function sedaghat_smart_delete_attached_media( $post_id, $post ) {

    if ( ! $post instanceof WP_Post ) {
    return;
    }

    if ( 'attachment' === $post->post_type ) {
    return;
    }

    $attachments = get_attached_media( '', $post_id );

    if ( empty( $attachments ) ) {
    return;
    }

    foreach ( $attachments as $attachment ) {

    $attachment_id = (int) $attachment->ID;

    if ( $attachment_id <= 0 ) {
    continue;
    }

    if ( ! sedaghat_attachment_has_other_references( $attachment_id, $post_id ) ) {

    wp_delete_attachment( $attachment_id, true );
    }
    }
    }


    /**
    * Check whether an Attachment has any valid reference elsewhere.
    */
    function sedaghat_attachment_has_other_references(
    $attachment_id,
    $deleting_post_id
    ) {

    $attachment_id = (int) $attachment_id;
    $deleting_post_id = (int) $deleting_post_id;

    if ( $attachment_id <= 0 ) {
    return false;
    }

    $references = sedaghat_get_attachment_reference_data( $attachment_id );

    /**
    * 1. Actual URL / Path inside post_content
    */
    if (
    sedaghat_attachment_used_in_post_content(
    $references,
    $deleting_post_id
    )
    ) {
    return true;
    }

    /**
    * 2. Known ID-based references inside post_content
    *
    * - wp-image-ID
    * - [gallery ids="..."]
    */
    if (
    sedaghat_attachment_id_used_in_post_content(
    $attachment_id,
    $deleting_post_id
    )
    ) {
    return true;
    }

    /**
    * 3. Elementor references
    *
    * Elementor stores page data in _elementor_data as JSON.
    * Image URLs are commonly stored as JSON-escaped URLs.
    */
    if (
    sedaghat_attachment_used_in_elementor_data(
    $references,
    $deleting_post_id
    )
    ) {
    return true;
    }

    /**
    * 4. Other valid postmeta references
    *
    * Only actual URLs / Paths are considered here.
    * A plain numeric value or raw ID is never considered a reference.
    */
    if (
    sedaghat_attachment_used_in_postmeta(
    $attachment_id,
    $references,
    $deleting_post_id
    )
    ) {
    return true;
    }

    return false;
    }


    /**
    * Build actual reference data for an Attachment.
    *
    * Includes:
    * - Original URL
    * - Original file path
    * - Original attachment metadata
    * - Generated image-size files
    */
    function sedaghat_get_attachment_reference_data( $attachment_id ) {

    $urls = array();
    $paths = array();

    $attachment_id = (int) $attachment_id;

    if ( $attachment_id <= 0 ) {
    return array(
    'urls' => array(),
    'paths' => array(),
    );
    }

    /**
    * Original Attachment URL
    */
    $attachment_url = wp_get_attachment_url( $attachment_id );

    if ( is_string( $attachment_url ) && '' !== $attachment_url ) {
    $urls[] = $attachment_url;
    }

    /**
    * Original file path
    */
    $attached_file = get_post_meta(
    $attachment_id,
    '_wp_attached_file',
    true
    );

    if ( is_string( $attached_file ) && '' !== $attached_file ) {

    $paths[] = $attached_file;

    $upload_dir = wp_get_upload_dir();

    if (
    ! empty( $upload_dir['baseurl'] ) &&
    ! empty( $upload_dir['basedir'] )
    ) {

    $paths[] = wp_normalize_path( $attached_file );

    $urls[] = trailingslashit( $upload_dir['baseurl'] )
    . ltrim(
    str_replace(
    '\\',
    '/',
    $attached_file
    ),
    '/'
    );
    }
    }

    /**
    * Attachment Metadata
    */
    $metadata = wp_get_attachment_metadata( $attachment_id );

    if ( is_array( $metadata ) ) {

    $upload_dir = wp_get_upload_dir();

    /**
    * Original file from metadata
    */
    if (
    ! empty( $metadata['file'] ) &&
    is_string( $metadata['file'] )
    ) {

    $metadata_file = wp_normalize_path(
    $metadata['file']
    );

    $paths[] = $metadata_file;

    if ( ! empty( $upload_dir['baseurl'] ) ) {

    $urls[] = trailingslashit(
    $upload_dir['baseurl']
    )
    . ltrim(
    $metadata_file,
    '/'
    );
    }
    }

    /**
    * Generated image-size files
    */
    if (
    ! empty( $metadata['sizes'] ) &&
    is_array( $metadata['sizes'] ) &&
    ! empty( $metadata['file'] )
    ) {

    $directory = dirname(
    wp_normalize_path(
    $metadata['file']
    )
    );

    if ( '.' === $directory ) {
    $directory = '';
    }

    foreach ( $metadata['sizes'] as $size ) {

    if (
    empty( $size['file'] ) ||
    ! is_string( $size['file'] )
    ) {
    continue;
    }

    $size_file = wp_normalize_path(
    $size['file']
    );

    $relative_path = '';

    if ( '' !== $directory ) {

    $relative_path = trailingslashit(
    $directory
    )
    . ltrim(
    $size_file,
    '/'
    );

    } else {

    $relative_path = $size_file;
    }

    $paths[] = $relative_path;

    if ( ! empty( $upload_dir['baseurl'] ) ) {

    $urls[] = trailingslashit(
    $upload_dir['baseurl']
    )
    . ltrim(
    $relative_path,
    '/'
    );
    }
    }
    }
    }

    /**
    * Normalize URLs
    */
    $urls = array_values(
    array_unique(
    array_filter(
    array_map(
    'esc_url_raw',
    $urls
    )
    )
    )
    );

    /**
    * Normalize Paths
    */
    $paths = array_values(
    array_unique(
    array_filter(
    array_map(
    function ( $path ) {

    return wp_normalize_path(
    str_replace(
    '\\',
    '/',
    $path
    )
    );
    },
    $paths
    )
    )
    )
    );

    return array(
    'urls' => $urls,
    'paths' => $paths,
    );
    }


    /**
    * Check for actual URL / Path references inside post_content.
    */
    function sedaghat_attachment_used_in_post_content(
    $references,
    $deleting_post_id
    ) {

    global $wpdb;

    $deleting_post_id = (int) $deleting_post_id;

    $search_values = array();

    if ( ! empty( $references['urls'] ) ) {

    $search_values = array_merge(
    $search_values,
    $references['urls']
    );
    }

    if ( ! empty( $references['paths'] ) ) {

    $search_values = array_merge(
    $search_values,
    $references['paths']
    );
    }

    if ( empty( $search_values ) ) {
    return false;
    }

    $conditions = array(
    'p.ID <> %d',
    "p.post_type <> 'attachment'",
    );

    $params = array(
    $deleting_post_id,
    );

    foreach ( $search_values as $value ) {

    if ( '' === $value ) {
    continue;
    }

    $conditions[] = 'p.post_content LIKE %s';

    $params[] = '%'
    . $wpdb->esc_like( $value )
    . '%';
    }

    if ( count( $conditions ) <= 2 ) {
    return false;
    }

    $sql = "
    SELECT p.ID
    FROM {$wpdb->posts} AS p
    WHERE "
    . implode(
    ' AND ',
    $conditions
    )
    . "
    LIMIT 1
    ";

    $found = $wpdb->get_var(
    $wpdb->prepare(
    $sql,
    $params
    )
    );

    return ! empty( $found );
    }


    /**
    * Check known ID-based references inside post_content.
    *
    * - wp-image-ID
    * - [gallery ids="..."]
    */
    function sedaghat_attachment_id_used_in_post_content(
    $attachment_id,
    $deleting_post_id
    ) {

    global $wpdb;

    $attachment_id = (int) $attachment_id;
    $deleting_post_id = (int) $deleting_post_id;

    if ( $attachment_id <= 0 ) {
    return false;
    }

    /**
    * wp-image-ID
    */
    $wp_image_class = 'wp-image-' . $attachment_id;

    $sql = "
    SELECT ID
    FROM {$wpdb->posts}
    WHERE ID <> %d
    AND post_type <> 'attachment'
    AND post_content LIKE %s
    LIMIT 1
    ";

    $found = $wpdb->get_var(
    $wpdb->prepare(
    $sql,
    $deleting_post_id,
    '%'
    . $wpdb->esc_like(
    $wp_image_class
    )
    . '%'
    )
    );

    if ( ! empty( $found ) ) {
    return true;
    }

    /**
    * [gallery ids="..."]
    */
    $gallery_candidates = $wpdb->get_results(
    $wpdb->prepare(
    "
    SELECT ID, post_content
    FROM {$wpdb->posts}
    WHERE ID <> %d
    AND post_type <> 'attachment'
    AND post_content LIKE %s
    ",
    $deleting_post_id,
    '%[gallery%'
    )
    );

    if ( empty( $gallery_candidates ) ) {
    return false;
    }

    foreach ( $gallery_candidates as $candidate ) {

    if ( empty( $candidate->post_content ) ) {
    continue;
    }

    if (
    ! preg_match_all(
    '/\[gallery\b[^\]]*\]/i',
    $candidate->post_content,
    $matches
    )
    ) {
    continue;
    }

    foreach ( $matches[0] as $shortcode ) {

    if (
    ! preg_match(
    '/\bids\s*=\s*(["\'])(.*?)\1/i',
    $shortcode,
    $id_match
    )
    ) {
    continue;
    }

    $ids = array_map(
    'absint',
    array_map(
    'trim',
    explode(
    ',',
    $id_match[2]
    )
    )
    );

    if (
    in_array(
    $attachment_id,
    $ids,
    true
    )
    ) {
    return true;
    }
    }
    }

    return false;
    }


    /**
    * Check Elementor references.
    *
    * Elementor stores page data in:
    *
    * _elementor_data
    *
    * as JSON.
    *
    * Example:
    *
    * "image":{
    * "url":"http:\/\/staging.localhost\/wp-content\/uploads\/2026\/09\/001.jpg",
    * "id":10019,
    * "source":"library"
    * }
    *
    * A numeric ID alone is NOT checked.
    * Only the actual Attachment URL / Path is checked.
    */
    function sedaghat_attachment_used_in_elementor_data(
    $references,
    $deleting_post_id
    ) {

    global $wpdb;

    $deleting_post_id = (int) $deleting_post_id;

    $search_values = array();

    /**
    * Actual URLs
    */
    if ( ! empty( $references['urls'] ) ) {

    foreach ( $references['urls'] as $url ) {

    if ( ! is_string( $url ) || '' === $url ) {
    continue;
    }

    /**
    * Normal URL
    */
    $search_values[] = $url;

    /**
    * JSON-escaped URL
    *
    * Example:
    * http://example.com/wp-content/...
    *
    * becomes:
    * http:\/\/example.com\/wp-content\/...
    */
    $search_values[] = str_replace(
    '/',
    '\\/',
    $url
    );
    }
    }

    /**
    * Actual Paths
    */
    if ( ! empty( $references['paths'] ) ) {

    foreach ( $references['paths'] as $path ) {

    if ( ! is_string( $path ) || '' === $path ) {
    continue;
    }

    /**
    * Normal path
    */
    $search_values[] = $path;

    /**
    * JSON-escaped path
    */
    $search_values[] = str_replace(
    '/',
    '\\/',
    $path
    );
    }
    }

    $search_values = array_values(
    array_unique(
    array_filter(
    $search_values
    )
    )
    );

    if ( empty( $search_values ) ) {
    return false;
    }

    $conditions = array();
    $params = array();

    foreach ( $search_values as $value ) {

    $conditions[] = 'pm.meta_value LIKE %s';

    $params[] = '%'
    . $wpdb->esc_like( $value )
    . '%';
    }

    /**
    * Only _elementor_data is checked here.
    *
    * This prevents arbitrary numeric IDs or generic Custom Fields
    * from being treated as media references.
    */
    $sql = "
    SELECT pm.meta_id
    FROM {$wpdb->postmeta} AS pm
    INNER JOIN {$wpdb->posts} AS p
    ON p.ID = pm.post_id
    WHERE pm.meta_key = '_elementor_data'
    AND (
    "
    . implode(
    ' OR ',
    $conditions
    )
    . "
    )
    AND pm.post_id <> %d
    AND pm.post_id <> %d
    AND NOT (
    p.post_type = 'revision'
    AND p.post_parent = %d
    )
    LIMIT 1
    ";

    $params[] = $deleting_post_id;
    $params[] = $deleting_post_id;
    $params[] = $deleting_post_id;

    $found = $wpdb->get_var(
    $wpdb->prepare(
    $sql,
    $params
    )
    );

    return ! empty( $found );
    }


    /**
    * Check other postmeta fields.
    *
    * Valid references:
    * - Actual URL
    * - Actual Path
    *
    * Invalid references:
    * - Numeric ID
    * - Numeric string
    * - JSON containing only a numeric ID
    * - Serialized array containing only a numeric ID
    */
    function sedaghat_attachment_used_in_postmeta(
    $attachment_id,
    $references,
    $deleting_post_id
    ) {

    global $wpdb;

    $attachment_id = (int) $attachment_id;
    $deleting_post_id = (int) $deleting_post_id;

    if ( $attachment_id <= 0 ) {
    return false;
    }

    /**
    * _thumbnail_id
    */
    $sql = "
    SELECT pm.meta_id
    FROM {$wpdb->postmeta} AS pm
    INNER JOIN {$wpdb->posts} AS p
    ON p.ID = pm.post_id
    WHERE pm.meta_key = '_thumbnail_id'
    AND pm.meta_value = %s
    AND pm.post_id <> %d
    AND pm.post_id <> %d
    AND NOT (
    p.post_type = 'revision'
    AND p.post_parent = %d
    )
    LIMIT 1
    ";

    $found = $wpdb->get_var(
    $wpdb->prepare(
    $sql,
    (string) $attachment_id,
    $attachment_id,
    $deleting_post_id,
    $deleting_post_id
    )
    );

    if ( ! empty( $found ) ) {
    return true;
    }

    /**
    * _product_image_gallery
    */
    $gallery_pattern = '(^|,)[[:space:]]*'
    . preg_quote(
    (string) $attachment_id,
    '/'
    )
    . '[[:space:]]*(,|$)';

    $sql = "
    SELECT pm.meta_id
    FROM {$wpdb->postmeta} AS pm
    INNER JOIN {$wpdb->posts} AS p
    ON p.ID = pm.post_id
    WHERE pm.meta_key = '_product_image_gallery'
    AND pm.meta_value REGEXP %s
    AND pm.post_id <> %d
    AND pm.post_id <> %d
    AND NOT (
    p.post_type = 'revision'
    AND p.post_parent = %d
    )
    LIMIT 1
    ";

    $found = $wpdb->get_var(
    $wpdb->prepare(
    $sql,
    $gallery_pattern,
    $attachment_id,
    $deleting_post_id,
    $deleting_post_id
    )
    );

    if ( ! empty( $found ) ) {
    return true;
    }

    /**
    * Actual URL / Path inside other postmeta fields
    */
    $search_values = array();

    if ( ! empty( $references['urls'] ) ) {

    $search_values = array_merge(
    $search_values,
    $references['urls']
    );
    }

    if ( ! empty( $references['paths'] ) ) {

    $search_values = array_merge(
    $search_values,
    $references['paths']
    );
    }

    if ( empty( $search_values ) ) {
    return false;
    }

    $conditions = array();
    $params = array();

    foreach ( $search_values as $value ) {

    if ( '' === $value ) {
    continue;
    }

    $conditions[] = 'pm.meta_value LIKE %s';

    $params[] = '%'
    . $wpdb->esc_like( $value )
    . '%';
    }

    if ( empty( $conditions ) ) {
    return false;
    }

    $where = "
    (
    "
    . implode(
    ' OR ',
    $conditions
    )
    . "
    )
    AND pm.post_id <> %d
    AND pm.post_id <> %d
    AND NOT (
    p.post_type = 'revision'
    AND p.post_parent = %d
    )
    ";

    $params[] = $attachment_id;
    $params[] = $deleting_post_id;
    $params[] = $deleting_post_id;

    $sql = "
    SELECT pm.meta_id
    FROM {$wpdb->postmeta} AS pm
    INNER JOIN {$wpdb->posts} AS p
    ON p.ID = pm.post_id
    WHERE "
    . $where
    . "
    LIMIT 1
    ";

    $found = $wpdb->get_var(
    $wpdb->prepare(
    $sql,
    $params
    )
    );

    return ! empty( $found );
    }

    Best,
    Hamed

Viewing 2 replies - 1 through 2 (of 2 total)

You must be logged in to reply to this topic.