Preview context.
*/
function ai4seo_get_attachment_editor_preview_context(
array $attribute_values,
array $active_fields,
bool $is_image,
string $attachment_url
): array {
$preview_values = array();
$attachment_url_path = wp_parse_url( $attachment_url, PHP_URL_PATH );
// Normalize every supported value once so all preview cards consume the same safe snapshot.
foreach ( AI4SEO_AVAILABLE_ATTACHMENT_ATTRIBUTE_IDENTIFIERS as $attribute_identifier ) {
$attribute_identifier = sanitize_key( $attribute_identifier );
$value = $attribute_values[ $attribute_identifier ] ?? '';
$preview_values[ $attribute_identifier ] = is_scalar( $value ) ? ai4seo_normalize_editor_input_value( (string) $value ) : '';
}
$attachment_quality_windows = array();
$fixed_attachment_quality_windows = AI4SEO_GENERATED_OUTPUT_QUALITY_WINDOWS['attachment_attributes'] ?? array();
foreach ( array( 'title', 'alt-text', 'caption', 'description' ) as $attachment_attribute_identifier ) {
$window = ai4seo_get_generation_length_quality_window( 'attachment_attributes', $attachment_attribute_identifier );
// Only Alt Text currently has a configurable stage, so retain declared targets for the remaining attributes.
if ( ! $window ) {
$window = $fixed_attachment_quality_windows[ $attachment_attribute_identifier ] ?? array();
}
$attachment_quality_windows[ sanitize_key( $attachment_attribute_identifier ) ] = array(
'min' => absint( $window['min-length'] ?? 0 ),
'max' => absint( $window['max-length'] ?? 0 ),
);
}
// Keep the client context presentation-only; form controls remain authoritative for saving.
return array(
'context' => 'attachment',
'values' => $preview_values,
'activeFields' => array_values( array_map( 'sanitize_key', $active_fields ) ),
'isImage' => $is_image,
'fileName' => sanitize_file_name( wp_basename( is_string( $attachment_url_path ) ? $attachment_url_path : '' ) ),
'qualityWindows' => $attachment_quality_windows,
);
}
// End attachment preview-context construction before the existing media utilities begin.
/**
* Format an attachment upload time from its WordPress date values.
*
* @param string $post_date_gmt Attachment post_date_gmt value.
* @param string $timezone Timezone identifier or 'auto'.
* @param string $post_date_local Optional attachment post_date fallback.
* @return string Formatted upload time, or an empty string when unavailable.
*/
function ai4seo_get_attachment_upload_time_display( string $post_date_gmt, string $timezone = 'auto', string $post_date_local = '' ): string {
$post_date_gmt = trim( $post_date_gmt );
$display_timezone = ai4seo_get_timezone( $timezone );
if ( ai4seo_is_valid_mysql_datetime( $post_date_gmt ) ) {
// Interpret the database field explicitly as UTC so display timezones are applied exactly once.
$upload_timestamp = strtotime( $post_date_gmt . ' UTC' );
} elseif ( '' === $post_date_gmt || '0000-00-00 00:00:00' === $post_date_gmt ) {
$post_date_local = trim( $post_date_local );
// Recover legacy rows only from an exact local WordPress date in the resolved site timezone.
if ( ! ai4seo_is_valid_mysql_datetime( $post_date_local ) ) {
return '';
}
$local_datetime = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $post_date_local, $display_timezone );
if ( false === $local_datetime || $post_date_local !== $local_datetime->format( 'Y-m-d H:i:s' ) ) {
return '';
}
$upload_timestamp = $local_datetime->getTimestamp();
} else {
// A malformed non-zero GMT value is corrupt data, not a missing value eligible for fallback.
return '';
}
if ( false === $upload_timestamp ) {
return '';
}
return ai4seo_format_unix_timestamp( $upload_timestamp, 'auto', 'auto', ' ', $display_timezone->getName() );
}
/**
* Return the normalized MIME type for an attachment post.
*
* @param int $attachment_post_id Attachment post ID.
* @return string|null Normalized MIME type, an empty string when unavailable, or null for an invalid post.
*/
function ai4seo_get_attachment_post_mime_type( $attachment_post_id ): ?string {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 373146404, 'Prevented loop', true );
return null;
}
$attachment_post = get_post( $attachment_post_id );
if ( ! $attachment_post || empty( $attachment_post->post_type ) ) {
return null;
}
// we found it already in the post_mime_type field.
if ( ! empty( $attachment_post->post_mime_type ) ) {
return ai4seo_normalize_mime_type_string( $attachment_post->post_mime_type );
}
// fallback: try to get it from the url.
$attachment_url = ai4seo_get_attachment_url( $attachment_post_id );
if ( ! $attachment_url ) {
return '';
}
return ai4seo_get_mime_type_from_url( $attachment_url );
}
/**
* Return the URL or GUID associated with an attachment-like post.
*
* @param int $attachment_post_id Attachment post ID.
* @return string|null Attachment URL, or null when the post is unavailable.
*/
function ai4seo_get_attachment_url( $attachment_post_id ): ?string {
$attachment_post = get_post( $attachment_post_id );
if ( ! $attachment_post || empty( $attachment_post->post_type ) ) {
return null;
}
// check if it's an attachment.
if ( 'attachment' === $attachment_post->post_type ) {
// check url of the attachment.
$ai4seo_attachment_url = wp_get_attachment_url( $attachment_post_id );
} else {
$ai4seo_attachment_url = get_the_guid( $attachment_post );
}
return $ai4seo_attachment_url;
}
/**
* Get the best available attachment source.
* Attention: Only use this function on a small number of attachments at once.
*
* Returns either a local file path or a reachable URL.
*
* @param int $attachment_post_id Attachment post ID.
* @return array|null
*/
function ai4seo_get_best_attachment_source( int $attachment_post_id ): ?array {
try {
$attachment_post = get_post( $attachment_post_id );
if ( ! $attachment_post || 'attachment' !== $attachment_post->post_type ) {
return null;
}
$attachment_path = get_attached_file( $attachment_post_id );
if ( $attachment_path && file_exists( $attachment_path ) && is_readable( $attachment_path ) ) {
return array(
'type' => 'path',
'source' => $attachment_path,
);
}
$attachment_url = wp_get_attachment_url( $attachment_post_id );
if ( ! $attachment_url || wp_http_validate_url( $attachment_url ) === false ) {
return null;
}
// Offloaded attachment URLs may be external, so probe them through WordPress's SSRF-safe transport.
$response = wp_safe_remote_head(
$attachment_url,
array(
'timeout' => 10,
'redirection' => 3,
)
);
$should_use_range_get = is_wp_error( $response );
if ( ! $should_use_range_get ) {
$head_response_code = (int) wp_remote_retrieve_response_code( $response );
$should_use_range_get = in_array( $head_response_code, array( 403, 405, 501 ), true );
}
if ( $should_use_range_get ) {
// Some storage providers reject HEAD; a one-byte safe GET checks reachability without fetching the file.
$response = wp_safe_remote_get(
$attachment_url,
array(
'timeout' => 10,
'redirection' => 3,
'stream' => false,
'limit_response_size' => 1,
'headers' => array(
'Range' => 'bytes=0-0',
),
)
);
}
if ( is_wp_error( $response ) ) {
return null;
}
$response_code = (int) wp_remote_retrieve_response_code( $response );
if ( $response_code >= 200 && $response_code < 400 ) {
return array(
'type' => 'url',
'source' => $attachment_url,
);
}
} catch ( Exception $e ) {
return null;
}
return null;
}
/**
* Function to read and analyze the attachment attributes coverage of the given attachment ids (post ids)
*
* @param int|array $attachment_post_ids The post ids of the attachments we want to analyze.
* @param bool|null $read_succeeded Receives whether every authoritative read succeeded.
* @param array|null $analysis_issues Opt-in analysis diagnostics; null preserves strict callers.
* @return array
*/
function ai4seo_read_and_analyse_attachment_attributes_coverage( $attachment_post_ids, ?bool &$read_succeeded = null, ?array &$analysis_issues = null ): array {
global $wpdb;
$read_succeeded = false;
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 898193802, 'Prevented loop', true );
return array();
}
// allow single ID.
if ( ! is_array( $attachment_post_ids ) ) {
$attachment_post_ids = array( $attachment_post_ids );
}
// initial coverage structure.
$attachment_attributes_coverage = ai4seo_create_empty_attachment_attributes_coverage_array( $attachment_post_ids );
// bail on empty or invalid IDs.
if ( empty( $attachment_post_ids ) ) {
$read_succeeded = true;
return $attachment_attributes_coverage;
}
foreach ( $attachment_post_ids as $attachment_post_id ) {
if ( ! is_numeric( $attachment_post_id ) ) {
return $attachment_attributes_coverage;
}
}
// normalize IDs.
$attachment_post_ids = array_map( 'absint', $attachment_post_ids );
// active attributes.
$active_attachment_attributes = ai4seo_get_active_attachment_attributes();
if ( ! $active_attachment_attributes ) {
$read_succeeded = true;
return $attachment_attributes_coverage;
}
// chunk IDs to avoid huge IN-lists.
$database_chunk_size = ai4seo_get_database_chunk_size();
$attachment_post_ids_chunks = array_chunk( $attachment_post_ids, $database_chunk_size );
$seen_alt_values = array();
// --- TITLE / CAPTION / DESCRIPTION / GUID ----------------------------------------- \\
if ( array_intersect( array( 'title', 'caption', 'description' ), $active_attachment_attributes ) ) {
foreach ( $attachment_post_ids_chunks as $this_attachment_post_ids_chunk ) {
if ( empty( $this_attachment_post_ids_chunk ) ) {
continue;
}
$attachment_posts_query = ai4seo_prepare_database_query(
'SELECT ID, post_title, post_excerpt, post_content, guid
FROM {{posts_table}}
WHERE ID IN ({{post_ids}})',
array(
'posts_table' => ai4seo_database_identifier_binding( 'table.posts' ),
'post_ids' => ai4seo_database_list_binding( '%d', array_values( $this_attachment_post_ids_chunk ) ),
)
);
if ( false === $attachment_posts_query ) {
ai4seo_debug_message( 984321663, 'Could not prepare the attachment coverage query.', true );
return array();
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed query compiler prepared the bounded attachment-ID batch; coverage must reflect current post fields before generation decisions.
$attachment_posts = $wpdb->get_results( $attachment_posts_query, ARRAY_A );
// on error.
if ( $wpdb->last_error ) {
ai4seo_debug_message( 984321663, 'Database error: ' . $wpdb->last_error, true );
return array();
}
if ( ! is_array( $attachment_posts ) ) {
return array();
}
if ( ! $attachment_posts ) {
continue;
}
foreach ( $attachment_posts as $this_attachment_post ) {
if ( ! is_array( $this_attachment_post ) || ! array_key_exists( 'ID', $this_attachment_post ) ) {
return array();
}
$this_attachment_post_id = ai4seo_normalize_database_id( $this_attachment_post['ID'] );
if ( false === $this_attachment_post_id || ! isset( $attachment_attributes_coverage[ $this_attachment_post_id ] ) ) {
return array();
}
if ( in_array( 'title', $active_attachment_attributes, true ) ) {
if ( ! array_key_exists( 'post_title', $this_attachment_post ) || ( null !== $analysis_issues && ! is_string( $this_attachment_post['post_title'] ) ) ) {
if ( null !== $analysis_issues ) {
ai4seo_add_posts_table_analysis_issue( $analysis_issues, $this_attachment_post_id, 'post_title' );
continue;
}
return array();
}
$attachment_attributes_coverage[ $this_attachment_post_id ]['title'] = $this_attachment_post['post_title'];
}
if ( in_array( 'caption', $active_attachment_attributes, true ) ) {
if ( ! array_key_exists( 'post_excerpt', $this_attachment_post ) || ( null !== $analysis_issues && ! is_string( $this_attachment_post['post_excerpt'] ) ) ) {
if ( null !== $analysis_issues ) {
ai4seo_add_posts_table_analysis_issue( $analysis_issues, $this_attachment_post_id, 'post_excerpt' );
continue;
}
return array();
}
$attachment_attributes_coverage[ $this_attachment_post_id ]['caption'] = $this_attachment_post['post_excerpt'];
}
if ( in_array( 'description', $active_attachment_attributes, true ) ) {
if ( ! array_key_exists( 'post_content', $this_attachment_post ) || ( null !== $analysis_issues && ! is_string( $this_attachment_post['post_content'] ) ) ) {
if ( null !== $analysis_issues ) {
ai4seo_add_posts_table_analysis_issue( $analysis_issues, $this_attachment_post_id, 'post_content' );
continue;
}
return array();
}
$attachment_attributes_coverage[ $this_attachment_post_id ]['description'] = $this_attachment_post['post_content'];
}
}
}
}
// --- ALT TEXT --------------------------------------------------------------------- \\
if ( in_array( 'alt-text', $active_attachment_attributes, true ) ) {
foreach ( $attachment_post_ids_chunks as $this_post_ids_chunk ) {
if ( empty( $this_post_ids_chunk ) ) {
continue;
}
$attachment_postmeta_query = ai4seo_prepare_database_query(
'SELECT post_id, meta_value FROM {{postmeta_table}} WHERE meta_key = {{meta_key}} AND post_id IN ({{post_ids}})',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'meta_key' => ai4seo_database_scalar_binding( '%s', '_wp_attachment_image_alt' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The indexed exact key is paired with a bounded attachment-ID batch.
'post_ids' => ai4seo_database_list_binding( '%d', array_values( $this_post_ids_chunk ) ),
)
);
if ( false === $attachment_postmeta_query ) {
ai4seo_debug_message( 984321664, 'Could not prepare the attachment alt-text coverage query.', true );
return array();
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed query compiler prepared the bounded key/attachment-ID batch; coverage must reflect current alt text before generation decisions.
$this_attachment_postmetas = $wpdb->get_results( $attachment_postmeta_query, ARRAY_A );
// on error.
if ( $wpdb->last_error ) {
ai4seo_debug_message( 984321664, 'Database error: ' . $wpdb->last_error, true );
return array();
}
if ( ! is_array( $this_attachment_postmetas ) ) {
return array();
}
if ( empty( $this_attachment_postmetas ) ) {
continue;
}
foreach ( $this_attachment_postmetas as $this_attachment_postmeta ) {
if ( ! is_array( $this_attachment_postmeta )
|| ! array_key_exists( 'post_id', $this_attachment_postmeta )
|| ! array_key_exists( 'meta_value', $this_attachment_postmeta )
|| ( null === $analysis_issues && ! is_string( $this_attachment_postmeta['meta_value'] ) ) ) {
return array();
}
$this_attachment_post_id = ai4seo_normalize_database_id( $this_attachment_postmeta['post_id'] );
if ( false === $this_attachment_post_id || ! isset( $attachment_attributes_coverage[ $this_attachment_post_id ] ) ) {
return array();
}
$alt_text = ai4seo_decode_attachment_text_value( $this_attachment_postmeta['meta_value'] );
if ( null === $alt_text || array_key_exists( $this_attachment_post_id, $seen_alt_values ) ) {
if ( null === $analysis_issues ) {
return array();
}
ai4seo_add_posts_table_analysis_issue( $analysis_issues, $this_attachment_post_id, 'alt-text', 'ambiguous_metadata' );
continue;
}
$seen_alt_values[ $this_attachment_post_id ] = $alt_text;
$attachment_attributes_coverage[ $this_attachment_post_id ]['alt-text'] = $alt_text;
}
}
}
foreach ( $analysis_issues ?? array() as $issue ) {
unset( $attachment_attributes_coverage[ $issue['post_id'] ] );
}
$read_succeeded = true;
return $attachment_attributes_coverage;
}
/**
* Decode one WordPress storage layer and validate a logical attachment text value.
*
* @param mixed $value Raw database value.
* @return string|null Text, or null when it cannot safely participate in coverage or editing.
*/
function ai4seo_decode_attachment_text_value( $value ): ?string {
if ( is_string( $value ) && is_serialized( $value ) ) {
$value = ai4seo_safe_maybe_unserialize( $value );
}
return is_string( $value ) && wp_check_invalid_utf8( $value ) === $value && false === strpos( $value, "\0" ) ? $value : null;
}
/**
* Function to return the summary of the attachment attributes coverage array
*
* @param array $attachment_attributes_coverage The attachment attributes coverage array generated by ai4seo_read_and_analyze_attachment_attributes_coverage().
* @return array The summary of the attachment attributes coverage array, basically the amount of filled attachment attributes per attachment
*/
function ai4seo_get_attachment_attributes_coverage_summary( array $attachment_attributes_coverage ): array {
// generate a summary of the attachment attributes coverage array.
$attachment_attributes_coverage_summary = array();
if ( ! $attachment_attributes_coverage ) {
return $attachment_attributes_coverage_summary;
}
foreach ( $attachment_attributes_coverage as $attachment_post_id => $attachment_attributes ) {
$attachment_attributes_coverage_summary[ $attachment_post_id ] = 0;
foreach ( $attachment_attributes as $this_attachment_attribute ) {
if ( $this_attachment_attribute ) {
++$attachment_attributes_coverage_summary[ $attachment_post_id ];
}
}
}
return $attachment_attributes_coverage_summary;
}
/**
* Function to create an empty attachment attributes coverage array
*
* @param array $attachment_post_ids The post ids of the attachments we want to analyze.
* @return array The empty attachment attributes coverage array
*/
function ai4seo_create_empty_attachment_attributes_coverage_array( array $attachment_post_ids ): array {
$attachment_field_descriptors = ai4seo_get_generation_field_descriptors( 'attachment_attributes' );
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 155352411, 'Prevented loop', true );
return array();
}
// make sure all entries of post_ids are numeric.
foreach ( $attachment_post_ids as $attachment_post_id ) {
if ( ! is_numeric( $attachment_post_id ) ) {
return array();
}
}
// Make sure that all parameters are not empty.
if ( empty( $attachment_post_ids ) ) {
return array();
}
if ( ! defined( 'AI4SEO_ATTACHMENT_ATTRIBUTES_DETAILS' ) ) {
return array();
}
$active_attachment_attributes = ai4seo_get_active_attachment_attributes();
if ( ! $active_attachment_attributes ) {
return array();
}
// build an array that holds track of which attachment_attributes are covered by the given posts.
$attachment_attributes_coverage = array();
foreach ( $attachment_post_ids as $post_id ) {
$attachment_attributes_coverage[ $post_id ] = array();
foreach ( $attachment_field_descriptors as $this_attachment_attribute_identifier => $this_attachment_attribute_details ) {
if ( ! in_array( $this_attachment_attribute_identifier, $active_attachment_attributes, true ) ) {
continue;
}
$attachment_attributes_coverage[ $post_id ][ $this_attachment_attribute_identifier ] = '';
}
}
return $attachment_attributes_coverage;
}
/**
* Checks if the metadata for a given post is fully covered
*
* @param int $attachment_post_id The post id to check the metadata coverage for.
* @param bool|null $read_succeeded Receives whether the one-ID source snapshot was authoritative.
* @return bool Whether the metadata for a given post is fully covered
*/
function ai4seo_are_attachment_attributes_fully_covered( int $attachment_post_id, ?bool &$read_succeeded = null ): bool {
$read_succeeded = false;
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 346802262, 'Prevented loop', true );
return true;
}
// get the total amount of attachment attributes.
$active_attachment_attributes = ai4seo_get_active_attachment_attributes();
if ( ! $active_attachment_attributes ) {
$read_succeeded = true;
return true;
}
$num_active_and_covered_attachment_attributes = 0;
// Read every one-ID owner without accepting cache misses, missing posts, or duplicate alt rows.
$source_read_succeeded = false;
$attachment_post_exists = false;
$this_attachment_attributes_coverage = ai4seo_read_available_attachment_attributes(
$attachment_post_id,
$source_read_succeeded,
$attachment_post_exists
);
if ( ! $source_read_succeeded || ! $attachment_post_exists ) {
return false;
}
foreach ( $active_attachment_attributes as $this_attachment_attribute ) {
if ( ! empty( $this_attachment_attributes_coverage[ $this_attachment_attribute ] ) ) {
++$num_active_and_covered_attachment_attributes;
}
}
$attachment_attributes_coverage_percentage = ( $num_active_and_covered_attachment_attributes / count( $active_attachment_attributes ) ) * 100;
$read_succeeded = true;
return ai4seo_is_full_coverage_percentage( $attachment_attributes_coverage_percentage );
}
/**
* Returns the number of active attachment attributes
*
* @return int the number of active attachment attributes
*/
function ai4seo_get_active_num_attachment_attributes(): int {
return count( ai4seo_get_active_attachment_attributes() );
}
/**
* Reads one attachment post and its authoritative attachment-attribute values.
*
* @param int $attachment_post_id Attachment post ID.
* @param bool|null $read_succeeded Receives whether both exact reads and row validation succeeded.
* @param bool|null $post_exists Receives whether the posts-table owner exists after a successful post read.
* @param array|null $editor_issues Explicit opt-in to field-level display quality; strict callers remain unchanged.
* @param string|null $failure_reason Receives a source defect or hard read failure classification.
* @return array{post?: array, alt_text?: array} Validated source snapshot, or an empty array.
*/
function ai4seo_read_attachment_attribute_source_snapshot(
int $attachment_post_id,
?bool &$read_succeeded = null,
?bool &$post_exists = null,
?array &$editor_issues = null,
?string &$failure_reason = null
): array {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
$read_succeeded = false;
$post_exists = false;
$failure_reason = 'read_failed';
if ( $attachment_post_id <= 0 ) {
return array();
}
$post_query = ai4seo_prepare_database_query(
'SELECT `ID`, `post_type`, `post_mime_type`, `guid`, `post_title`, `post_excerpt`, `post_content`
FROM {{posts_table}}
WHERE `ID` = {{post_id}}
LIMIT 1',
array(
'posts_table' => ai4seo_database_identifier_binding( 'table.posts' ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
)
);
if ( false === $post_query ) {
return array();
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed compiler owns the exact primary-key read; queue and coverage decisions require current source bytes.
$post_row = $wpdb->get_row( $post_query, ARRAY_A );
if ( $wpdb->last_error ) {
return array();
}
if ( null === $post_row ) {
$read_succeeded = true;
return array();
}
$required_post_string_columns = array(
'post_type',
'post_mime_type',
'guid',
'post_title',
'post_excerpt',
'post_content',
);
if ( ! is_array( $post_row ) || ai4seo_normalize_database_id( $post_row['ID'] ?? null ) !== $attachment_post_id ) {
return array();
}
foreach ( $required_post_string_columns as $required_post_string_column ) {
if ( ! array_key_exists( $required_post_string_column, $post_row ) || ! is_string( $post_row[ $required_post_string_column ] ) ) {
if ( null !== $editor_issues && 'post_type' !== $required_post_string_column ) {
$editor_issues[ $required_post_string_column ] = 'unknown';
continue;
}
$failure_reason = 'damaged_source';
return array();
}
}
$post_exists = true;
$attachment_meta_query = ai4seo_prepare_database_query(
'SELECT `meta_id`, `meta_key`, `meta_value`
FROM {{postmeta_table}}
WHERE `post_id` = {{post_id}}
AND `meta_key` IN ({{meta_keys}})
ORDER BY `meta_id` ASC
LIMIT {{row_limit}}',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'meta_keys' => ai4seo_database_list_binding( '%s', array( '_wp_attached_file', '_wp_attachment_image_alt' ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Exact post ID and bounded duplicate-owner inspection.
'row_limit' => ai4seo_database_scalar_binding( '%d', null !== $editor_issues ? 128 : 4 ),
)
);
if ( false === $attachment_meta_query ) {
return array();
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed compiler owns the exact post/key query; duplicate alt owners must fail closed outside object caches.
$attachment_meta_rows = $wpdb->get_results( $attachment_meta_query, ARRAY_A );
if ( $wpdb->last_error || ! is_array( $attachment_meta_rows ) ) {
return array();
}
$meta_states = array(
'_wp_attached_file' => array(
'exists' => false,
'meta_id' => 0,
'value' => '',
),
'_wp_attachment_image_alt' => array(
'exists' => false,
'meta_id' => 0,
'value' => '',
),
);
foreach ( $attachment_meta_rows as $attachment_meta_row ) {
$meta_id = is_array( $attachment_meta_row ) ? ai4seo_normalize_database_id( $attachment_meta_row['meta_id'] ?? null ) : false;
$meta_key = is_array( $attachment_meta_row ) && isset( $attachment_meta_row['meta_key'] ) && is_string( $attachment_meta_row['meta_key'] )
? $attachment_meta_row['meta_key']
: '';
$raw_meta_value = is_array( $attachment_meta_row ) && array_key_exists( 'meta_value', $attachment_meta_row )
? $attachment_meta_row['meta_value']
: null;
$meta_value = ai4seo_decode_attachment_text_value( $raw_meta_value );
if ( null !== $editor_issues && false !== $meta_id && isset( $meta_states[ $meta_key ] ) ) {
if ( null === $meta_value ) {
$editor_issues[ $meta_key ] = 'unknown';
continue;
}
if ( $meta_states[ $meta_key ]['exists'] ) {
if ( empty( $editor_issues[ $meta_key ] ) || 'duplicate' === $editor_issues[ $meta_key ] ) {
$editor_issues[ $meta_key ] = $meta_states[ $meta_key ]['value'] === $meta_value ? 'duplicate' : 'conflict';
}
continue;
}
}
if ( false === $meta_id || ! isset( $meta_states[ $meta_key ] ) ) {
return array();
}
if ( $meta_states[ $meta_key ]['exists']
|| null === $meta_value
) {
$failure_reason = 'damaged_source';
return array();
}
$meta_states[ $meta_key ] = array(
'exists' => true,
'meta_id' => $meta_id,
'value' => $meta_value,
);
}
// At the bounded query limit an unseen duplicate may still contradict either key.
if ( null !== $editor_issues && count( $attachment_meta_rows ) >= 128 ) {
$editor_issues['_wp_attached_file'] = 'unknown';
$editor_issues['_wp_attachment_image_alt'] = 'unknown';
}
$read_succeeded = true;
return array(
'post' => $post_row,
'attached_file' => $meta_states['_wp_attached_file'],
'alt_text' => $meta_states['_wp_attachment_image_alt'],
);
}
/**
* Resolves an attachment URL without rereading post or postmeta storage.
*
* The exact snapshot remains authoritative. Native attachments prefer their verified attached-file
* path under the current uploads base URL and fall back to the verified GUID. Other supported media
* post types use their verified GUID directly.
*
* @param array $snapshot Snapshot returned by ai4seo_read_attachment_attribute_source_snapshot().
* @return string|null Resolved URL, or null when the verified snapshot has no usable source.
*/
function ai4seo_resolve_attachment_url_from_source_snapshot( array $snapshot ): ?string {
$post_state = $snapshot['post'] ?? null;
$attached_file_state = $snapshot['attached_file'] ?? null;
$validate_url = static function ( string $url ): ?string {
$url = trim( $url );
$url_scheme = wp_parse_url( $url, PHP_URL_SCHEME );
if ( false === filter_var( $url, FILTER_VALIDATE_URL )
|| ! is_string( $url_scheme )
|| ! in_array( strtolower( $url_scheme ), array( 'http', 'https' ), true )
) {
return null;
}
return $url;
};
if ( ! is_array( $post_state )
|| ! isset( $post_state['post_type'], $post_state['guid'] )
|| ! is_string( $post_state['post_type'] )
|| ! is_string( $post_state['guid'] )
|| ! is_array( $attached_file_state )
|| ! isset( $attached_file_state['exists'], $attached_file_state['value'] )
|| ! is_bool( $attached_file_state['exists'] )
|| ! is_string( $attached_file_state['value'] )
) {
return null;
}
$verified_guid = trim( $post_state['guid'] );
if ( 'attachment' !== $post_state['post_type'] ) {
return $validate_url( $verified_guid );
}
if ( $attached_file_state['exists'] && '' !== trim( $attached_file_state['value'] ) ) {
$uploads = wp_get_upload_dir();
if ( is_array( $uploads )
&& empty( $uploads['error'] )
&& isset( $uploads['basedir'], $uploads['baseurl'] )
&& is_string( $uploads['basedir'] )
&& is_string( $uploads['baseurl'] )
&& '' !== $uploads['baseurl']
) {
$verified_file = wp_normalize_path( trim( $attached_file_state['value'] ) );
$uploads_basedir = rtrim( wp_normalize_path( $uploads['basedir'] ), '/' );
$relative_file = '';
if ( '' !== $uploads_basedir && 0 === strpos( $verified_file, $uploads_basedir . '/' ) ) {
$relative_file = substr( $verified_file, strlen( $uploads_basedir ) + 1 );
} elseif ( 1 !== preg_match( '#^(?:[A-Za-z]:/|/)#', $verified_file ) ) {
$relative_file = $verified_file;
}
$relative_file = ltrim( $relative_file, '/' );
if ( '' !== $relative_file
&& 0 !== strpos( $relative_file, '../' )
&& false === strpos( $relative_file, '/../' )
&& false === strpos( $relative_file, "\0" )
) {
$resolved_upload_url = $validate_url( rtrim( $uploads['baseurl'], '/' ) . '/' . $relative_file );
if ( null !== $resolved_upload_url ) {
return $resolved_upload_url;
}
}
}
}
return $validate_url( $verified_guid );
}
/**
* Returns the attachment attributes for a specific attachment post id.
*
* @param int $attachment_post_id The post id of the attachment.
* @param bool|null $read_succeeded Receives whether the exact one-ID snapshot was authoritative.
* @param bool|null $post_exists Receives whether the attachment post exists.
* @return array The attachment attributes.
*/
function ai4seo_read_available_attachment_attributes(
int $attachment_post_id,
?bool &$read_succeeded = null,
?bool &$post_exists = null
): array {
$attachment_attributes = array(
'title' => '',
'caption' => '',
'description' => '',
'alt-text' => '',
);
$snapshot = ai4seo_read_attachment_attribute_source_snapshot(
$attachment_post_id,
$read_succeeded,
$post_exists
);
if ( ! $read_succeeded || ! $post_exists ) {
return $attachment_attributes;
}
$attachment_attributes['title'] = $snapshot['post']['post_title'];
$attachment_attributes['caption'] = $snapshot['post']['post_excerpt'];
$attachment_attributes['description'] = $snapshot['post']['post_content'];
$attachment_attributes['alt-text'] = $snapshot['alt_text']['value'];
return $attachment_attributes;
}
/**
* Builds the exact attachment-attribute coverage transition for one valid media post.
*
* @param int $attachment_post_id Attachment post ID whose persisted attributes are authoritative.
* @param bool $clear_failed_generation_status Whether Failed must be absent after success.
* @param bool $clear_claimable_generation_status Whether Pending and Force must also be absent.
* @param bool|null $read_succeeded Receives whether coverage and generated-data reads were authoritative.
* @return array{additions: array, removals: array}|array{} Normalized transition maps, or empty on read failure.
*/
function ai4seo_build_attachment_attributes_coverage_post_id_option_transition(
int $attachment_post_id,
bool $clear_failed_generation_status = false,
bool $clear_claimable_generation_status = false,
?bool &$read_succeeded = null
): array {
$read_succeeded = false;
$coverage_option_names = array(
AI4SEO_MISSING_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME,
AI4SEO_FULLY_COVERED_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME,
AI4SEO_GENERATED_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME,
);
$coverage_read_succeeded = false;
$generated_data_read_succeeded = false;
$is_fully_covered = ai4seo_are_attachment_attributes_fully_covered( $attachment_post_id, $coverage_read_succeeded );
$has_generated_data = ai4seo_post_has_generated_data( $attachment_post_id, $generated_data_read_succeeded );
if ( ! $coverage_read_succeeded || ! $generated_data_read_succeeded ) {
return array();
}
$has_generated_data = $is_fully_covered && $has_generated_data;
$destination_option = $is_fully_covered
? AI4SEO_FULLY_COVERED_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME
: AI4SEO_MISSING_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME;
$transition_additions = array( $destination_option => array( $attachment_post_id ) );
$transition_removals = array();
if ( $has_generated_data ) {
$transition_additions[ AI4SEO_GENERATED_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME ] = array( $attachment_post_id );
}
foreach ( $coverage_option_names as $coverage_option_name ) {
if ( ! isset( $transition_additions[ $coverage_option_name ] ) ) {
$transition_removals[ $coverage_option_name ] = array( $attachment_post_id );
}
}
if ( $clear_failed_generation_status ) {
$transition_removals[ AI4SEO_FAILED_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME ] = array( $attachment_post_id );
}
if ( $clear_claimable_generation_status ) {
$transition_removals[ AI4SEO_PENDING_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME ] = array( $attachment_post_id );
$transition_removals[ AI4SEO_FORCE_OVERWRITE_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME ] = array( $attachment_post_id );
}
$read_succeeded = true;
return array(
'additions' => $transition_additions,
'removals' => $transition_removals,
);
}
/**
* Refreshes the attachment attributes coverage for the given post by putting the post id into the corresponding option
*
* @param int $attachment_post_id The post id to refresh the attachment attributes coverage for.
* @param WP_Post|null $post The post object to refresh the attachment attributes coverage for.
* @param bool $clear_failed_generation_status Whether a successful manual save also clears Failed.
* @return bool True only when the complete requested coverage state was verified.
*/
function ai4seo_refresh_one_posts_attachment_attributes_coverage(
int $attachment_post_id,
$post = null,
bool $clear_failed_generation_status = false
): bool {
if ( ai4seo_is_post_state_storage_paused() ) {
return ai4seo_mutate_analysis_reconciliation( array( $attachment_post_id ), true );
}
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 393113591, 'Prevented loop', true );
return false;
}
if ( $attachment_post_id <= 0 ) {
return false;
}
if ( ! ai4seo_is_post_a_valid_attachment( $attachment_post_id, $post ) ) {
return ai4seo_remove_post_ids_from_all_options( $attachment_post_id );
}
$coverage_read_succeeded = false;
$coverage_transition = ai4seo_build_attachment_attributes_coverage_post_id_option_transition(
$attachment_post_id,
$clear_failed_generation_status,
false,
$coverage_read_succeeded
);
if ( ! $coverage_read_succeeded ) {
return false;
}
return ai4seo_apply_post_id_option_transition(
$coverage_transition['additions'],
$coverage_transition['removals']
);
}
/**
* This function checks if an attachment is valid for our plugin to be considered
*
* @param int $attachment_post_id The post id to check.
* @param WP_Post|null $attachment_post The post object to check.
* @return bool Whether the attachment is valid
*/
function ai4seo_is_post_a_valid_attachment( int $attachment_post_id, ?WP_Post $attachment_post = null ): bool {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 304333735, 'Prevented loop', true );
return false;
}
if ( ! is_numeric( $attachment_post_id ) ) {
return false;
}
$supported_attachment_post_types = ai4seo_get_supported_attachment_post_types();
// read post.
if ( null === $attachment_post ) {
$attachment_post = get_post( $attachment_post_id );
}
// check if the post could be read.
if ( ! $attachment_post || is_wp_error( $attachment_post ) || ! isset( $attachment_post->post_type ) ) {
return false;
}
// check if the post type is an attachment.
if ( ! in_array( (string) $attachment_post->post_type, $supported_attachment_post_types, true ) ) {
return false;
}
$attachment_post_mime_type = ai4seo_get_attachment_post_mime_type( $attachment_post_id );
$allowed_attachment_mime_types = ai4seo_get_allowed_attachment_mime_types();
// check mime type.
if ( ! in_array( $attachment_post_mime_type, $allowed_attachment_mime_types, true ) ) {
return false;
}
// check post status.
if ( ! in_array( (string) $attachment_post->post_status, array( 'publish', 'future', 'private', 'pending', 'inherit' ), true ) ) {
return false;
}
return true;
}
/**
* Resolve generation-only MIME eligibility without changing inventory or editing support.
*
* @param array|null $settings_snapshot Optional authoritative Settings option snapshot from a queue lock.
* @param bool $require_svg_capability Whether to apply the server capability gate.
* @return array Enabled and currently available MIME types. Invalid storage fails closed.
*/
function ai4seo_get_enabled_attachment_mime_types( ?array $settings_snapshot = null, bool $require_svg_capability = true ): array {
$file_types = ai4seo_get_setting( AI4SEO_SETTING_ENABLED_ATTACHMENT_FILE_TYPES );
if ( null !== $settings_snapshot ) {
$settings = ai4seo_safe_maybe_unserialize( $settings_snapshot['value'] ?? array() );
if ( is_string( $settings ) && ai4seo_is_json( $settings ) ) {
$settings = json_decode( $settings, true );
}
if ( ! is_array( $settings ) ) {
return array();
}
$file_types = array_key_exists( AI4SEO_SETTING_ENABLED_ATTACHMENT_FILE_TYPES, $settings )
? $settings[ AI4SEO_SETTING_ENABLED_ATTACHMENT_FILE_TYPES ] : AI4SEO_LEGACY_ATTACHMENT_FILE_TYPES;
}
if ( ! ai4seo_validate_setting_value( AI4SEO_SETTING_ENABLED_ATTACHMENT_FILE_TYPES, $file_types ) ) {
return array();
}
$mime_types = array();
foreach ( array_unique( $file_types ) as $file_type ) {
if ( 'svg' !== $file_type || ! $require_svg_capability || ai4seo_robhub_api()->supports_svg_static_v1() ) {
$mime_types[] = AI4SEO_ATTACHMENT_FILE_TYPE_MIME_TYPES[ $file_type ];
}
}
return $mime_types;
}
/**
* Filter only generation additions; removal, editing, and hiding retain supported media.
*
* @param array $post_ids Candidate attachment IDs.
* @param int|null $svg_unavailable Receives the count excluded only by the SVG service gate.
* @return array Eligible IDs.
*/
function ai4seo_filter_generation_enabled_attachment_ids( array $post_ids, ?int &$svg_unavailable = null ): array {
$svg_unavailable = 0;
$settings_snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_SETTINGS_OPTION_NAME );
if ( null === $settings_snapshot ) {
return array();
}
$mime_types = ai4seo_get_enabled_attachment_mime_types( $settings_snapshot, false );
$svg_ready = ai4seo_robhub_api()->supports_svg_static_v1();
$eligible = array();
foreach ( $post_ids as $post_id ) {
$mime_type = ai4seo_get_attachment_post_mime_type( (int) $post_id );
if ( ! in_array( $mime_type, $mime_types, true ) ) {
continue;
}
if ( 'image/svg+xml' === $mime_type && ! $svg_ready ) {
++$svg_unavailable;
continue;
}
$eligible[] = $post_id;
}
return $eligible;
}
/**
* Explain the service gate without exposing provider credentials or response bodies.
*
* @param int $skipped Optional number of SVG entries excluded by this gate.
* @return string Translatable, plain-text explanation.
*/
function ai4seo_get_svg_unavailability_message( int $skipped = 0 ): string {
$api = ai4seo_robhub_api();
$status = $api->read_environmental_variable( $api::ENVIRONMENTAL_VARIABLE_SVG_STATUS );
$reason = $status['reason'] ?? '';
if ( $api->supports_svg_static_v1() ) {
$message = __( 'SVG conversion is available again. Please retry the skipped entries.', 'ai-for-seo' );
} elseif ( ! $api->is_account_synced() ) {
$message = __( 'SVG availability could not be confirmed because account data is not synchronized. Refresh account data and try again.', 'ai-for-seo' );
} elseif ( 'disabled' === $reason ) {
$message = __( 'SVG conversion is disabled on the service. Your selection is retained.', 'ai-for-seo' );
} elseif ( 'configuration_unavailable' === $reason ) {
$message = __( 'The SVG conversion service is not configured or ready. Please contact support. Your selection is retained.', 'ai-for-seo' );
} else {
$message = 'provider_timeout' === $reason
? __( 'The SVG availability check timed out.', 'ai-for-seo' )
: __( 'SVG conversion is temporarily unavailable or its availability could not be confirmed.', 'ai-for-seo' );
$message .= ' ' . ( (int) ( $status['retry_after'] ?? 60 ) > 60
? __( 'Please try again later. Your SVG selection is retained.', 'ai-for-seo' )
: __( 'Please try again in a minute. Your SVG selection is retained.', 'ai-for-seo' ) );
}
if ( $skipped > 0 ) {
$message = sprintf(
/* translators: %s: number of SVG entries skipped because the conversion service was unavailable. */
_n( '%s SVG entry skipped.', '%s SVG entries skipped.', $skipped, 'ai-for-seo' ),
ai4seo_format_number_i18n( $skipped )
) . ' ' . $message;
}
return $message;
}
/**
* Read bounded SVG bytes without parsing XML or passing through a raster decoder.
*
* @param array $image_source Validated attachment source descriptor.
* @return string|WP_Error SVG bytes or a safe error.
*/
function ai4seo_read_attachment_svg_source( array $image_source ) {
$local_path = ai4seo_get_attachment_generation_local_file_path( $image_source );
$body = false;
if ( '' !== $local_path ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read only the attachment-owned path with a hard byte limit.
$body = file_get_contents( $local_path, false, null, 0, AI4SEO_SVG_MAX_SOURCE_BYTES + 1 );
}
if ( false === $body ) {
// Do not use generic URL-to-local mapping: query variants must retain their delivery identity.
$body = ai4seo_get_remote_body( (string) $image_source['delivery_url'], 'remote_only', AI4SEO_SVG_MAX_SOURCE_BYTES );
}
if ( is_wp_error( $body ) ) {
return new WP_Error( 'svg_source', __( 'The SVG could not be read securely. Check that its URL is accessible and its size is at most 1 MiB.', 'ai-for-seo' ) );
}
if ( ! is_string( $body ) || '' === $body || strlen( $body ) > AI4SEO_SVG_MAX_SOURCE_BYTES ) {
return new WP_Error( 'svg_source', __( 'The SVG must contain data and be no larger than 1 MiB.', 'ai-for-seo' ) );
}
return $body;
}
/**
* Prepare manual image input from authoritative identity and bounded image bytes.
* Storage is never repaired. Filtered/CDN URLs retain their exact identity.
*
* @param int $post_id Native attachment ID.
* @return array|WP_Error Source descriptor and verified bytes, or a hard admission error.
*/
function ai4seo_prepare_manual_attachment_generation_input( int $post_id ) {
$issues = array();
$read = false;
$exists = false;
$snapshot = ai4seo_read_attachment_attribute_source_snapshot( $post_id, $read, $exists, $issues );
$error = new WP_Error( 'ai4seo_manual_image_unavailable', __( 'The media source could not be identified safely. No generation was started. Use Help → Inspect post to check this entry.', 'ai-for-seo' ) );
if ( ! $read || ! $exists || 'attachment' !== $snapshot['post']['post_type']
|| in_array( $issues['_wp_attached_file'] ?? '', array( 'unknown', 'conflict' ), true ) ) {
return $error;
}
// A bad GUID is irrelevant when the attached file independently identifies the upload.
$snapshot['post']['guid'] = empty( $issues['guid'] ) ? $snapshot['post']['guid'] : '';
$canonical_url = ai4seo_resolve_attachment_url_from_source_snapshot( $snapshot );
if ( ! $canonical_url ) {
return $error;
}
$url = apply_filters( 'wp_get_attachment_url', $canonical_url, $post_id );
if ( ! is_string( $url ) || ! wp_http_validate_url( $url ) ) {
// Local uploads may use a private development host; only the exact upload URL qualifies.
$uploads = wp_get_upload_dir();
if ( ! is_string( $url ) || $url !== $canonical_url || empty( $uploads['baseurl'] )
|| 0 !== strpos( $url, trailingslashit( $uploads['baseurl'] ) ) ) {
return $error;
}
}
$source = array(
'attachment_id' => $post_id,
'original_url' => $url,
'delivery_url' => $url,
'width' => 0,
'height' => 0,
'mime_type' => '',
'size_name' => 'full',
);
$uploads = wp_get_upload_dir();
$file = $snapshot['attached_file']['value'];
if ( '' !== $file && ! path_is_absolute( $file ) && ! empty( $uploads['basedir'] ) ) {
$file = trailingslashit( $uploads['basedir'] ) . $file;
}
if ( '' !== $file ) {
$normalized_file = wp_normalize_path( $file );
$uploads_prefix = trailingslashit( wp_normalize_path( $uploads['basedir'] ?? '' ) );
$real_file = realpath( $file );
$real_uploads = realpath( $uploads['basedir'] ?? '' );
if ( empty( $uploads['basedir'] ) || 0 !== strpos( $normalized_file, $uploads_prefix )
|| 1 === preg_match( '#(?:^|/)\.\.(?:/|$)#', $normalized_file )
|| ( false !== $real_file && ( false === $real_uploads
|| 0 !== strpos( wp_normalize_path( $real_file ), trailingslashit( wp_normalize_path( $real_uploads ) ) ) ) ) ) {
return $error;
}
}
// Even matching basenames do not prove that a filtered crop is the original local file.
$local = $url === $canonical_url ? ai4seo_get_attachment_generation_local_file_path( $source, $file ) : '';
if ( '' !== $local ) {
$image = ai4seo_get_image_data_from_local_file( $local, AI4SEO_MAX_BASE64_ATTACHMENT_SOURCE_SIZE_BYTES );
} else {
// Do not let same-site shortcuts strip a signed URL or crop query.
$bytes = ai4seo_get_remote_body( $url, 'remote_only', AI4SEO_MAX_BASE64_ATTACHMENT_SOURCE_SIZE_BYTES );
if ( is_wp_error( $bytes ) ) {
return $bytes;
}
$probe = is_string( $bytes ) ? ai4seo_is_probably_image_content( $bytes ) : array();
if ( empty( $probe['is_probably_image'] ) || strlen( $bytes ) > AI4SEO_MAX_BASE64_ATTACHMENT_SOURCE_SIZE_BYTES ) {
return $error;
}
$image = array(
'data' => $bytes,
'mime_type' => ai4seo_get_mime_type_from_detected_image_format( $probe['detected_format'] ?? '' ),
);
}
if ( is_wp_error( $image ) ) {
return $image;
}
$original_mime = $image['mime_type'];
// A remote conversion cannot grant permission for a disabled original format.
$hint_mime = ai4seo_get_mime_type_from_url( $canonical_url );
$policy_mime = '' !== $local ? $original_mime : ( $hint_mime ? $hint_mime : ( $snapshot['post']['post_mime_type'] ?? '' ) );
if ( '' === $local && 'image/svg+xml' === ( $snapshot['post']['post_mime_type'] ?? '' ) ) {
$policy_mime = 'image/svg+xml';
}
$settings = ai4seo_get_raw_option_snapshot( AI4SEO_SETTINGS_OPTION_NAME );
$enabled = null !== $settings ? ai4seo_get_enabled_attachment_mime_types( $settings ) : array();
if ( ! in_array( $original_mime, $enabled, true ) || ! in_array( $policy_mime, $enabled, true )
|| ( 'image/svg+xml' === $original_mime && strlen( $image['data'] ) > AI4SEO_SVG_MAX_SOURCE_BYTES ) ) {
return new WP_Error( 'ai4seo_manual_image_format', __( 'Generation for this original file type is disabled, unavailable, or exceeds its size limit. Check Enabled File Types in Media Attributes settings.', 'ai-for-seo' ) );
}
$source['mime_type'] = $original_mime;
// Actual image dimensions replace optional stored hints only in memory.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- SVG and unsupported decoders have no raster dimensions.
$dimensions = 'image/svg+xml' !== $original_mime ? @getimagesizefromstring( $image['data'] ) : false;
if ( is_array( $dimensions ) ) {
$source['width'] = (int) $dimensions[0];
$source['height'] = (int) $dimensions[1];
}
if ( '' !== $local && is_array( $dimensions ) ) {
$candidate = ai4seo_get_attachment_generation_image_source( $post_id, $url, $original_mime, $dimensions );
if ( $candidate && 'full' !== $candidate['size_name']
&& dirname( $candidate['delivery_url'] ) === dirname( $url ) ) {
$candidate_path = ai4seo_get_attachment_generation_local_file_path( $candidate, $file );
$candidate_image = $candidate_path ? ai4seo_get_image_data_from_local_file( $candidate_path, AI4SEO_MAX_BASE64_ATTACHMENT_SOURCE_SIZE_BYTES ) : null;
if ( is_array( $candidate_image ) && in_array( $candidate_image['mime_type'], $enabled, true ) && 'image/svg+xml' !== $candidate_image['mime_type'] ) {
$source = $candidate;
$image = $candidate_image;
$source['mime_type'] = $image['mime_type'];
}
}
}
$image['original_mime_type'] = $policy_mime;
return array(
'source' => $source,
'image' => $image,
);
}
/**
* Resolves the image source used for attachment-attribute generation.
*
* Existing WordPress sub-sizes are considered only when the full image dimensions are known
* and exceed the generation limit. Missing dimensions or sub-sizes retain the full source.
*
* @param int $attachment_post_id Attachment post ID.
* @param string|null $original_attachment_url Optional pre-resolved full attachment URL.
* @param string|null $full_mime_type Optional pre-resolved full attachment MIME type.
* @param array|null $verified_dimensions Actual dimensions for the manual read-only path.
* @return array|null {
* Attachment generation image source, or null when the attachment URL is unavailable.
*
* @type string $original_url Full attachment URL retained for reference and context.
* @type string $delivery_url URL used for image delivery.
* @type int $width Delivery image width when known.
* @type int $height Delivery image height when known.
* @type string $mime_type Delivery image MIME type.
* @type string $size_name WordPress image size name.
* @type int $attachment_id Attachment identity used only for local source resolution.
* }
*/
function ai4seo_get_attachment_generation_image_source(
int $attachment_post_id,
?string $original_attachment_url = null,
?string $full_mime_type = null,
?array $verified_dimensions = null
): ?array {
// Preserve the canonical full URL as request context even when a smaller delivery source is selected later.
$attachment_post_id = absint( $attachment_post_id );
if ( null === $original_attachment_url ) {
$original_attachment_url = ai4seo_get_attachment_url( $attachment_post_id );
}
if ( ! $original_attachment_url ) {
return null;
}
$full_mime_type = $full_mime_type ?? ai4seo_get_attachment_post_mime_type( $attachment_post_id );
if ( 'image/svg+xml' === $full_mime_type ) {
// SVG dimensions and sub-sizes from upload plugins are not trusted raster representations.
return array(
'attachment_id' => $attachment_post_id,
'original_url' => $original_attachment_url,
'delivery_url' => $original_attachment_url,
'width' => 0,
'height' => 0,
'mime_type' => $full_mime_type,
'size_name' => 'full',
);
}
// Prefer WordPress metadata because it identifies the full image without opening the image binary.
$attachment_metadata = wp_get_attachment_metadata( $attachment_post_id );
$full_width = 0;
$full_height = 0;
if ( null !== $verified_dimensions ) {
$full_width = (int) ( $verified_dimensions[0] ?? 0 );
$full_height = (int) ( $verified_dimensions[1] ?? 0 );
} elseif ( is_array( $attachment_metadata ) ) {
$full_width = absint( $attachment_metadata['width'] ?? 0 );
$full_height = absint( $attachment_metadata['height'] ?? 0 );
}
// Read local image headers only when attachment metadata cannot identify the full dimensions.
if ( $full_width <= 0 || $full_height <= 0 ) {
$attached_file = get_attached_file( $attachment_post_id );
if ( is_string( $attached_file ) && is_file( $attached_file ) && is_readable( $attached_file ) ) {
$full_image_size = wp_getimagesize( $attached_file );
if ( is_array( $full_image_size ) ) {
$full_width = absint( $full_image_size[0] ?? 0 );
$full_height = absint( $full_image_size[1] ?? 0 );
}
}
}
// Build the unchanged fallback descriptor once so every soft-selection failure returns the same full source.
if ( null === $full_mime_type ) {
$full_mime_type = ai4seo_get_attachment_post_mime_type( $attachment_post_id ) ?? '';
}
$full_image_source = array(
'attachment_id' => $attachment_post_id,
'original_url' => $original_attachment_url,
'delivery_url' => $original_attachment_url,
'width' => $full_width,
'height' => $full_height,
'mime_type' => $full_mime_type,
'size_name' => 'full',
);
// Soft selection requires known oversized full dimensions; every other case keeps today's full source.
if ( $full_width <= 0 || $full_height <= 0
|| ( $full_width <= AI4SEO_ATTACHMENT_GENERATION_MAX_IMAGE_DIMENSION
&& $full_height <= AI4SEO_ATTACHMENT_GENERATION_MAX_IMAGE_DIMENSION )
) {
return $full_image_source;
}
// Try only existing core image sizes in descending preference; WordPress must not create variants during generation.
$allowed_mime_types = array_values( array_diff( ai4seo_get_allowed_attachment_mime_types(), array( 'image/svg+xml' ) ) );
// Malformed optional metadata must not reach WordPress's arithmetic helpers.
if ( null !== $verified_dimensions && ( ! is_array( $attachment_metadata )
|| ! is_numeric( $attachment_metadata['width'] ?? null ) || ! is_numeric( $attachment_metadata['height'] ?? null ) ) ) {
return $full_image_source;
}
foreach ( array( '2048x2048', '1536x1536', 'large' ) as $image_size_name ) {
if ( null !== $verified_dimensions ) {
$size = $attachment_metadata['sizes'][ $image_size_name ] ?? null;
if ( ! is_array( $size ) || ! is_string( $size['file'] ?? null )
|| ! is_numeric( $size['width'] ?? null ) || ! is_numeric( $size['height'] ?? null ) ) {
continue;
}
}
$candidate_image_source = wp_get_attachment_image_src( $attachment_post_id, $image_size_name, false );
// Reject malformed filter results and the full-image fallback before coercing tuple values.
if ( ! is_array( $candidate_image_source )
|| ! isset(
$candidate_image_source[0],
$candidate_image_source[1],
$candidate_image_source[2],
$candidate_image_source[3]
)
|| ! is_string( $candidate_image_source[0] )
|| '' === trim( $candidate_image_source[0] )
|| ! is_numeric( $candidate_image_source[1] )
|| ! is_numeric( $candidate_image_source[2] )
|| true !== $candidate_image_source[3]
) {
continue;
}
// Trust the filtered WordPress source tuple only when its URL and actual reported dimensions fit the delivery limit.
$candidate_url = (string) $candidate_image_source[0];
$candidate_width = absint( $candidate_image_source[1] ?? 0 );
$candidate_height = absint( $candidate_image_source[2] ?? 0 );
if ( false === filter_var( $candidate_url, FILTER_VALIDATE_URL )
|| $candidate_width <= 0
|| $candidate_height <= 0
|| $candidate_width > AI4SEO_ATTACHMENT_GENERATION_MAX_IMAGE_DIMENSION
|| $candidate_height > AI4SEO_ATTACHMENT_GENERATION_MAX_IMAGE_DIMENSION
) {
continue;
}
// Prefer the sub-size MIME metadata, then infer from its filtered URL, while retaining the
// full MIME as a safe fallback.
$candidate_mime_type = '';
if ( is_array( $attachment_metadata ) && isset( $attachment_metadata['sizes'][ $image_size_name ]['mime-type'] ) && is_string( $attachment_metadata['sizes'][ $image_size_name ]['mime-type'] ) ) {
$candidate_mime_type = ai4seo_normalize_mime_type_string(
(string) $attachment_metadata['sizes'][ $image_size_name ]['mime-type']
);
}
if ( ! $candidate_mime_type ) {
$candidate_mime_type = ai4seo_get_mime_type_from_url( $candidate_url );
}
if ( ! in_array( $candidate_mime_type, $allowed_mime_types, true ) ) {
$candidate_mime_type = $full_mime_type;
}
// Return the first eligible candidate while keeping the full URL available as the stable reference.
return array(
'attachment_id' => $attachment_post_id,
'original_url' => $original_attachment_url,
'delivery_url' => $candidate_url,
'width' => $candidate_width,
'height' => $candidate_height,
'mime_type' => $candidate_mime_type,
'size_name' => $image_size_name,
);
}
// Keep the optimization soft when none of the existing core sizes satisfies every candidate requirement.
return $full_image_source;
}
/**
* Resolve the selected attachment file without depending on its public URL host.
*
* Only current-site upload files identified by WordPress attachment metadata are eligible.
* Missing/offloaded files and filtered sources that do not match that identity retain URL loading.
*
* @param array $image_source Attachment generation source descriptor.
* @param string|null $verified_attached_file Optional authoritative path from the manual snapshot.
* @return string Contained local file path, or an empty string for URL fallback.
*/
function ai4seo_get_attachment_generation_local_file_path( array $image_source, ?string $verified_attached_file = null ): string {
$attachment_id = $image_source['attachment_id'] ?? 0;
$size_name = $image_source['size_name'] ?? '';
$delivery_url = $image_source['delivery_url'] ?? '';
if ( ! is_int( $attachment_id ) || $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id )
|| ! in_array( $size_name, array( 'full', '2048x2048', '1536x1536', 'large' ), true )
|| ! is_string( $delivery_url ) ) {
return '';
}
// Query parameters can select CDN crops or other variants that the local filename cannot identify.
// Keep every nonempty query on the URL-loading path, including unknown or signed parameters.
$delivery_url_parts = wp_parse_url( $delivery_url );
if ( ! is_array( $delivery_url_parts ) || '' !== ( $delivery_url_parts['query'] ?? '' ) ) {
return '';
}
// Unfiltered attachment storage identifies the file; URL/CDN filters do not grant filesystem authority.
$attached_file = $verified_attached_file ?? get_attached_file( $attachment_id, true );
$uploads = wp_get_upload_dir();
if ( ! is_string( $attached_file ) || '' === $attached_file || ! empty( $uploads['error'] )
|| empty( $uploads['basedir'] ) || ! is_string( $uploads['basedir'] ) ) {
return '';
}
$candidate_path = $attached_file;
if ( 'full' !== $size_name ) {
$metadata = wp_get_attachment_metadata( $attachment_id, true );
$size_file = $metadata['sizes'][ $size_name ]['file'] ?? '';
// Sub-size metadata names a sibling file, never a URL, subdirectory or traversal path.
if ( ! is_string( $size_file ) || '' === $size_file || '.' === $size_file || '..' === $size_file
|| strpbrk( $size_file, "/\\\0" ) !== false ) {
return '';
}
$candidate_path = dirname( $attached_file ) . '/' . $size_file;
}
// Preserve the selected variant when a filter points the delivery URL at different content.
$delivery_path = $delivery_url_parts['path'] ?? null;
if ( ! is_string( $delivery_path )
|| wp_basename( rawurldecode( $delivery_path ) ) !== wp_basename( $candidate_path ) ) {
return '';
}
$real_candidate = realpath( $candidate_path );
$real_uploads = realpath( $uploads['basedir'] );
if ( false === $real_candidate || false === $real_uploads
|| ! is_file( $real_candidate ) || ! is_readable( $real_candidate ) ) {
return '';
}
// Canonical containment rejects sibling-prefix tricks and symlinks outside this site's uploads.
$uploads_prefix = trailingslashit( wp_normalize_path( $real_uploads ) );
if ( 0 !== strpos( wp_normalize_path( $real_candidate ), $uploads_prefix ) ) {
return '';
}
return $real_candidate;
}
/**
* Determines whether to use base64 encoding or URL for image upload based on user setting and automatic logic
*
* @param string $attachment_url The attachment URL to check.
* @return bool true if base64 should be used, false if URL should be used
*/
function ai4seo_should_use_base64_image( string $attachment_url ): bool {
global $ai4seo_allowed_image_file_type_names;
// Get the user's preference for image upload method.
$image_upload_method = ai4seo_get_setting( AI4SEO_SETTING_IMAGE_UPLOAD_METHOD );
switch ( $image_upload_method ) {
case 'base64':
// User explicitly chose base64 - always encode and send image data directly.
return true;
case 'url':
// User explicitly chose URL - always send the image URL.
return false;
case 'auto':
default:
// Auto mode: use intelligent logic to decide the best method
// Default to URL method for better performance (smaller payload).
$ai4seo_use_base64_image = false;
// First check: Validate URL format
// If URL format is invalid, we must use base64 as fallback.
if ( ! filter_var( $attachment_url, FILTER_VALIDATE_URL ) ) {
$ai4seo_use_base64_image = true;
}
// Second check: Detect localhost/development environments
// Our API cannot access localhost URLs, so base64 is required.
if ( ! $ai4seo_use_base64_image && ai4seo_robhub_api()->are_we_on_a_localhost_system() ) {
$ai4seo_use_base64_image = true;
}
// third check: Validate file type at the end of the URL.
if ( ! $ai4seo_use_base64_image ) {
// Get the file extension from the URL.
$file_extension = pathinfo( $attachment_url, PATHINFO_EXTENSION );
// If the file extension is not in our allowed list, we must use base64.
if ( ! in_array( strtolower( $file_extension ), $ai4seo_allowed_image_file_type_names, true ) ) {
$ai4seo_use_base64_image = true;
}
}
// Third check: Test URL accessibility (only if we haven't already decided on base64).
if ( ! $ai4seo_use_base64_image ) {
// Attempt to get HTTP headers to verify the URL is accessible.
$attachment_url_headers = get_headers( $attachment_url );
// If we can't get headers or they're malformed, the URL is not accessible.
if ( ! $attachment_url_headers || ! is_array( $attachment_url_headers ) || ! isset( $attachment_url_headers[0] ) ) {
$ai4seo_use_base64_image = true;
}
// Check for successful HTTP response (200 OK)
// If the response is not successful, our Server won't be able to access the URL.
if ( strpos( $attachment_url_headers[0], '200' ) === false ) {
$ai4seo_use_base64_image = true;
}
}
return $ai4seo_use_base64_image;
}
}
/**
* Calls the attachment-attribute generation endpoint with one preselected image source.
*
* RobHub owns model repair attempts. A second request is permitted only when RobHub explicitly
* confirms that its URL-source acquisition failed before model work and provides a one-time token
* for client-assisted base64 recovery.
*
* @param array $attachment_image_source Attachment image source descriptor.
* @param array $robhub_api_call_parameters RobHub API parameters.
* @param array|null $verified_image Bounded bytes from manual preflight; never client-supplied.
* @return array RobHub API response.
*/
function ai4seo_call_attachment_attributes_generation_api(
array $attachment_image_source,
array $robhub_api_call_parameters,
?array $verified_image = null
): array {
// Validate the complete descriptor before either transport can allocate or fetch image data.
$original_url = (string) ( $attachment_image_source['original_url'] ?? '' );
$delivery_url = (string) ( $attachment_image_source['delivery_url'] ?? '' );
$mime_type = (string) ( $attachment_image_source['mime_type'] ?? '' );
if ( ! $original_url || ! $delivery_url || ! $mime_type ) {
return array(
'success' => false,
'message' => 'Attachment image source is incomplete',
'code' => 361324726,
);
}
$settings_snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_SETTINGS_OPTION_NAME );
$attachment_id = (int) ( $attachment_image_source['attachment_id'] ?? 0 );
// The choice governs the original attachment format, even when WordPress serves a converted raster sub-size.
$attachment_mime_type = $attachment_id > 0 ? ai4seo_get_attachment_post_mime_type( $attachment_id ) : $mime_type;
if ( null !== $verified_image ) {
$attachment_mime_type = $verified_image['original_mime_type'];
}
if ( null === $settings_snapshot || ! in_array( $attachment_mime_type, ai4seo_get_enabled_attachment_mime_types( $settings_snapshot ), true ) ) {
return array(
'success' => false,
'message' => __( 'Generation for this file type is disabled or unavailable. Check Enabled File Types in Media Attributes settings. SVG also requires an available conversion service.', 'ai-for-seo' ),
'code' => 121026001,
);
}
if ( 'image/svg+xml' === $mime_type ) {
$svg = null !== $verified_image ? $verified_image['data'] : ai4seo_read_attachment_svg_source( $attachment_image_source );
if ( is_wp_error( $svg ) ) {
return array(
'success' => false,
'message' => $svg->get_error_message(),
'code' => 121026002,
);
}
unset( $robhub_api_call_parameters['attachment_url'], $robhub_api_call_parameters['attachment_recovery_token'] );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encode bounded source bytes for the existing image-data transport contract.
$robhub_api_call_parameters['content'] = 'data:image/svg+xml;base64,' . base64_encode( $svg );
$robhub_api_call_parameters['reference_attachment_url'] = $original_url;
return ai4seo_robhub_api()->call( 'ai4seo/generate-all-attachment-attributes', $robhub_api_call_parameters );
}
// Apply the existing user/automatic transport policy to the selected delivery URL rather than the full reference URL.
if ( ai4seo_should_use_base64_image( $delivery_url ) ) {
return ai4seo_generate_attachment_attributes_using_base64(
$attachment_image_source,
$robhub_api_call_parameters,
$verified_image
);
}
// Only URL requests started in Auto mode contribute to the persistent recovery history.
$should_track_url_recovery = 'auto' === ai4seo_get_setting( AI4SEO_SETTING_IMAGE_UPLOAD_METHOD );
// Send both identities so RobHub can analyze the delivery variant while retaining full-image context.
$robhub_api_call_parameters['attachment_url'] = $delivery_url;
$robhub_api_call_parameters['reference_attachment_url'] = $original_url;
// Keep the existing URL-first request as the normal path; only RobHub can authorize a distinct
// local-source continuation after it proves no model attempt began.
$url_response = ai4seo_robhub_api()->call( 'ai4seo/generate-all-attachment-attributes', $robhub_api_call_parameters );
$continuation_token = ai4seo_get_attachment_base64_recovery_token( $url_response );
// Both URL success and failures without an authorized continuation break a recovery streak.
if ( ! $continuation_token ) {
// Manual URL mode must not alter the history reserved for automatic transport selection.
if ( $should_track_url_recovery ) {
ai4seo_record_attachment_url_generation_result( false );
}
return $url_response;
}
// This is the sole client-side continuation. Never feed attachment_url back into the
// continuation, and never recurse after a base64 request fails.
unset( $robhub_api_call_parameters['attachment_url'] );
$robhub_api_call_parameters['attachment_recovery_token'] = $continuation_token;
$base64_response = ai4seo_generate_attachment_attributes_using_base64(
$attachment_image_source,
$robhub_api_call_parameters,
$verified_image
);
// Count only a successful URL-to-Base64 pair; preparation and continuation failures reset history.
if ( $should_track_url_recovery ) {
ai4seo_record_attachment_url_generation_result( ai4seo_robhub_api()->was_call_successful( $base64_response ) );
}
return $base64_response;
}
/**
* Prefer direct image data after three consecutive successful URL-to-Base64 recoveries.
*
* @param bool $recovered Whether a server-authorized Base64 continuation succeeded.
* @return void
*/
function ai4seo_record_attachment_url_generation_result( bool $recovered ): void {
// A manual transport preference must not accumulate history or trigger an automatic switch.
if ( 'auto' !== ai4seo_get_setting( AI4SEO_SETTING_IMAGE_UPLOAD_METHOD ) ) {
return;
}
// Recalculate inside the existing CAS retry so concurrent generations cannot lose increments.
if (
! ai4seo_mutate_environmental_variable_value(
AI4SEO_ENVIRONMENTAL_VARIABLE_ATTACHMENT_BASE64_RECOVERY_STREAK,
static function ( $current_streak ) use ( $recovered ): int {
// Bound retries at the switch threshold until the preference write succeeds.
return $recovered ? min( 3, (int) $current_streak + 1 ) : 0;
},
false
)
) {
ai4seo_debug_message( 709071001, 'Could not persist the attachment Base64 recovery streak.', true );
return;
}
// Saturate at three so a failed preference write can be retried by the next successful recovery.
if ( ! $recovered || 3 !== (int) ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_ATTACHMENT_BASE64_RECOVERY_STREAK ) ) {
return;
}
// Generation can outlive a settings save; refresh both caches before changing the current preference.
ai4seo_invalidate_option_cache( AI4SEO_SETTINGS_OPTION_NAME );
if ( ! ai4seo_reset_settings_request_cache_for_current_site() ) {
ai4seo_debug_message( 709071002, 'Could not refresh image upload settings after Base64 recovery.', true );
return;
}
// A failed read must never become a default settings snapshot that can overwrite saved configuration.
$settings_snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_SETTINGS_OPTION_NAME );
if ( null === $settings_snapshot ) {
ai4seo_debug_message( 709071007, 'Could not read authoritative image upload settings after Base64 recovery.', true );
return;
}
// Retain the extra-serialization and JSON formats already supported by settings hydration.
$persisted_settings = $settings_snapshot['exists'] ? $settings_snapshot['value'] : array();
$persisted_settings = ai4seo_safe_maybe_unserialize( $persisted_settings );
// Decode JSON only when the serialization pass still leaves a textual settings value.
if ( is_string( $persisted_settings ) && ai4seo_is_json( $persisted_settings ) ) {
$persisted_settings = json_decode( $persisted_settings, true );
}
// Preserve malformed storage for its owner instead of replacing it during image generation.
if ( ! is_array( $persisted_settings ) ) {
ai4seo_debug_message( 709071008, 'Invalid stored settings prevented the automatic image upload switch.', true );
return;
}
// Only an absent key inherits Auto; explicit preferences and malformed values must remain untouched.
$image_upload_method = array_key_exists( AI4SEO_SETTING_IMAGE_UPLOAD_METHOD, $persisted_settings )
? $persisted_settings[ AI4SEO_SETTING_IMAGE_UPLOAD_METHOD ]
: AI4SEO_DEFAULT_SETTINGS[ AI4SEO_SETTING_IMAGE_UPLOAD_METHOD ];
if ( 'auto' !== $image_upload_method ) {
return;
}
// Change only the transport field and let any concurrent settings save win the exact snapshot comparison.
$persisted_settings[ AI4SEO_SETTING_IMAGE_UPLOAD_METHOD ] = 'base64';
$settings_were_updated = ai4seo_compare_and_swap_option_snapshot(
AI4SEO_SETTINGS_OPTION_NAME,
$settings_snapshot,
$persisted_settings,
true
);
// Discard request-local state after either outcome without publishing an older settings snapshot.
ai4seo_invalidate_option_cache( AI4SEO_SETTINGS_OPTION_NAME );
ai4seo_reset_settings_request_cache_for_current_site();
// Keep the saturated streak for a later recovery; a lost race is an ordinary administrative save.
if ( true !== $settings_were_updated ) {
// Only a persistence failure needs a diagnostic; losing the snapshot race is expected.
if ( null === $settings_were_updated ) {
ai4seo_debug_message( 709071003, 'Could not switch image uploads to Base64 after repeated successful recovery.', true );
}
return;
}
// Clear the completed streak only after the new transport preference has been stored.
if ( ! ai4seo_update_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_ATTACHMENT_BASE64_RECOVERY_STREAK, 0 ) ) {
ai4seo_debug_message( 709071004, 'Could not clear the attachment recovery streak after switching to Base64.', true );
}
}
/**
* Read the narrowly-scoped base64 continuation contract from a failed API response.
*
* @param mixed $response Normalized RobHub API response.
* @return string Opaque continuation token, or an empty string when no continuation is allowed.
*/
function ai4seo_get_attachment_base64_recovery_token( $response ): string {
// Recovery metadata is meaningful only on a failed URL-mode request; a successful response
// must never prompt another generation request.
if ( ! is_array( $response ) || ( $response['success'] ?? false ) ) {
return '';
}
// Billing failures stop the account-scoped workflow even if malformed upstream metadata requests recovery.
if ( isset( $response['code'] )
&& ai4seo_robhub_api()->is_terminal_billing_error_code( $response['code'] )
) {
return '';
}
// Require the API's deliberately narrow contract rather than treating arbitrary error metadata
// as permission to retry, which preserves RobHub's coordinated model-attempt budget.
$recovery = $response['recovery'] ?? null;
if ( ! is_array( $recovery ) || 'base64' !== ( $recovery['method'] ?? '' ) ) {
return '';
}
// Validate the opaque token locally so an expired or malformed response fails without a second
// HTTP request; server-side binding remains the authoritative security check.
$token = $recovery['continuation_token'] ?? '';
$expires_at = $recovery['expires_at'] ?? 0;
if ( ! is_string( $token )
|| ! preg_match( '/^[A-Za-z0-9_-]{32,128}$/', $token )
|| ! is_numeric( $expires_at )
|| (int) $expires_at <= time() ) {
return '';
}
return $token;
}
/**
* Normalize an attachment image preparation stage for safe diagnostics.
*
* @param mixed $failure_stage Untrusted or internally supplied stage value.
* @return string Allowlisted stage, or an empty string when unknown.
*/
function ai4seo_normalize_attachment_image_preparation_failure_stage( $failure_stage ): string {
// Reject unexpected types before sanitization can coerce them into misleading diagnostics.
if ( ! is_string( $failure_stage ) ) {
return '';
}
// Keep only stages emitted by the bounded image-preparation pipeline.
$failure_stage = sanitize_key( $failure_stage );
$allowed_stages = array(
'decode_budget',
'image_editor',
'image_metadata',
'derivative_encode',
'derivative_metadata',
'derivative_mime',
'derivative_path',
'derivative_read',
'derivative_reopen',
'derivative_resize',
'derivative_save',
'derivative_size',
'loop_prevented',
);
if ( ! in_array( $failure_stage, $allowed_stages, true ) ) {
return '';
}
return $failure_stage;
}
/**
* Normalize the optional reason within a decode-budget failure.
*
* @param mixed $failure_reason Untrusted or internally supplied reason.
* @return string Allowlisted reason, or an empty string.
*/
function ai4seo_normalize_attachment_image_preparation_failure_reason( $failure_reason ): string {
return is_string( $failure_reason )
&& in_array( $failure_reason, array( 'pixel_limit', 'memory_limit', 'dimensions_unavailable' ), true )
? $failure_reason : '';
}
/**
* Preserve preparation diagnostics while returning an actionable generation error.
*
* @param array $preparation_response Local image preparation result.
* @return array Generation error with the established numeric code.
*/
function ai4seo_get_attachment_preparation_error_response( array $preparation_response ): array {
$code = (int) ( $preparation_response['code'] ?? 361324725 );
$stage = ai4seo_normalize_attachment_image_preparation_failure_stage( $preparation_response['failure_stage'] ?? '' );
$reason = 'decode_budget' === $stage
? ai4seo_normalize_attachment_image_preparation_failure_reason( $preparation_response['failure_reason'] ?? '' ) : '';
$result = array(
'success' => false,
'message' => $preparation_response['message'] ?? __( 'This image could not be prepared for processing. Try again or use another image.', 'ai-for-seo' ),
'code' => $code,
);
// Retain existing codes so account, queue, and retry decisions do not change with the wording.
if ( in_array( $code, array( 111324725, 131324725, 141324725, 581927126 ), true ) ) {
$result['code'] = 391014824;
$result['message'] = __( 'This image could not be prepared for processing. Try again or use another image.', 'ai-for-seo' );
if ( 'decode_budget' === $stage ) {
$result['message'] = __( 'This image could not be prepared within the safe processing limits. Try a smaller image.', 'ai-for-seo' );
if ( 'pixel_limit' === $reason ) {
$result['message'] = __( 'This image exceeds the safe processing limit. Use a smaller image or regenerate its WordPress image sizes, then try again.', 'ai-for-seo' );
} elseif ( 'memory_limit' === $reason ) {
$result['message'] = __( 'There is not enough available memory to prepare this image. Try a smaller image.', 'ai-for-seo' );
} elseif ( 'dimensions_unavailable' === $reason ) {
$result['message'] = __( 'The dimensions of this image could not be read. Re-save the image or use another image, then try again.', 'ai-for-seo' );
}
} elseif ( 581927126 === $code ) {
$result['message'] = __( 'The fetched content is not a valid image. Re-save the image or use another image, then try again.', 'ai-for-seo' );
} elseif ( 111324725 === $code ) {
$result['message'] = __( 'The image could not be loaded. Check that it is accessible, then try again.', 'ai-for-seo' );
}
}
if ( '' !== $stage ) {
$result['failure_stage'] = $stage;
}
if ( '' !== $reason ) {
$result['failure_reason'] = $reason;
}
return $result;
}
/**
* Build a structured oversized media response for local/base64 attachment processing.
*
* @param int $content_length The known source size in bytes.
* @return array
*/
function ai4seo_get_attachment_source_too_large_response( int $content_length = 0 ): array {
// Mirror the RobHub oversized-fetch error so the existing failed-attachment handling can persist this state.
$message = 'Content too large to fetch';
if ( $content_length > 0 ) {
$message .= ' (Content-Length: ' . $content_length . ' bytes)';
}
return array(
'success' => false,
'message' => $message,
'code' => 71214326,
);
}
/**
* Convert a TLS certificate failure into the structured attachment response contract.
*
* @param WP_Error $error The actionable TLS verification error.
* @return array
*/
function ai4seo_get_attachment_source_tls_error_response( WP_Error $error ): array {
// Preserve the transport error context because failed-attachment diagnostics consume each field.
return array(
'success' => false,
'message' => $error->get_error_message(),
'code' => 101324725,
'error_code' => $error->get_error_code(),
'error_data' => $error->get_error_data(),
);
}
/**
* Load an image and return its base64 conversion result.
*
* @param string $image_url Image URL.
* @param string $local_image_path Optional validated attachment file path.
* @param array|null $verified_image Bounded bytes from manual preflight.
* @return array Image conversion result.
*/
function ai4seo_get_base64_from_image_file( $image_url, string $local_image_path = '', ?array $verified_image = null ): array {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 697474987, 'Prevented loop', true );
return array(
'success' => false,
'message' => 'Infinite loop detected',
'code' => 91234725,
);
}
// Keep source-size policy and attachment response codes outside the generic image loader.
$image_source = $verified_image ?? ( '' !== $local_image_path
? ai4seo_get_image_data_from_local_file( $local_image_path, AI4SEO_MAX_BASE64_ATTACHMENT_SOURCE_SIZE_BYTES )
: null );
// Retain remote recovery for missing/unreadable/corrupt local copies, but never bypass the source-size cap.
if ( null === $image_source
|| ( is_wp_error( $image_source ) && 'ai4seo_fetch_too_large' !== $image_source->get_error_code() ) ) {
$image_source = ai4seo_get_image_data_from_url( $image_url, AI4SEO_MAX_BASE64_ATTACHMENT_SOURCE_SIZE_BYTES );
}
if ( is_wp_error( $image_source ) ) {
$error_code = $image_source->get_error_code();
if ( 'ai4seo_fetch_too_large' === $error_code ) {
$content_length = $image_source->get_error_data();
return ai4seo_get_attachment_source_too_large_response( is_int( $content_length ) ? $content_length : 0 );
}
if ( 'ai4seo_tls_verification_failed' === $error_code ) {
return ai4seo_get_attachment_source_tls_error_response( $image_source );
}
if ( in_array( $error_code, array( 'ai4seo_image_source_empty', 'ai4seo_image_source_invalid' ), true ) ) {
return array(
'success' => false,
'message' => $image_source->get_error_message(),
'code' => 'ai4seo_image_source_empty' === $error_code ? 111324725 : 581927126,
);
}
return array(
'success' => false,
'message' => 'Media URL not accessible: ' . $image_source->get_error_message(),
'code' => 'ai4seo_image_fetch_exception' === $error_code ? 91324725 : 101324725,
);
}
$image_body = $image_source['data'];
$source_mime_type = $image_source['mime_type'];
// Encode the attachment while collecting the actual post-conversion MIME for the data URI.
$encoded_mime_type = $source_mime_type;
$failure_stage = '';
$failure_reason = '';
try {
$attachment_base64 = ai4seo_smart_image_base64_encode(
$image_body,
$source_mime_type,
$encoded_mime_type,
$failure_stage,
$failure_reason
);
} catch ( Exception $e ) {
return array(
'success' => false,
'message' => 'Media content could not be base64 encoded: ' . $e->getMessage(),
'code' => 131324725,
'failure_stage' => 'derivative_encode',
);
}
if ( ! $attachment_base64 ) {
// Normalize the by-reference stage and retain the established generic fallback for unknown failures.
$failure_stage = ai4seo_normalize_attachment_image_preparation_failure_stage( $failure_stage );
$failure_stage = '' !== $failure_stage ? $failure_stage : 'derivative_encode';
return array(
'success' => false,
'message' => 'Media content could not be base64 encoded',
'code' => 141324725,
'failure_stage' => $failure_stage,
'failure_reason' => 'decode_budget' === $failure_stage
? ai4seo_normalize_attachment_image_preparation_failure_reason( $failure_reason ) : '',
);
}
return array(
'success' => true,
'data' => $attachment_base64,
'mime_type' => $encoded_mime_type,
);
}
/**
* Generates attachment attributes from a base64 representation of the selected image source.
*
* @param array $attachment_image_source Attachment image source descriptor.
* @param array $robhub_api_call_parameters RobHub API parameters.
* @param array|null $verified_image Bounded bytes from manual preflight; never client-supplied.
* @return array RobHub API response.
*/
function ai4seo_generate_attachment_attributes_using_base64(
array $attachment_image_source,
array $robhub_api_call_parameters,
?array $verified_image = null
): array {
// Retain the existing recursion safeguard now that manual and cron generation share this transport helper.
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 868051563, 'Prevented loop', true );
return array(
'success' => false,
'message' => 'Prevented infinite loop',
'code' => 361324724,
);
}
// Keep the full reference separate while fetching and encoding only the selected delivery source.
$original_url = (string) ( $attachment_image_source['original_url'] ?? '' );
$delivery_url = (string) ( $attachment_image_source['delivery_url'] ?? '' );
$mime_type = (string) ( $attachment_image_source['mime_type'] ?? '' );
$is_recovery_continuation = ! empty( $robhub_api_call_parameters['attachment_recovery_token'] );
// Reuse the bounded image fetcher so the established 25 MB source limit remains authoritative for base64.
$base64_from_image_file_response = ai4seo_get_base64_from_image_file(
$delivery_url,
null === $verified_image ? ai4seo_get_attachment_generation_local_file_path( $attachment_image_source ) : '',
$verified_image
);
// Normalize all fetch failures before building the data URI so callers receive the established error contract.
if ( ! isset( $base64_from_image_file_response['success'] ) || ! $base64_from_image_file_response['success']
|| ! isset( $base64_from_image_file_response['data'] ) || ! $base64_from_image_file_response['data'] ) {
if ( $is_recovery_continuation ) {
ai4seo_log_attachment_base64_recovery_preparation_failure( $base64_from_image_file_response );
}
return ai4seo_get_attachment_preparation_error_response( $base64_from_image_file_response );
}
// Build the data URI with the actual encoded MIME while retaining the canonical full URL
// for context and diagnostics.
$attachment_base64 = $base64_from_image_file_response['data'];
$encoded_mime_type = ai4seo_normalize_mime_type_string(
$base64_from_image_file_response['mime_type'] ?? ''
);
// Fall back to the selected source MIME only when the encoded response does not identify one.
if ( ! $encoded_mime_type ) {
$encoded_mime_type = $mime_type;
}
// A base64 request is final. In particular, a server-guided continuation must not retain the
// failed delivery URL, which also makes a token replay incapable of triggering URL retrieval.
unset( $robhub_api_call_parameters['attachment_url'] );
$robhub_api_call_parameters['reference_attachment_url'] = $original_url;
$robhub_api_call_parameters['content'] = "data:{$encoded_mime_type};base64,{$attachment_base64}";
return ai4seo_robhub_api()->call( 'ai4seo/generate-all-attachment-attributes', $robhub_api_call_parameters );
}
/**
* Create the safe diagnostic fields for a failed client-side Base64 recovery preparation.
*
* @param array $base64_response Image preparation response.
* @return array Safe stage, source error code, and optional decode-budget reason.
*/
function ai4seo_get_attachment_base64_recovery_preparation_failure_diagnostic( array $base64_response ): array {
$source_error_code = ( isset( $base64_response['code'] ) && is_numeric( $base64_response['code'] ) )
? (int) $base64_response['code']
: 0;
$stage = ai4seo_normalize_attachment_image_preparation_failure_stage(
$base64_response['failure_stage'] ?? ''
);
// Fall back to the legacy error-code classification only when no precise safe stage was supplied.
if ( '' === $stage ) {
$stage = 'unknown';
if ( in_array( $source_error_code, array( 91324725, 101324725, 111324725 ), true ) ) {
$stage = 'media_fetch_failed';
} elseif ( 71214326 === $source_error_code ) {
$stage = 'media_source_too_large';
} elseif ( 581927126 === $source_error_code ) {
$stage = 'media_validation_failed';
} elseif ( in_array( $source_error_code, array( 131324725, 141324725 ), true ) ) {
$stage = 'base64_encoding_failed';
} elseif ( 91234725 === $source_error_code ) {
$stage = 'loop_prevented';
}
}
$diagnostic = array(
'stage' => $stage,
'source_error_code' => $source_error_code,
);
$reason = 'decode_budget' === $stage
? ai4seo_normalize_attachment_image_preparation_failure_reason( $base64_response['failure_reason'] ?? '' ) : '';
if ( '' !== $reason ) {
$diagnostic['reason'] = $reason;
}
return $diagnostic;
}
/**
* Log a sanitized diagnostic when a server-authorized Base64 recovery cannot be submitted.
*
* @param array $base64_response Image preparation response.
* @return void
*/
function ai4seo_log_attachment_base64_recovery_preparation_failure( array $base64_response ): void {
$diagnostic = ai4seo_get_attachment_base64_recovery_preparation_failure_diagnostic( $base64_response );
// Never log the original URL, continuation token, server response, image bytes, or raw failure message.
ai4seo_debug_message(
870014824,
'Attachment Base64 recovery preparation failed before continuation submission (stage: '
. $diagnostic['stage']
. ( isset( $diagnostic['reason'] ) ? '; reason: ' . $diagnostic['reason'] : '' )
. '; source error code: '
. $diagnostic['source_error_code']
. ').'
);
}
/**
* Determine whether prepared output dimensions satisfy the model-input canvas limit.
*
* @param int $width Image width in pixels.
* @param int $height Image height in pixels.
* @param string|null $failure_reason Optional reason when the budget is exceeded.
* @return bool Whether the image can be submitted without further resizing.
*/
function ai4seo_image_dimensions_fit_model_output_budget( int $width, int $height, ?string &$failure_reason = null ): bool {
return ai4seo_image_dimensions_fit_output_budget(
$width,
$height,
AI4SEO_ATTACHMENT_GENERATION_MAX_IMAGE_DIMENSION,
$failure_reason
);
}
/**
* Read and independently validate a generated image derivative.
*
* The image editor's save result is advisory. The bytes on disk remain authoritative for size,
* MIME type, dimensions, and whether WordPress can decode the derivative again.
*
* @param string $derivative_path Local derivative path returned by the image editor.
* @param string $required_mime_type MIME type required for the derivative.
* @param string $reported_mime_type MIME type reported by the image editor.
* @param int $maximum_size_bytes Maximum permitted derivative size.
* @param string|null $failure_stage Safe failure-stage identifier.
* @param string|null $failure_reason Optional decode-budget reason.
* @return array|WP_Error Validated derivative data, or a normalized validation error.
*/
function ai4seo_read_validated_image_derivative(
string $derivative_path,
string $required_mime_type,
string $reported_mime_type,
int $maximum_size_bytes,
?string &$failure_stage = null,
?string &$failure_reason = null
) {
return ai4seo_read_image_derivative(
$derivative_path,
$required_mime_type,
$reported_mime_type,
$maximum_size_bytes,
AI4SEO_ATTACHMENT_GENERATION_MAX_IMAGE_DIMENSION,
$failure_stage,
$failure_reason
);
}
/**
* Create a model-compatible base64 image, converting or downsizing when necessary.
*
* @param string $image_data The image data to encode.
* @param string $source_mime_type MIME type detected from the source bytes.
* @param string|null $encoded_mime_type Actual MIME type of the encoded output.
* @param string|null $failure_stage Safe failure-stage identifier.
* @param string|null $failure_reason Optional decode-budget reason.
* @return string The base64-encoded image data, or an empty string on error.
*/
function ai4seo_smart_image_base64_encode(
string $image_data,
string $source_mime_type = '',
?string &$encoded_mime_type = null,
?string &$failure_stage = null,
?string &$failure_reason = null
): string {
// Default to the detected source MIME; a derivative branch replaces it with a compatible format below.
$encoded_mime_type = ai4seo_normalize_mime_type_string( $source_mime_type ) ?? '';
$failure_stage = '';
$failure_reason = '';
// Preserve the existing recursion guard before allocating temporary files or image-editor resources.
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
$failure_stage = 'loop_prevented';
ai4seo_debug_message( 241293986, 'Prevented loop', true );
return '';
}
// Preserve the generation policy while the image helper operates only on bytes and explicit limits.
return ai4seo_encode_image_data_to_base64(
$image_data,
$source_mime_type,
array(
'target_size_bytes' => AI4SEO_ATTACHMENT_GENERATION_TARGET_IMAGE_SIZE_BYTES,
'maximum_dimension' => AI4SEO_ATTACHMENT_GENERATION_MAX_IMAGE_DIMENSION,
'quality' => AI4SEO_ATTACHMENT_GENERATION_DERIVATIVE_QUALITY,
'maximum_attempts' => AI4SEO_ATTACHMENT_GENERATION_MAX_ENCODING_ATTEMPTS,
'derivative_mime_type' => 'image/avif' === $encoded_mime_type ? 'image/png' : 'image/jpeg',
'force_derivative' => 'image/avif' === $encoded_mime_type,
),
$encoded_mime_type,
$failure_stage,
$failure_reason
);
}
/**
* Reads the single provider row owned by an imported NextGEN entry.
*
* @param int $picture_id NextGEN picture ID.
* @return array|false Provider row, or false when it cannot be read unambiguously.
*/
function ai4seo_read_nextgen_attachment_attribute_sync_row( int $picture_id ) {
global $wpdb;
$picture_id = absint( $picture_id );
if ( $picture_id <= 0 ) {
return false;
}
$query = ai4seo_prepare_database_query(
'SELECT `pid`, `description`, `alttext`
FROM {{pictures_table}}
WHERE `pid` = {{picture_id}}
LIMIT 2',
array(
'pictures_table' => ai4seo_database_identifier_binding( 'table.nextgen_pictures' ),
'picture_id' => ai4seo_database_scalar_binding( '%d', $picture_id ),
)
);
if ( false === $query ) {
return false;
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed compiler owns the optional provider identifier and pid; synchronization must preflight and verify the current provider row around its one-shot write.
$rows = $wpdb->get_results( $query, ARRAY_A );
if ( $wpdb->last_error || ! is_array( $rows ) || 1 !== count( $rows ) ) {
return false;
}
return $rows[0];
}
/**
* Checks whether a database row contains the exact requested field values.
*
* @param array $row Database row.
* @param array $expected_values Expected field values.
* @return bool True when every expected field has the same stored string value.
*/
function ai4seo_attachment_attribute_row_matches_values( array $row, array $expected_values ): bool {
foreach ( $expected_values as $field => $expected_value ) {
if ( ! array_key_exists( $field, $row ) || (string) $expected_value !== (string) $row[ $field ] ) {
return false;
}
}
return true;
}
/**
* Compares and sets allowed provider fields for one imported NextGEN entry.
*
* @param int $picture_id NextGEN picture ID.
* @param array $desired_values Values to store.
* @param array $expected_values Values that must still be current for this write to own the change.
* @return array Result with succeeded, changed, and conflicted boolean keys.
*/
function ai4seo_compare_and_set_nextgen_attachment_attribute_sync_row( int $picture_id, array $desired_values, array $expected_values ): array {
global $wpdb;
$picture_id = absint( $picture_id );
$allowed_fields = array(
'description' => true,
'alttext' => true,
);
$result = array(
'succeeded' => false,
'changed' => false,
'conflicted' => false,
);
if (
$picture_id <= 0
|| ! $desired_values
|| array_diff_key( $desired_values, $allowed_fields )
|| array_diff_key( $expected_values, $allowed_fields )
|| array_keys( $desired_values ) !== array_keys( $expected_values )
) {
return $result;
}
// Canonical field order selects one of the closed query templates below.
$normalized_desired_values = array();
$normalized_expected_values = array();
foreach ( array_keys( $allowed_fields ) as $field ) {
if ( array_key_exists( $field, $desired_values ) ) {
$normalized_desired_values[ $field ] = $desired_values[ $field ];
$normalized_expected_values[ $field ] = $expected_values[ $field ];
}
}
$query_templates = array(
'description' => 'UPDATE {{pictures_table}} SET `description` = {{desired_description}} WHERE `pid` = {{picture_id}} AND BINARY `description` = BINARY {{expected_description}} LIMIT 1',
'alttext' => 'UPDATE {{pictures_table}} SET `alttext` = {{desired_alttext}} WHERE `pid` = {{picture_id}} AND BINARY `alttext` = BINARY {{expected_alttext}} LIMIT 1',
'description,alttext' => 'UPDATE {{pictures_table}} SET `description` = {{desired_description}}, `alttext` = {{desired_alttext}} WHERE `pid` = {{picture_id}} AND BINARY `description` = BINARY {{expected_description}} AND BINARY `alttext` = BINARY {{expected_alttext}} LIMIT 1',
);
$query_key = implode( ',', array_keys( $normalized_desired_values ) );
$query_template = $query_templates[ $query_key ] ?? '';
if ( '' === $query_template ) {
return $result;
}
$bindings = array(
'pictures_table' => ai4seo_database_identifier_binding( 'table.nextgen_pictures' ),
'picture_id' => ai4seo_database_scalar_binding( '%d', $picture_id ),
);
foreach ( $normalized_desired_values as $field => $value ) {
$bindings[ 'desired_' . $field ] = ai4seo_database_scalar_binding( '%s', $value );
$bindings[ 'expected_' . $field ] = ai4seo_database_scalar_binding( '%s', $normalized_expected_values[ $field ] );
}
$query = ai4seo_prepare_database_query( $query_template, $bindings );
if ( false === $query ) {
return $result;
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- A closed typed template uses BINARY comparisons for byte-exact provider CAS; uncached readback distinguishes an equal no-op from a raced writer.
$update_result = $wpdb->query( $query );
if ( false === $update_result || $wpdb->last_error ) {
return $result;
}
$result['changed'] = (int) $update_result > 0;
$current_row = ai4seo_read_nextgen_attachment_attribute_sync_row( $picture_id );
if ( false === $current_row ) {
return $result;
}
if ( ai4seo_attachment_attribute_row_matches_values( $current_row, $normalized_desired_values ) ) {
$result['succeeded'] = true;
return $result;
}
$result['conflicted'] = true;
return $result;
}
/**
* Reads current WordPress-owned post columns without consulting the object cache.
*
* @param int $attachment_post_id Attachment post ID.
* @return array|false Current post row, or false on absence/database failure.
*/
function ai4seo_read_attachment_attribute_post_row( int $attachment_post_id ) {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
if ( $attachment_post_id <= 0 ) {
return false;
}
$query = ai4seo_prepare_database_query(
'SELECT `ID`, `post_title`, `post_excerpt`, `post_content`
FROM {{posts_table}}
WHERE `ID` = {{post_id}}
LIMIT 1',
array(
'posts_table' => ai4seo_database_identifier_binding( 'table.posts' ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
)
);
if ( false === $query ) {
return false;
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed compiler owns the post identifier and ID; compare-and-set verification must bypass the possibly stale post object cache.
$row = $wpdb->get_row( $query, ARRAY_A );
return $wpdb->last_error || ! is_array( $row ) ? false : $row;
}
/**
* Compares and sets WordPress-owned post columns used for attachment attributes.
*
* @param int $attachment_post_id Attachment post ID.
* @param array $desired_values Values to store.
* @param array $expected_values Values that must still be current for this write to own the change.
* @return array Result with succeeded, changed, and conflicted boolean keys.
*/
function ai4seo_compare_and_set_attachment_attribute_post_columns( int $attachment_post_id, array $desired_values, array $expected_values ): array {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
$allowed_columns = array(
'post_title' => true,
'post_excerpt' => true,
'post_content' => true,
);
$result = array(
'succeeded' => false,
'changed' => false,
'conflicted' => false,
);
if (
$attachment_post_id <= 0
|| ! $desired_values
|| array_diff_key( $desired_values, $allowed_columns )
|| array_diff_key( $expected_values, $allowed_columns )
|| array_keys( $desired_values ) !== array_keys( $expected_values )
) {
return $result;
}
// Canonical column order selects one of the closed query templates below.
$normalized_desired_values = array();
$normalized_expected_values = array();
foreach ( array_keys( $allowed_columns ) as $column ) {
if ( array_key_exists( $column, $desired_values ) ) {
$normalized_desired_values[ $column ] = $desired_values[ $column ];
$normalized_expected_values[ $column ] = $expected_values[ $column ];
}
}
$query_templates = array(
'post_title' => 'UPDATE {{posts_table}} SET `post_title` = {{desired_post_title}} WHERE `ID` = {{post_id}} AND BINARY `post_title` = BINARY {{expected_post_title}} LIMIT 1',
'post_excerpt' => 'UPDATE {{posts_table}} SET `post_excerpt` = {{desired_post_excerpt}} WHERE `ID` = {{post_id}} AND BINARY `post_excerpt` = BINARY {{expected_post_excerpt}} LIMIT 1',
'post_content' => 'UPDATE {{posts_table}} SET `post_content` = {{desired_post_content}} WHERE `ID` = {{post_id}} AND BINARY `post_content` = BINARY {{expected_post_content}} LIMIT 1',
'post_title,post_excerpt' => 'UPDATE {{posts_table}} SET `post_title` = {{desired_post_title}}, `post_excerpt` = {{desired_post_excerpt}} WHERE `ID` = {{post_id}} AND BINARY `post_title` = BINARY {{expected_post_title}} AND BINARY `post_excerpt` = BINARY {{expected_post_excerpt}} LIMIT 1',
'post_title,post_content' => 'UPDATE {{posts_table}} SET `post_title` = {{desired_post_title}}, `post_content` = {{desired_post_content}} WHERE `ID` = {{post_id}} AND BINARY `post_title` = BINARY {{expected_post_title}} AND BINARY `post_content` = BINARY {{expected_post_content}} LIMIT 1',
'post_excerpt,post_content' => 'UPDATE {{posts_table}} SET `post_excerpt` = {{desired_post_excerpt}}, `post_content` = {{desired_post_content}} WHERE `ID` = {{post_id}} AND BINARY `post_excerpt` = BINARY {{expected_post_excerpt}} AND BINARY `post_content` = BINARY {{expected_post_content}} LIMIT 1',
'post_title,post_excerpt,post_content' => 'UPDATE {{posts_table}} SET `post_title` = {{desired_post_title}}, `post_excerpt` = {{desired_post_excerpt}}, `post_content` = {{desired_post_content}} WHERE `ID` = {{post_id}} AND BINARY `post_title` = BINARY {{expected_post_title}} AND BINARY `post_excerpt` = BINARY {{expected_post_excerpt}} AND BINARY `post_content` = BINARY {{expected_post_content}} LIMIT 1',
);
$query_key = implode( ',', array_keys( $normalized_desired_values ) );
$query_template = $query_templates[ $query_key ] ?? '';
if ( '' === $query_template ) {
return $result;
}
$bindings = array(
'posts_table' => ai4seo_database_identifier_binding( 'table.posts' ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
);
foreach ( $normalized_desired_values as $column => $value ) {
$bindings[ 'desired_' . $column ] = ai4seo_database_scalar_binding( '%s', $value );
$bindings[ 'expected_' . $column ] = ai4seo_database_scalar_binding( '%s', $normalized_expected_values[ $column ] );
}
$query = ai4seo_prepare_database_query( $query_template, $bindings );
if ( false === $query ) {
return $result;
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- A closed typed template uses BINARY comparisons for byte-exact post CAS while preserving unslashed storage; uncached readback distinguishes an equal no-op from a raced writer.
$update_result = $wpdb->query( $query );
if ( false === $update_result || $wpdb->last_error ) {
return $result;
}
$result['changed'] = (int) $update_result > 0;
$current_row = ai4seo_read_attachment_attribute_post_row( $attachment_post_id );
if ( false === $current_row ) {
return $result;
}
if ( ai4seo_attachment_attribute_row_matches_values( $current_row, $normalized_desired_values ) ) {
$result['succeeded'] = true;
return $result;
}
$result['conflicted'] = true;
return $result;
}
/**
* Reads the exact WordPress alt-text metadata state without consulting the object cache.
*
* @param int $attachment_post_id Attachment post ID.
* @return array|false State with exists, meta_id, and value keys, or false on ambiguity/failure.
*/
function ai4seo_read_attachment_alt_text_postmeta_state( int $attachment_post_id ) {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
if ( $attachment_post_id <= 0 ) {
return false;
}
$query = ai4seo_prepare_database_query(
'SELECT `meta_id`, `meta_value`
FROM {{postmeta_table}}
WHERE `post_id` = {{post_id}}
AND `meta_key` = {{meta_key}}
ORDER BY `meta_id` ASC
LIMIT 2',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'meta_key' => ai4seo_database_scalar_binding( '%s', '_wp_attachment_image_alt' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The exact key is bounded by one post ID and two rows to establish an unambiguous CAS owner.
)
);
if ( false === $query ) {
return false;
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed compiler owns the core identifier and exact key/ID values; this one-shot preflight must bypass a possibly stale metadata cache.
$rows = $wpdb->get_results( $query, ARRAY_A );
if ( $wpdb->last_error || ! is_array( $rows ) || count( $rows ) > 1 ) {
return false;
}
if ( ! $rows ) {
return array(
'exists' => false,
'meta_id' => 0,
'value' => '',
);
}
$meta_id = absint( $rows[0]['meta_id'] ?? 0 );
if ( $meta_id <= 0 || ! array_key_exists( 'meta_value', $rows[0] ) ) {
return false;
}
return array(
'exists' => true,
'meta_id' => $meta_id,
'value' => (string) $rows[0]['meta_value'],
);
}
/**
* Reads one exact attachment alt-text metadata owner by its primary key.
*
* @param int $attachment_post_id Attachment post ID.
* @param int $meta_id Postmeta primary key.
* @return array Result with succeeded, exists, and value keys.
*/
function ai4seo_read_attachment_alt_text_postmeta_row_by_id( int $attachment_post_id, int $meta_id ): array {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
$meta_id = absint( $meta_id );
$result = array(
'succeeded' => false,
'exists' => false,
'value' => '',
);
if ( $attachment_post_id <= 0 || $meta_id <= 0 ) {
return $result;
}
$query = ai4seo_prepare_database_query(
'SELECT `meta_id`, `meta_value`
FROM {{postmeta_table}}
WHERE `meta_id` = {{meta_id}}
AND `post_id` = {{post_id}}
AND `meta_key` = {{meta_key}}
LIMIT 1',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'meta_id' => ai4seo_database_scalar_binding( '%d', $meta_id ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'meta_key' => ai4seo_database_scalar_binding( '%s', '_wp_attachment_image_alt' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The primary meta ID and post ID bound this ownership verification to one row.
)
);
if ( false === $query ) {
return $result;
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed compiler owns the core identifier and exact primary/key/post bindings; rollback verification must bypass stale metadata caches.
$row = $wpdb->get_row( $query, ARRAY_A );
if ( $wpdb->last_error ) {
return $result;
}
$result['succeeded'] = true;
if ( is_array( $row ) && absint( $row['meta_id'] ?? 0 ) === $meta_id && array_key_exists( 'meta_value', $row ) ) {
$result['exists'] = true;
$result['value'] = (string) $row['meta_value'];
}
return $result;
}
/**
* Compares and sets one attachment alt-text row against an exact preflight state.
*
* Missing rows use a guarded insert. Existing rows use their captured ID and a byte-exact value
* predicate. A zero-row result is accepted only when one unambiguous row already has the desired
* bytes; every other zero-row result is a concurrent-write conflict.
*
* @param int $attachment_post_id Attachment post ID.
* @param string $desired_value Desired unslashed alt text.
* @param array $expected_state Exact state returned by ai4seo_read_attachment_alt_text_postmeta_state().
* @return array Result with succeeded, changed, conflicted, and ownership keys.
*/
function ai4seo_compare_and_set_attachment_alt_text_postmeta( int $attachment_post_id, string $desired_value, array $expected_state ): array {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
$meta_key = '_wp_attachment_image_alt';
$result = array(
'succeeded' => false,
'changed' => false,
'conflicted' => false,
'ownership' => array(),
);
if (
$attachment_post_id <= 0
|| ! array_key_exists( 'exists', $expected_state )
|| ! is_bool( $expected_state['exists'] )
|| ! array_key_exists( 'meta_id', $expected_state )
|| ! array_key_exists( 'value', $expected_state )
) {
return $result;
}
// Match update_post_meta(): sanitize the unslashed request before its authoritative update filter.
$meta_subtype = get_object_subtype( 'post', $attachment_post_id );
$update_hook_value = sanitize_meta( $meta_key, $desired_value, 'post', $meta_subtype );
$update_check = apply_filters(
'update_post_metadata',
null,
$attachment_post_id,
$meta_key,
$update_hook_value,
''
);
// A non-null metadata filter owns the operation exactly as it does in WordPress core.
if ( null !== $update_check ) {
$result['succeeded'] = (bool) $update_check;
return $result;
}
if ( $expected_state['exists'] ) {
$expected_meta_id = absint( $expected_state['meta_id'] );
if ( $expected_meta_id <= 0 ) {
return $result;
}
$hook_value = $update_hook_value;
$serialized_value = maybe_serialize( $hook_value );
$stored_value = (string) $serialized_value;
// WordPress returns before update actions when the one existing value already matches.
if ( $stored_value === (string) $expected_state['value'] ) {
$result['succeeded'] = true;
return $result;
}
$query = ai4seo_prepare_database_query(
'UPDATE {{postmeta_table}}
SET `meta_value` = {{desired_value}}
WHERE `meta_id` = {{meta_id}}
AND `post_id` = {{post_id}}
AND `meta_key` = {{meta_key}}
AND BINARY `meta_value` = BINARY {{expected_value}}
LIMIT 1',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'desired_value' => ai4seo_database_scalar_binding( '%s', $stored_value ),
'meta_id' => ai4seo_database_scalar_binding( '%d', $expected_meta_id ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'meta_key' => ai4seo_database_scalar_binding( '%s', $meta_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The primary meta ID and post ID bound this byte-exact ownership check to one row.
'expected_value' => ai4seo_database_scalar_binding( '%s', (string) $expected_state['value'] ),
)
);
} else {
// update_metadata() delegates a missing owner to add_metadata(), which sanitizes and filters again.
$hook_value = sanitize_meta( $meta_key, $desired_value, 'post', $meta_subtype );
$add_check = apply_filters(
'add_post_metadata',
null,
$attachment_post_id,
$meta_key,
$hook_value,
false
);
if ( null !== $add_check ) {
$result['succeeded'] = (bool) $add_check;
return $result;
}
$serialized_value = maybe_serialize( $hook_value );
$stored_value = (string) $serialized_value;
$query = ai4seo_prepare_database_query(
'INSERT INTO {{postmeta_insert_table}} (`post_id`, `meta_key`, `meta_value`)
SELECT {{insert_post_id}}, {{insert_meta_key}}, {{insert_meta_value}}
WHERE NOT EXISTS (
SELECT 1
FROM {{postmeta_exists_table}}
WHERE `post_id` = {{existing_post_id}}
AND `meta_key` = {{existing_meta_key}}
)',
array(
'postmeta_insert_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'insert_post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'insert_meta_key' => ai4seo_database_scalar_binding( '%s', $meta_key ),
'insert_meta_value' => ai4seo_database_scalar_binding( '%s', $stored_value ),
'postmeta_exists_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'existing_post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'existing_meta_key' => ai4seo_database_scalar_binding( '%s', $meta_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The exact key and post ID guard the single-row insert against a concurrent metadata owner.
)
);
}
if ( false === $query ) {
return $result;
}
// Reproduce core's pre-write actions only after every filter has allowed the direct mutation.
if ( $expected_state['exists'] ) {
do_action( 'update_post_meta', $expected_meta_id, $attachment_post_id, $meta_key, $hook_value );
do_action( 'update_postmeta', $expected_meta_id, $attachment_post_id, $meta_key, $serialized_value );
} else {
do_action( 'add_post_meta', $attachment_post_id, $meta_key, $hook_value );
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Closed typed templates perform the missing/exact-value CAS; caller-owned cache invalidation follows every mutation or observed conflict.
$write_result = $wpdb->query( $query );
if ( false === $write_result || $wpdb->last_error ) {
return $result;
}
if ( (int) $write_result > 1 ) {
return $result;
}
$result['changed'] = 1 === (int) $write_result;
if ( $result['changed'] ) {
if ( $expected_state['exists'] ) {
$result['ownership'] = array(
'type' => 'update',
'meta_id' => $expected_meta_id,
'previous_value' => (string) $expected_state['value'],
'previous_hook_value' => (string) $expected_state['value'],
'written_value' => $stored_value,
'written_hook_value' => $hook_value,
);
} else {
$inserted_meta_id = absint( $wpdb->insert_id );
if ( $inserted_meta_id <= 0 ) {
return $result;
}
$result['ownership'] = array(
'type' => 'insert',
'meta_id' => $inserted_meta_id,
'previous_value' => '',
'previous_hook_value' => '',
'written_value' => $stored_value,
'written_hook_value' => $hook_value,
);
}
// Match WordPress cache/action order so post-write observers read the committed value.
wp_cache_delete( $attachment_post_id, 'post_meta' );
if ( $expected_state['exists'] ) {
do_action( 'updated_post_meta', $expected_meta_id, $attachment_post_id, $meta_key, $hook_value );
do_action( 'updated_postmeta', $expected_meta_id, $attachment_post_id, $meta_key, $serialized_value );
} else {
do_action( 'added_post_meta', $inserted_meta_id, $attachment_post_id, $meta_key, $hook_value );
}
}
$current_state = ai4seo_read_attachment_alt_text_postmeta_state( $attachment_post_id );
if ( false === $current_state ) {
if ( 0 === (int) $write_result && ! $wpdb->last_error ) {
$result['conflicted'] = true;
}
return $result;
}
if ( $current_state['exists'] && $stored_value === $current_state['value'] ) {
$result['succeeded'] = true;
return $result;
}
$result['conflicted'] = true;
return $result;
}
/**
* Reverses only the exact alt-text postmeta mutation owned by one failed operation.
*
* Updated rows are restored only while their current bytes still match the operation's write.
* Inserted rows are deleted only by their captured primary key and exact written bytes. Absence is
* accepted because a concurrent delete also removes this operation's partial state.
*
* @param int $attachment_post_id Attachment post ID.
* @param array $ownership Exact ownership returned by ai4seo_compare_and_set_attachment_alt_text_postmeta().
* @return bool True when this operation's alt-text mutation is absent or restored.
*/
function ai4seo_reverse_attachment_alt_text_postmeta_write( int $attachment_post_id, array $ownership ): bool {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
$ownership_type = (string) ( $ownership['type'] ?? '' );
$meta_id = absint( $ownership['meta_id'] ?? 0 );
$meta_key = '_wp_attachment_image_alt';
$previous_value = (string) ( $ownership['previous_value'] ?? '' );
$written_value = (string) ( $ownership['written_value'] ?? '' );
$previous_hook_value = array_key_exists( 'previous_hook_value', $ownership )
? $ownership['previous_hook_value']
: $previous_value;
$written_hook_value = array_key_exists( 'written_hook_value', $ownership )
? $ownership['written_hook_value']
: $written_value;
if ( $attachment_post_id <= 0 || $meta_id <= 0 || ! in_array( $ownership_type, array( 'insert', 'update' ), true ) ) {
return false;
}
if ( 'update' === $ownership_type ) {
$meta_subtype = get_object_subtype( 'post', $attachment_post_id );
$rollback_hook_value = sanitize_meta( $meta_key, $previous_hook_value, 'post', $meta_subtype );
$rollback_check = apply_filters(
'update_post_metadata',
null,
$attachment_post_id,
$meta_key,
$rollback_hook_value,
$written_hook_value
);
if ( null !== $rollback_check ) {
return (bool) $rollback_check;
}
// Restore the exact captured bytes; maybe_serialize() would double-serialize scalar text that resembles serialized data.
$serialized_rollback_value = $previous_value;
$restored_value = $previous_value;
$query = ai4seo_prepare_database_query(
'UPDATE {{postmeta_table}}
SET `meta_value` = {{previous_value}}
WHERE `meta_id` = {{meta_id}}
AND `post_id` = {{post_id}}
AND `meta_key` = {{meta_key}}
AND BINARY `meta_value` = BINARY {{written_value}}
LIMIT 1',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'previous_value' => ai4seo_database_scalar_binding( '%s', $restored_value ),
'meta_id' => ai4seo_database_scalar_binding( '%d', $meta_id ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'meta_key' => ai4seo_database_scalar_binding( '%s', $meta_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The primary meta ID and post ID bound this reverse CAS to the operation-owned row.
'written_value' => ai4seo_database_scalar_binding( '%s', $written_value ),
)
);
} else {
$rollback_hook_value = $written_hook_value;
$rollback_check = apply_filters(
'delete_post_metadata',
null,
$attachment_post_id,
$meta_key,
$rollback_hook_value,
false
);
if ( null !== $rollback_check ) {
return (bool) $rollback_check;
}
$query = ai4seo_prepare_database_query(
'DELETE FROM {{postmeta_table}}
WHERE `meta_id` = {{meta_id}}
AND `post_id` = {{post_id}}
AND `meta_key` = {{meta_key}}
AND BINARY `meta_value` = BINARY {{written_value}}
LIMIT 1',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'meta_id' => ai4seo_database_scalar_binding( '%d', $meta_id ),
'post_id' => ai4seo_database_scalar_binding( '%d', $attachment_post_id ),
'meta_key' => ai4seo_database_scalar_binding( '%s', $meta_key ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The primary meta ID and post ID restrict deletion to the operation-owned insert.
'written_value' => ai4seo_database_scalar_binding( '%s', $written_value ),
)
);
}
if ( false === $query ) {
return false;
}
// Do not announce a reverse mutation when a concurrent owner has already removed or replaced it.
$current_owner = ai4seo_read_attachment_alt_text_postmeta_row_by_id( $attachment_post_id, $meta_id );
if ( ! $current_owner['succeeded'] ) {
return false;
}
if ( ! $current_owner['exists'] ) {
return true;
}
if ( $written_value !== $current_owner['value'] ) {
return 'update' === $ownership_type && $restored_value === $current_owner['value'];
}
if ( 'update' === $ownership_type ) {
do_action( 'update_post_meta', $meta_id, $attachment_post_id, $meta_key, $rollback_hook_value );
do_action( 'update_postmeta', $meta_id, $attachment_post_id, $meta_key, $serialized_rollback_value );
} else {
$meta_ids = array( (string) $meta_id );
do_action( 'delete_post_meta', $meta_ids, $attachment_post_id, $meta_key, $rollback_hook_value );
do_action( 'delete_postmeta', $meta_ids );
}
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Closed typed reverse-CAS/delete templates target only the captured primary key and exact bytes; caller repairs caches after the attempt.
$reverse_result = $wpdb->query( $query );
if ( false === $reverse_result || $wpdb->last_error || (int) $reverse_result > 1 ) {
return false;
}
if ( 1 === (int) $reverse_result ) {
wp_cache_delete( $attachment_post_id, 'post_meta' );
if ( 'update' === $ownership_type ) {
do_action( 'updated_post_meta', $meta_id, $attachment_post_id, $meta_key, $rollback_hook_value );
do_action( 'updated_postmeta', $meta_id, $attachment_post_id, $meta_key, $serialized_rollback_value );
} else {
do_action( 'deleted_post_meta', $meta_ids, $attachment_post_id, $meta_key, $rollback_hook_value );
do_action( 'deleted_postmeta', $meta_ids );
}
}
$current_owner = ai4seo_read_attachment_alt_text_postmeta_row_by_id( $attachment_post_id, $meta_id );
if ( ! $current_owner['succeeded'] ) {
return false;
}
if ( ! $current_owner['exists'] ) {
return true;
}
return 'update' === $ownership_type && $restored_value === $current_owner['value'];
}
// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
/**
* Returns the bounded advisory-lock name for one site's attachment alt-text owner.
*
* @param int $attachment_post_id Attachment post ID.
* @return string Site/post/meta-scoped lock name, or an empty string for invalid input.
*/
function ai4seo_get_attachment_alt_text_postmeta_lock_name( int $attachment_post_id ): string {
global $wpdb;
$attachment_post_id = absint( $attachment_post_id );
if ( $attachment_post_id <= 0 ) {
return '';
}
$database_name = defined( 'DB_NAME' ) ? (string) DB_NAME : '';
$scope_hash = substr( hash( 'sha256', $database_name . '|' . (string) $wpdb->prefix . '|' . absint( get_current_blog_id() ) ), 0, 32 );
// The database/prefix/site hash prevents server-wide GET_LOCK collisions; the longest signed ID keeps this at 63 bytes.
return 'ai4seo_alt_' . $scope_hash . '_' . $attachment_post_id;
}
/**
* Serializes one alt-text CAS and reverses any unverifiable owned write before releasing the lock.
*
* @param int $attachment_post_id Attachment post ID.
* @param string $desired_value Desired unslashed alt text.
* @param array $expected_state Exact preflight state.
* @return array CAS result plus rollback_attempted and rollback_succeeded keys.
*/
function ai4seo_write_attachment_alt_text_postmeta_with_lock( int $attachment_post_id, string $desired_value, array $expected_state ): array {
$lock_name = ai4seo_get_attachment_alt_text_postmeta_lock_name( $attachment_post_id );
$result = array(
'succeeded' => false,
'changed' => false,
'conflicted' => false,
'ownership' => array(),
'rollback_attempted' => false,
'rollback_succeeded' => true,
);
if ( '' === $lock_name || ! ai4seo_acquire_database_advisory_lock( $lock_name ) ) {
return $result;
}
try {
$result = ai4seo_compare_and_set_attachment_alt_text_postmeta( $attachment_post_id, $desired_value, $expected_state );
$result['rollback_attempted'] = false;
$result['rollback_succeeded'] = true;
if ( ! $result['succeeded'] && $result['ownership'] ) {
$result['rollback_attempted'] = true;
$result['rollback_succeeded'] = ai4seo_reverse_attachment_alt_text_postmeta_write( $attachment_post_id, $result['ownership'] );
}
return $result;
} finally {
if ( ! ai4seo_release_database_advisory_lock( $lock_name ) ) {
ai4seo_debug_message( 984321724, 'Could not release the attachment alt-text database advisory lock.', true );
}
}
}
/**
* Updates the currently active attachment attributes for an attachment.
*
* Multi-owner writes use provider, posts, then postmeta order. Every later failure attempts to
* restore earlier owners from captured values. If compensation itself fails, false remains an
* idempotent retry signal and caches are repaired for any WordPress post state that may remain.
*
* @param int $attachment_post_id the attachment post id.
* @param array $attachment_attribute_updates the updates to apply with the keys title, caption, description, alt-text.
* @param bool $force_overwrite_all_existing_data if true, existing data will be overwritten, if false, we check the settings to identify the attachment attributes that should be overwritten.
* @param array|null $operation_details Receives commit_state: not_committed, committed, or possibly_committed.
* @param array|null $expected_values Optional authoritative editor baseline for requested fields.
* @return bool true on success, false on failure
*/
function ai4seo_update_attachment_attributes(
int $attachment_post_id,
array $attachment_attribute_updates = array(),
bool $force_overwrite_all_existing_data = false,
?array &$operation_details = null,
?array $expected_values = null
): bool {
$attachment_field_descriptors = ai4seo_get_generation_field_descriptors( 'attachment_attributes' );
global $wpdb;
$operation_details = array(
'commit_state' => 'not_committed',
);
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 540510370, 'Prevented loop', true );
return false;
}
// Normalize API/editor values through the shared field sanitizer before selecting persistence owners.
$attachment_attribute_updates = ai4seo_deep_sanitize( $attachment_attribute_updates, 'ai4seo_sanitize_editor_field_value' );
// Apply field-specific overwrite settings unless the caller explicitly forces every owner.
$overwrite_existing_data_attachment_attributes_names = array();
if ( ! $force_overwrite_all_existing_data ) {
$overwrite_existing_data_attachment_attributes_names = ai4seo_normalize_attachment_attribute_identifier_list(
(array) ai4seo_get_setting( AI4SEO_SETTING_OVERWRITE_EXISTING_ATTACHMENT_ATTRIBUTES )
);
}
$ai4seo_active_attachment_attributes = ai4seo_get_active_attachment_attributes();
// Load the attachment once so owner selection and every CAS preflight share the same starting state.
$attachment_post = get_post( $attachment_post_id );
if ( ! $attachment_post ) {
return false;
}
// Collect every owner write before mutating any table.
$wordpress_owner_reached_desired_state = false;
$post_updates = array();
$original_post_values = array();
$postmeta_update_requested = false;
$postmeta_update_value = '';
$postmeta_expected_state = array();
$nextgen_gallery_updates = array();
$is_imported_nextgen_entry = ai4seo_is_plugin_or_theme_active( AI4SEO_THIRD_PARTY_PLUGIN_NEXTGEN_GALLERY )
&& AI4SEO_NEXTGEN_GALLERY_POST_TYPE === $attachment_post->post_type
&& absint( $attachment_post->post_parent ) > 0;
// Resolve requested fields into their WordPress and optional provider write sets before any mutation.
foreach ( $attachment_field_descriptors as $this_attachment_attribute_identifier => $this_attachment_attribute_details ) {
if ( ! in_array( $this_attachment_attribute_identifier, $ai4seo_active_attachment_attributes, true ) ) {
continue;
}
$this_api_identifier = $this_attachment_attribute_details['api-identifier'] ?? '';
if ( isset( $attachment_attribute_updates[ $this_attachment_attribute_identifier ] ) ) {
$this_attachment_attribute_value = $attachment_attribute_updates[ $this_attachment_attribute_identifier ];
} elseif ( $this_api_identifier && isset( $attachment_attribute_updates[ $this_api_identifier ] ) ) {
$this_attachment_attribute_value = $attachment_attribute_updates[ $this_api_identifier ];
} else {
continue;
}
// Resolve the caller-wide and field-specific overwrite policies before inspecting current values.
if ( true === $force_overwrite_all_existing_data ) {
$overwrite_this_attachment_attribute = true;
} else {
$overwrite_this_attachment_attribute = in_array( $this_attachment_attribute_identifier, $overwrite_existing_data_attachment_attributes_names, true );
}
// Normalize and bound every value with the same editor-field contract used by interactive saves.
$this_attachment_attribute_value = ai4seo_normalize_editor_input_value( $this_attachment_attribute_value );
$this_max_length = ai4seo_get_max_editor_input_length( $this_attachment_attribute_identifier );
$this_attachment_attribute_value = ai4seo_trim_string_to_length( $this_attachment_attribute_value, $this_max_length );
// Route post fields and alt text to their distinct persistence and CAS owners.
if ( in_array( $this_attachment_attribute_identifier, array( 'title', 'caption', 'description' ), true ) ) {
// Map public field identifiers to the exact wp_posts columns used by the closed CAS templates.
switch ( $this_attachment_attribute_identifier ) {
case 'title':
$this_post_column = 'post_title';
break;
case 'caption':
$this_post_column = 'post_excerpt';
break;
case 'description':
$this_post_column = 'post_content';
break;
default:
continue 2;
}
// Preserve nonempty existing values unless this field is explicitly overwrite-enabled.
if ( ! $overwrite_this_attachment_attribute && ! empty( $attachment_post->$this_post_column ) ) {
if ( $this_attachment_attribute_value === (string) $attachment_post->$this_post_column ) {
$wordpress_owner_reached_desired_state = true;
}
continue;
}
// Collect direct post updates together so unslashed bytes and exact rollback ownership stay aligned.
$post_updates[ $this_post_column ] = $this_attachment_attribute_value;
$original_post_values[ $this_post_column ] = (string) $attachment_post->$this_post_column;
if ( null !== $expected_values && ( $expected_values[ $this_attachment_attribute_identifier ] ?? null ) !== $original_post_values[ $this_post_column ] ) {
return false;
}
// Mirror imported NextGEN descriptions so provider and WordPress owners remain synchronized.
if ( $is_imported_nextgen_entry && 'description' === $this_attachment_attribute_identifier ) {
$nextgen_gallery_updates['description'] = $this_attachment_attribute_value;
}
} elseif ( 'alt-text' === $this_attachment_attribute_identifier ) {
// Capture an exact missing/value owner before any provider or WordPress mutation.
$postmeta_expected_state = ai4seo_read_attachment_alt_text_postmeta_state( $attachment_post_id );
if ( null !== $expected_values && ( $expected_values['alt-text'] ?? null ) !== ( $postmeta_expected_state['value'] ?? null ) ) {
return false;
}
if ( false === $postmeta_expected_state ) {
ai4seo_debug_message( 984321723, 'Could not preflight the exact attachment alt-text metadata owner: ' . $wpdb->last_error, true );
return false;
}
// Preserve the existing non-overwrite rule, including PHP's established empty-value semantics.
if ( ! $overwrite_this_attachment_attribute && $postmeta_expected_state['exists'] && ! empty( $postmeta_expected_state['value'] ) ) {
if ( $this_attachment_attribute_value === $postmeta_expected_state['value'] ) {
$wordpress_owner_reached_desired_state = true;
}
continue;
}
$postmeta_update_requested = true;
$postmeta_update_value = $this_attachment_attribute_value;
// Mirror imported NextGEN alt text under the same provider/WordPress synchronization contract.
if ( $is_imported_nextgen_entry ) {
$nextgen_gallery_updates['alttext'] = $this_attachment_attribute_value;
}
}
}
$nextgen_gallery_pid = absint( $attachment_post->post_parent );
$original_nextgen_gallery_values = array();
$nextgen_write_owned = false;
$post_write_owned = false;
$post_cache_repair_required = false;
$postmeta_cache_repair_required = false;
// A genuine imported entry must have exactly one current provider owner before any mutation begins.
if ( $nextgen_gallery_updates ) {
$nextgen_gallery_updates = ai4seo_deep_sanitize( $nextgen_gallery_updates );
$nextgen_gallery_row = ai4seo_read_nextgen_attachment_attribute_sync_row( $nextgen_gallery_pid );
if ( false === $nextgen_gallery_row ) {
ai4seo_debug_message( 984321689, 'Could not preflight the exact NextGEN picture row: ' . $wpdb->last_error, true );
return false;
}
foreach ( $nextgen_gallery_updates as $field => $value ) {
if ( ! array_key_exists( $field, $nextgen_gallery_row ) ) {
ai4seo_debug_message( 984321689, 'Could not preflight all requested NextGEN picture fields.', true );
return false;
}
$original_nextgen_gallery_values[ $field ] = $nextgen_gallery_row[ $field ];
}
}
// Provider first: a raced or failed optional-provider write must never leave WordPress half-updated.
if ( $nextgen_gallery_updates ) {
// Mark the call boundary conservatively in case a provider hook throws after mutation.
$operation_details['commit_state'] = 'possibly_committed';
$nextgen_write_result = ai4seo_compare_and_set_nextgen_attachment_attribute_sync_row(
$nextgen_gallery_pid,
$nextgen_gallery_updates,
$original_nextgen_gallery_values
);
if ( ! $nextgen_write_result['succeeded'] ) {
$nextgen_write_error = $wpdb->last_error;
$nextgen_rollback_succeeded = true;
// Roll back only a row this exact CAS changed, and only while its desired values remain current.
if ( $nextgen_write_result['changed'] ) {
$nextgen_rollback_result = ai4seo_compare_and_set_nextgen_attachment_attribute_sync_row(
$nextgen_gallery_pid,
$original_nextgen_gallery_values,
$nextgen_gallery_updates
);
$nextgen_rollback_succeeded = $nextgen_rollback_result['succeeded'];
if ( ! $nextgen_rollback_succeeded ) {
ai4seo_debug_message( 984321720, 'NextGEN synchronization failed and its provider state could not be restored without overwriting a concurrent writer; retrying the same attachment update is safe.', true );
}
}
$operation_details['commit_state'] = $nextgen_rollback_succeeded ? 'not_committed' : 'possibly_committed';
ai4seo_debug_message( 984321689, 'Database error while synchronizing NextGEN attachment attributes: ' . $nextgen_write_error, true );
return false;
}
$nextgen_write_owned = $nextgen_write_result['changed'];
}
if ( $post_updates ) {
// A thrown query filter or readback can make a post CAS result unknowable to the caller.
$operation_details['commit_state'] = 'possibly_committed';
$post_write_result = ai4seo_compare_and_set_attachment_attribute_post_columns(
$attachment_post_id,
$post_updates,
$original_post_values
);
$post_cache_repair_required = $post_write_result['changed']
|| $post_write_result['conflicted']
|| (
$post_write_result['succeeded']
&& ! $post_write_result['changed']
&& ! ai4seo_attachment_attribute_row_matches_values( $original_post_values, $post_updates )
);
if ( ! $post_write_result['succeeded'] ) {
$post_write_error = $wpdb->last_error;
$post_rollback_succeeded = true;
if ( $post_write_result['changed'] ) {
$post_rollback_result = ai4seo_compare_and_set_attachment_attribute_post_columns(
$attachment_post_id,
$original_post_values,
$post_updates
);
$post_cache_repair_required = $post_cache_repair_required || $post_rollback_result['changed'] || $post_rollback_result['conflicted'];
$post_rollback_succeeded = $post_rollback_result['succeeded'];
}
$nextgen_rollback_succeeded = true;
if ( $nextgen_write_owned ) {
$nextgen_rollback_result = ai4seo_compare_and_set_nextgen_attachment_attribute_sync_row(
$nextgen_gallery_pid,
$original_nextgen_gallery_values,
$nextgen_gallery_updates
);
$nextgen_rollback_succeeded = $nextgen_rollback_result['succeeded'];
}
$operation_details['commit_state'] = $post_rollback_succeeded && $nextgen_rollback_succeeded
? 'not_committed'
: 'possibly_committed';
if ( $post_cache_repair_required ) {
clean_post_cache( $attachment_post_id );
if ( ! ai4seo_force_bump_content_type_list_cache_version() ) {
ai4seo_debug_message( 984321725, 'Could not persist the attachment content-list cache repair after a failed post write; retrying the attachment update remains safe.', true );
}
}
if ( ! $post_rollback_succeeded || ! $nextgen_rollback_succeeded ) {
ai4seo_debug_message( 984321721, 'The WordPress attachment write failed and an owned earlier value could not be restored without overwriting a concurrent writer; retrying the same attachment update is safe.', true );
}
ai4seo_debug_message( 984321688, 'Database error: ' . $post_write_error, true );
return false;
}
$post_write_owned = $post_write_result['changed'];
$wordpress_owner_reached_desired_state = true;
}
// Postmeta is last because an exact conflict can compensate only earlier writes this operation owns.
if ( $postmeta_update_requested ) {
// The lock-scoped metadata writer can fail or throw after an owned write and compensation attempt.
$operation_details['commit_state'] = 'possibly_committed';
$postmeta_write_result = ai4seo_write_attachment_alt_text_postmeta_with_lock(
$attachment_post_id,
$postmeta_update_value,
$postmeta_expected_state
);
$postmeta_expected_matches_desired = $postmeta_expected_state['exists']
&& $postmeta_update_value === $postmeta_expected_state['value'];
$postmeta_cache_repair_required = $postmeta_write_result['changed']
|| $postmeta_write_result['conflicted']
|| (
$postmeta_write_result['succeeded']
&& ! $postmeta_write_result['changed']
&& ! $postmeta_expected_matches_desired
);
if ( $postmeta_write_result['succeeded'] ) {
$wordpress_owner_reached_desired_state = true;
}
}
if ( $postmeta_update_requested && ! $postmeta_write_result['succeeded'] ) {
// The lock-scoped writer reverses its exact alt owner before this branch compensates earlier owners.
$postmeta_rollback_succeeded = $postmeta_write_result['rollback_succeeded'];
$post_rollback_succeeded = true;
if ( $post_write_owned ) {
$post_rollback_result = ai4seo_compare_and_set_attachment_attribute_post_columns(
$attachment_post_id,
$original_post_values,
$post_updates
);
$post_cache_repair_required = true;
$post_rollback_succeeded = $post_rollback_result['succeeded'];
}
$nextgen_rollback_succeeded = true;
if ( $nextgen_write_owned ) {
$nextgen_rollback_result = ai4seo_compare_and_set_nextgen_attachment_attribute_sync_row(
$nextgen_gallery_pid,
$original_nextgen_gallery_values,
$nextgen_gallery_updates
);
$nextgen_rollback_succeeded = $nextgen_rollback_result['succeeded'];
}
$operation_details['commit_state'] = $postmeta_rollback_succeeded
&& $post_rollback_succeeded
&& $nextgen_rollback_succeeded
? 'not_committed'
: 'possibly_committed';
if ( $postmeta_cache_repair_required ) {
// Both a retained concurrent value and an owned direct mutation must bypass preflight-era metadata caches.
ai4seo_invalidate_postmeta_caches( array( $attachment_post_id ) );
}
if ( $post_cache_repair_required ) {
clean_post_cache( $attachment_post_id );
}
if ( $post_cache_repair_required || $postmeta_cache_repair_required ) {
if ( ! ai4seo_force_bump_content_type_list_cache_version() ) {
ai4seo_debug_message( 984321726, 'Could not persist the attachment content-list cache repair after a failed postmeta write; retrying the attachment update remains safe.', true );
}
}
if ( ! $postmeta_rollback_succeeded || ! $post_rollback_succeeded || ! $nextgen_rollback_succeeded ) {
ai4seo_debug_message( 984321722, 'The attachment postmeta write failed and one or more operation-owned writes could not be restored; retrying the same attachment update is safe.', true );
}
return false;
}
if ( $postmeta_cache_repair_required ) {
ai4seo_invalidate_postmeta_caches( array( $attachment_post_id ) );
}
if ( $post_cache_repair_required ) {
clean_post_cache( $attachment_post_id );
}
// Primary owners now expose the requested state, even if the following cache-version write fails.
$operation_details['commit_state'] = 'committed';
if ( $wordpress_owner_reached_desired_state && ! ai4seo_force_bump_content_type_list_cache_version() ) {
ai4seo_debug_message( 984321727, 'The attachment attributes reached their requested WordPress state, but the content-list cache version could not be persisted; retrying the same update will repair it.', true );
return false;
}
return true;
}
// =========================================================================================== \
/**
* Checks whether a post ID points to a WordPress attachment post.
*
* @param int $attachment_post_id Attachment post ID.
* @return bool True when the post exists and is a WordPress attachment.
*/
function ai4seo_is_wordpress_attachment_post( int $attachment_post_id ): bool {
// Keep this fallback scoped to native WordPress attachments; custom media post types use their own flows.
$attachment_post_id = absint( $attachment_post_id );
if ( $attachment_post_id <= 0 ) {
return false;
}
$attachment_post = get_post( $attachment_post_id );
if ( ! $attachment_post || is_wp_error( $attachment_post ) ) {
return false;
}
return ( 'attachment' === $attachment_post->post_type );
}
// =========================================================================================== \
/**
* Checks whether a post ID can be stored as an attachment related-post fallback.
*
* @param int $related_post_id Related post ID.
* @return bool True when the related post can provide image usage context.
*/
function ai4seo_is_attachment_related_post_id_eligible( int $related_post_id ): bool {
// Store only posts that the existing usage-context system can actually use during generation.
return ai4seo_is_attachment_context_post_eligible( absint( $related_post_id ) );
}
// =========================================================================================== \
/**
* Normalizes a stored attachment related-post value.
*
* @param mixed $related_post_id_value Stored postmeta value.
* @return int Valid related post ID, or 0 when invalid.
*/
function ai4seo_get_valid_attachment_related_post_id_from_value( $related_post_id_value ): int {
// Accept only scalar integer-like postmeta values because this key is an int fallback cache.
if ( is_array( $related_post_id_value ) || is_object( $related_post_id_value ) ) {
return 0;
}
$related_post_id_string = trim( (string) $related_post_id_value );
if ( '' === $related_post_id_string || ! ctype_digit( $related_post_id_string ) ) {
return 0;
}
$related_post_id = absint( $related_post_id_string );
if ( $related_post_id <= 0 || ! ai4seo_is_attachment_related_post_id_eligible( $related_post_id ) ) {
return 0;
}
return $related_post_id;
}
// =========================================================================================== \
/**
* Reads the attachment related-post fallback ID.
*
* @param int $attachment_post_id Attachment post ID.
* @param bool $require_editable_post Whether the current plugin user must be able to edit the related post.
* @return int Related post ID, or 0 when missing or invalid.
*/
function ai4seo_get_attachment_related_post_id( int $attachment_post_id, bool $require_editable_post = false ): int {
// Do not expose fallback values for non-attachment posts, even if the meta key exists there.
$attachment_post_id = absint( $attachment_post_id );
if ( ! ai4seo_is_wordpress_attachment_post( $attachment_post_id ) ) {
return 0;
}
// Read all rows because older writes or manual edits may leave duplicate entries behind.
$related_post_id_values = get_post_meta( $attachment_post_id, AI4SEO_POST_META_RELATED_POST_ID_META_KEY, false );
if ( ! $related_post_id_values || ! is_array( $related_post_id_values ) ) {
return 0;
}
foreach ( $related_post_id_values as $this_related_post_id_value ) {
$this_related_post_id = ai4seo_get_valid_attachment_related_post_id_from_value( $this_related_post_id_value );
if ( $this_related_post_id > 0
&& ( ! $require_editable_post || ai4seo_can_edit_post( $this_related_post_id ) ) ) {
return $this_related_post_id;
}
}
return 0;
}
// =========================================================================================== \
/**
* Checks whether all existing attachment related-post fallback rows already match.
*
* @param int $attachment_post_id Attachment post ID.
* @param int $related_post_id Related post ID.
* @return bool True when no postmeta write is needed.
*/
function ai4seo_is_attachment_related_post_id_already_stored( int $attachment_post_id, int $related_post_id ): bool {
// Require every row to match exactly so mismatched duplicates still get overwritten by the writer.
$attachment_post_id = absint( $attachment_post_id );
$related_post_id_string = (string) absint( $related_post_id );
if ( $attachment_post_id <= 0 || '0' === $related_post_id_string ) {
return false;
}
$related_post_id_values = get_post_meta( $attachment_post_id, AI4SEO_POST_META_RELATED_POST_ID_META_KEY, false );
if ( ! $related_post_id_values || ! is_array( $related_post_id_values ) ) {
return false;
}
foreach ( $related_post_id_values as $this_related_post_id_value ) {
if ( is_array( $this_related_post_id_value ) || is_object( $this_related_post_id_value ) ) {
return false;
}
if ( trim( (string) $this_related_post_id_value ) !== $related_post_id_string ) {
return false;
}
}
return true;
}
// =========================================================================================== \
/**
* Stores the source post ID to use as an attachment usage-context fallback.
*
* @param int $attachment_post_id Attachment post ID.
* @param int $related_post_id Related post ID.
* @return bool True when the postmeta write completed without a database error.
*/
function ai4seo_update_attachment_related_post_id( int $attachment_post_id, int $related_post_id ): bool {
// Validate both sides before writing so the fallback cannot point generation at unusable context.
$attachment_post_id = absint( $attachment_post_id );
$related_post_id = absint( $related_post_id );
if ( ! ai4seo_is_wordpress_attachment_post( $attachment_post_id ) ) {
return false;
}
if ( ! ai4seo_is_attachment_related_post_id_eligible( $related_post_id ) ) {
return false;
}
if ( ai4seo_is_attachment_related_post_id_already_stored( $attachment_post_id, $related_post_id ) ) {
return true;
}
// update_post_meta without prev_value overwrites all existing rows for this key, keeping duplicates aligned.
return ai4seo_update_post_meta(
$attachment_post_id,
AI4SEO_POST_META_RELATED_POST_ID_META_KEY,
$related_post_id
);
}
// =========================================================================================== \
/**
* Stores one source post ID for multiple related attachment posts.
*
* @param array $attachment_post_ids Attachment post IDs.
* @param int $related_post_id Related post ID.
* @return bool True when all valid attachment writes completed without database errors.
*/
function ai4seo_update_attachment_related_post_id_for_attachment_post_ids(
array $attachment_post_ids,
int $related_post_id
): bool {
// Normalize the full scanner result before saving so table filters and pagination cannot narrow this cache.
$attachment_post_ids = array_values( array_unique( array_filter( array_map( 'absint', $attachment_post_ids ) ) ) );
$related_post_id = absint( $related_post_id );
if ( ! $attachment_post_ids ) {
return true;
}
if ( ! ai4seo_is_attachment_related_post_id_eligible( $related_post_id ) ) {
return false;
}
$overall_success = true;
// Save each attachment independently so one failed row does not block valid fallback cache entries.
foreach ( $attachment_post_ids as $this_attachment_post_id ) {
if ( ai4seo_update_attachment_related_post_id( $this_attachment_post_id, $related_post_id ) ) {
continue;
}
$overall_success = false;
ai4seo_debug_message(
4527052601,
'Could not update related post fallback for attachment post ID ' . $this_attachment_post_id . ' and related post ID ' . $related_post_id,
true
);
}
return $overall_success;
}
// =========================================================================================== \
/**
* Normalize a source URL for exact image matching, preserving path and query identity.
*
* @param string $url Source URL.
* @param string $base_url Page URL used to resolve relative sources.
* @return string Comparable HTTP URL, or empty for unsupported sources.
*/
function ai4seo_normalize_generation_image_url( string $url, string $base_url ): string {
$url = WP_Http::make_absolute_url( trim( $url ), $base_url );
$parts = wp_parse_url( $url );
if ( ! is_array( $parts )
|| empty( $parts['host'] )
|| empty( $parts['scheme'] )
|| ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
return '';
}
return strtolower( $parts['scheme'] ) . '://' . strtolower( $parts['host'] )
. ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' )
. ( $parts['path'] ?? '/' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
}
/**
* Match an actual image token by exact attachment identity or source URL.
*
* @param WP_HTML_Tag_Processor $processor Processor positioned at an IMG token.
* @param int $attachment_id Attachment identity.
* @param string $attachment_url Exact attachment URL.
* @param string $page_url Source page URL.
* @param array $known_urls Optional shared exact URL identities.
* @return bool Whether this image is the requested attachment.
*/
function ai4seo_generation_image_matches(
WP_HTML_Tag_Processor $processor,
int $attachment_id,
string $attachment_url,
string $page_url,
array $known_urls = array()
): bool {
$known_urls = $known_urls ? $known_urls : ai4seo_get_attachment_context_image_urls( $attachment_id, $attachment_url );
$ids = array();
if ( preg_match_all( '/(?:^|\\s)wp-image-([0-9]+)(?=\\s|$)/', (string) $processor->get_attribute( 'class' ), $matches ) ) {
$ids = array_map( 'absint', $matches[1] );
}
foreach ( array( 'data-id', 'data-attachment-id', 'data-media-id' ) as $attribute ) {
$value = $processor->get_attribute( $attribute );
if ( is_string( $value ) && ctype_digit( $value ) && (int) $value > 0 ) {
$ids[] = (int) $value;
}
}
if ( array_diff( $ids, array( $attachment_id ) ) ) {
return false;
}
$sources = array();
foreach ( array( 'src', 'data-src', 'data-lazy-src', 'data-original', 'data-full-image', 'data-light-image' ) as $attribute ) {
$value = $processor->get_attribute( $attribute );
if ( is_string( $value ) && '' !== $value ) {
$sources[] = $value;
}
}
foreach ( array( 'srcset', 'data-srcset', 'data-lazy-srcset' ) as $attribute ) {
$value = $processor->get_attribute( $attribute );
if ( is_string( $value ) ) {
$sources = array_merge( $sources, ai4seo_get_generation_srcset_urls( $value ) );
}
}
$matched = in_array( $attachment_id, $ids, true );
$normalized_sources = array();
foreach ( $sources as $source ) {
$url = ai4seo_normalize_generation_image_url( $source, $page_url ? $page_url : $attachment_url );
$normalized_sources[ $url ] = true;
if ( isset( $known_urls[ $url ] ) ) {
$matched = true;
}
}
if ( ! $matched ) {
return false;
}
foreach ( array_diff_key( $normalized_sources, $known_urls ) as $url => $unused ) {
// Keep the cap on contradiction checks: entirely known source lists need no lookup.
if ( count( $normalized_sources ) > 50 ) {
return false;
}
$other_id = attachment_url_to_postid( $url );
if ( $other_id > 0 && $other_id !== $attachment_id ) {
return false;
}
}
return true;
}
/**
* Return bounded visible text from a matching image's own single-image container.
*
* @param string $html Intact normalized source markup.
* @param int $attachment_id Requested attachment identity.
* @param string $attachment_url Exact attachment URL.
* @param string $page_url Base URL for relative image sources.
* @return string Isolated context, or empty when no eligible container supplies text.
*/
function ai4seo_get_attachment_surrounding_visible_text(
string $html,
int $attachment_id,
string $attachment_url,
string $page_url = ''
): string {
$evidence = ai4seo_get_attachment_html_evidence( $html, $attachment_id, $attachment_url, $page_url );
return $evidence['text'];
}
/**
* Returns post-related context for an attachment.
*
* @param int $attachment_post_id The attachment post ID.
* @param bool $require_editable_post Whether the current plugin user must be able to edit the context post.
* @return string Verified image text and, when permitted by the relation, source identity;
* empty when the selected policy supplies no usable context.
*/
function ai4seo_get_attachment_post_related_context( int $attachment_post_id, bool $require_editable_post = false ): string {
$result = ai4seo_resolve_attachment_usage_context( $attachment_post_id, $require_editable_post );
return $result['context'];
}
// =========================================================================================== \
/**
* Returns image attachment post IDs related to a specific post.
*
* Checks direct WordPress relations, post content, page-builder/custom postmeta values,
* and local image URLs. Attachments may also be used by other posts.
*
* @param int $post_id The post id to inspect.
* @return array Related attachment post IDs.
*/
function ai4seo_get_related_attachment_post_ids( int $post_id ): array {
// Keep the public helper compatible with existing callers that expect a plain attachment ID list.
$related_attachment_scan_result = ai4seo_get_related_attachment_scan_result( $post_id );
return (array) ( $related_attachment_scan_result['attachment_post_ids'] ?? array() );
}
// =========================================================================================== \
/**
* Returns detailed related attachment scan data for a specific post.
*
* @param int $post_id The post id to inspect.
* @return array Related attachment scan result.
*/
function ai4seo_get_related_attachment_scan_result( int $post_id ): array {
// Start with the full result shape so early returns stay compatible with the modal scanner.
$related_attachment_scan_result = ai4seo_get_empty_related_attachment_scan_result();
// Keep the scanner bounded to one source post per request; callers can retry later.
if ( ai4seo_prevent_loops( __FUNCTION__, 5 ) ) {
return $related_attachment_scan_result;
}
$post_id = absint( $post_id );
// Validate the source post before scanning content and postmeta values.
if ( $post_id <= 0 ) {
return $related_attachment_scan_result;
}
$post = get_post( $post_id );
if ( ! $post || is_wp_error( $post ) || ! isset( $post->post_type ) ) {
return $related_attachment_scan_result;
}
if ( in_array( $post->post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true ) ) {
return $related_attachment_scan_result;
}
if ( in_array( $post->post_status, array( 'trash', 'auto-draft' ), true ) ) {
return $related_attachment_scan_result;
}
$related_attachment_post_ids = array();
// Share lookup state across content and postmeta scans so URL resolution caps apply per modal request.
$related_attachment_scan_state = ai4seo_get_related_attachment_scan_state();
// Seed the result with direct WordPress relationships that do not require parsing content.
$featured_image_post_id = get_post_thumbnail_id( $post_id );
ai4seo_add_related_attachment_post_id( $related_attachment_post_ids, absint( $featured_image_post_id ) );
// Include WooCommerce variation thumbnails because products can store purchasable image choices on child posts.
ai4seo_add_related_attachment_post_ids(
$related_attachment_post_ids,
ai4seo_get_related_woocommerce_variation_attachment_post_ids( $post_id, (string) $post->post_type )
);
// Include uploaded children because WordPress stores some attachment relations through post_parent.
$child_attachment_post_ids = ai4seo_with_wpml_all_languages(
function () use ( $post_id ) {
return get_posts(
array(
'post_parent' => $post_id,
'post_type' => 'attachment',
'post_status' => array( 'publish', 'future', 'inherit' ),
'post_mime_type' => ai4seo_get_allowed_attachment_mime_types(),
'fields' => 'ids',
'posts_per_page' => -1,
'no_found_rows' => true,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Related media intentionally includes attachment children in every language.
'suppress_filters' => true,
'lang' => 'all',
)
);
}
);
ai4seo_add_related_attachment_post_ids( $related_attachment_post_ids, (array) $child_attachment_post_ids );
// Scan classic and block-editor text fields before falling back to broader postmeta inspection.
$content_values = array(
(string) ( $post->post_content ?? '' ),
(string) ( $post->post_excerpt ?? '' ),
(string) ( $post->post_content_filtered ?? '' ),
);
foreach ( $content_values as $this_content_value ) {
ai4seo_add_related_attachment_post_ids(
$related_attachment_post_ids,
ai4seo_extract_related_attachment_post_ids_from_value( $this_content_value, 'post_content', 0, $related_attachment_scan_state )
);
}
// Inspect targeted postmeta because Elementor, WooCommerce, BeTheme/Muffin, and other builders store media there.
$scannable_post_meta_rows = ai4seo_get_related_attachment_scannable_post_meta_rows( $post_id, $related_attachment_scan_result );
foreach ( $scannable_post_meta_rows as $this_scannable_post_meta_row ) {
ai4seo_add_related_attachment_post_ids(
$related_attachment_post_ids,
ai4seo_extract_related_attachment_post_ids_from_value(
$this_scannable_post_meta_row['meta_value'] ?? '',
(string) ( $this_scannable_post_meta_row['meta_key'] ?? '' ),
0,
$related_attachment_scan_state
)
);
}
// Bubble partial state from nested URL extraction into the result consumed by the AJAX modal.
if ( ! empty( $related_attachment_scan_state['is_partial'] ) ) {
$related_attachment_scan_result['is_partial'] = true;
}
// Attach validated IDs last so detailed scan metadata and the public wrapper share one source.
$related_attachment_scan_result['attachment_post_ids'] = array_values( $related_attachment_post_ids );
return $related_attachment_scan_result;
}
// =========================================================================================== \
/**
* Returns an empty related attachment scan result.
*
* @return array Empty scan result.
*/
function ai4seo_get_empty_related_attachment_scan_result(): array {
return array(
'attachment_post_ids' => array(),
'is_partial' => false,
'skipped_meta_count' => 0,
'scanned_meta_count' => 0,
);
}
// =========================================================================================== \
/**
* Returns related attachment scanner caps.
*
* @return array Scan limits.
*/
function ai4seo_get_related_attachment_scan_limits(): array {
// Keep the limits conservative because this scanner runs synchronously inside an admin modal request.
return array(
'max_selected_meta_rows' => 40,
'max_total_selected_meta_bytes' => 1048576,
'max_single_meta_value_bytes' => 262144,
'max_image_url_attachment_lookups' => 50,
'max_serialized_value_depth' => 8,
'max_serialized_value_nodes' => 2048,
);
}
// =========================================================================================== \
/**
* Returns mutable state for one related attachment scan.
*
* @return array Mutable scan state.
*/
function ai4seo_get_related_attachment_scan_state(): array {
// Keep mutable counters separate from configured limits so extractors can update one request's state.
$related_attachment_scan_limits = ai4seo_get_related_attachment_scan_limits();
return array(
'image_url_attachment_lookups' => 0,
'max_image_url_attachment_lookups' => absint( $related_attachment_scan_limits['max_image_url_attachment_lookups'] ?? 0 ),
'is_partial' => false,
);
}
// =========================================================================================== \
/**
* Returns selected postmeta rows that are worth scanning for related media.
*
* @param int $post_id Source post ID.
* @param array $related_attachment_scan_result Mutable scan result.
* @return array Scannable postmeta rows.
*/
function ai4seo_get_related_attachment_scannable_post_meta_rows( int $post_id, array &$related_attachment_scan_result ): array {
global $wpdb;
$post_id = absint( $post_id );
if ( $post_id <= 0 ) {
return array();
}
// Read limits once so row selection, SQL prefix loading, and PHP byte caps use the same thresholds.
$related_attachment_scan_limits = ai4seo_get_related_attachment_scan_limits();
$max_selected_meta_rows = absint( $related_attachment_scan_limits['max_selected_meta_rows'] ?? 40 );
$max_total_selected_meta_bytes = absint( $related_attachment_scan_limits['max_total_selected_meta_bytes'] ?? 1048576 );
$max_single_meta_value_bytes = absint( $related_attachment_scan_limits['max_single_meta_value_bytes'] ?? 262144 );
// Load only meta IDs and keys first; full values are fetched later only for selected media-like rows.
$post_meta_key_query = ai4seo_prepare_database_query(
'SELECT meta_id, meta_key FROM {{postmeta_table}} WHERE post_id = {{post_id}} ORDER BY meta_id ASC',
array(
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'post_id' => ai4seo_database_scalar_binding( '%d', $post_id ),
)
);
if ( false === $post_meta_key_query ) {
ai4seo_debug_message( 19747201, 'Could not prepare the related-attachment postmeta-key query.', true );
return array();
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed query compiler prepared this source-post lookup; the scan needs current keys before applying its strict row cap.
$post_meta_key_rows = $wpdb->get_results( $post_meta_key_query, ARRAY_A );
if ( $wpdb->last_error ) {
ai4seo_debug_message( 19747201, 'Database error: ' . $wpdb->last_error, true );
return array();
}
if ( ! $post_meta_key_rows || ! is_array( $post_meta_key_rows ) ) {
return array();
}
// Split exact known builder/media keys from broad media-like keys so exact keys keep priority under caps.
$exact_post_meta_keys = ai4seo_get_related_attachment_exact_post_meta_keys();
$exact_post_meta_ids = array();
$generic_post_meta_ids = array();
foreach ( $post_meta_key_rows as $this_post_meta_key_row ) {
$this_post_meta_id = absint( $this_post_meta_key_row['meta_id'] ?? 0 );
$this_post_meta_key = (string) ( $this_post_meta_key_row['meta_key'] ?? '' );
if ( $this_post_meta_id <= 0 ) {
continue;
}
if ( in_array( strtolower( trim( $this_post_meta_key ) ), $exact_post_meta_keys, true ) ) {
$exact_post_meta_ids[] = $this_post_meta_id;
continue;
}
if ( ! ai4seo_is_related_attachment_scannable_post_meta_key( $this_post_meta_key ) ) {
continue;
}
$generic_post_meta_ids[] = $this_post_meta_id;
}
// Apply the row cap after prioritization so known builder keys are not crowded out by generic custom fields.
$selected_post_meta_ids = array();
$candidate_post_meta_ids = array_merge( $exact_post_meta_ids, $generic_post_meta_ids );
foreach ( $candidate_post_meta_ids as $this_candidate_post_meta_id ) {
if ( count( $selected_post_meta_ids ) >= $max_selected_meta_rows ) {
$related_attachment_scan_result['is_partial'] = true;
++$related_attachment_scan_result['skipped_meta_count'];
continue;
}
$selected_post_meta_ids[] = $this_candidate_post_meta_id;
}
if ( ! $selected_post_meta_ids ) {
return array();
}
// Fetch only selected values, and only up to the single-value cap, to avoid loading huge builder payloads.
$post_meta_value_query = ai4seo_prepare_database_query(
'SELECT meta_id, meta_key, LEFT(meta_value, {{value_byte_limit}}) AS meta_value, LENGTH(meta_value) AS meta_value_bytes FROM {{postmeta_table}} WHERE meta_id IN ({{meta_ids}}) ORDER BY meta_id ASC',
array(
'value_byte_limit' => ai4seo_database_scalar_binding( '%d', $max_single_meta_value_bytes ),
'postmeta_table' => ai4seo_database_identifier_binding( 'table.postmeta' ),
'meta_ids' => ai4seo_database_list_binding( '%d', array_values( $selected_post_meta_ids ) ),
)
);
if ( false === $post_meta_value_query ) {
ai4seo_debug_message( 19747202, 'Could not prepare the related-attachment postmeta-value query.', true );
return array();
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The typed query compiler prepared exact selected meta IDs and the byte cap; current bounded prefixes are consumed only by this scan.
$post_meta_value_rows = $wpdb->get_results( $post_meta_value_query, ARRAY_A );
if ( $wpdb->last_error ) {
ai4seo_debug_message( 19747202, 'Database error: ' . $wpdb->last_error, true );
return array();
}
if ( ! $post_meta_value_rows || ! is_array( $post_meta_value_rows ) ) {
return array();
}
$scannable_post_meta_rows = array();
$scanned_post_meta_bytes = 0;
// Enforce remaining byte caps in PHP because the aggregate cap depends on rows already accepted.
foreach ( $post_meta_value_rows as $this_post_meta_value_row ) {
$this_post_meta_value = (string) ( $this_post_meta_value_row['meta_value'] ?? '' );
$this_post_meta_value_bytes = strlen( $this_post_meta_value );
$this_full_post_meta_value_bytes = absint( $this_post_meta_value_row['meta_value_bytes'] ?? $this_post_meta_value_bytes );
if ( $max_single_meta_value_bytes > 0 && $this_full_post_meta_value_bytes > $max_single_meta_value_bytes ) {
if ( $this_post_meta_value_bytes > $max_single_meta_value_bytes ) {
$this_post_meta_value = substr( $this_post_meta_value, 0, $max_single_meta_value_bytes );
$this_post_meta_value_bytes = strlen( $this_post_meta_value );
}
$related_attachment_scan_result['is_partial'] = true;
++$related_attachment_scan_result['skipped_meta_count'];
}
if ( $max_total_selected_meta_bytes > 0 && ( $scanned_post_meta_bytes + $this_post_meta_value_bytes ) > $max_total_selected_meta_bytes ) {
$remaining_post_meta_bytes = $max_total_selected_meta_bytes - $scanned_post_meta_bytes;
if ( $remaining_post_meta_bytes <= 0 ) {
$related_attachment_scan_result['is_partial'] = true;
++$related_attachment_scan_result['skipped_meta_count'];
continue;
}
$this_post_meta_value = substr( $this_post_meta_value, 0, $remaining_post_meta_bytes );
$this_post_meta_value_bytes = strlen( $this_post_meta_value );
$related_attachment_scan_result['is_partial'] = true;
++$related_attachment_scan_result['skipped_meta_count'];
}
$scanned_post_meta_bytes += $this_post_meta_value_bytes;
++$related_attachment_scan_result['scanned_meta_count'];
$scannable_post_meta_rows[] = array(
'meta_id' => absint( $this_post_meta_value_row['meta_id'] ?? 0 ),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Result-shape key, not a meta-query predicate.
'meta_key' => (string) ( $this_post_meta_value_row['meta_key'] ?? '' ),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Result-shape key, not a meta-query predicate.
'meta_value' => $this_post_meta_value,
);
}
// Return only the bounded values that downstream extractors should parse.
return $scannable_post_meta_rows;
}
// =========================================================================================== \
/**
* Returns exact postmeta keys that can store related media.
*
* @return array Exact postmeta keys.
*/
function ai4seo_get_related_attachment_exact_post_meta_keys(): array {
// Include builder keys that are known to store media even when their key names lack media terms.
return array(
'_thumbnail_id',
'_product_image_gallery',
'_elementor_data',
'_elementor_page_settings',
'_fl_builder_data',
'ct_builder_shortcodes',
'mfn-page-items-seo',
'mfn-page-items',
);
}
// =========================================================================================== \
/**
* Checks whether a BeTheme/Muffin postmeta key should be scanned for related media.
*
* @param string $meta_key Postmeta key.
* @return bool True if the key should be scanned.
*/
function ai4seo_is_related_attachment_betheme_post_meta_key( string $meta_key ): bool {
$meta_key = strtolower( trim( $meta_key ) );
// Muffin Builder stores page content in exact keys that do not include media-specific wording.
if ( in_array( $meta_key, array( 'mfn-page-items-seo', 'mfn-page-items' ), true ) ) {
return true;
}
// Broaden BeTheme/Muffin coverage only for prefixed keys whose names indicate media usage.
if ( strpos( $meta_key, 'mfn-' ) !== 0 && strpos( $meta_key, 'mfn_' ) !== 0 ) {
return false;
}
$betheme_media_terms = array(
'image',
'background',
'gallery',
'photo',
'media',
'thumbnail',
'poster',
);
foreach ( $betheme_media_terms as $this_betheme_media_term ) {
if ( strpos( $meta_key, $this_betheme_media_term ) !== false ) {
return true;
}
}
return false;
}
// =========================================================================================== \
/**
* Checks whether a postmeta key should be scanned for related media.
*
* @param string $meta_key Postmeta key.
* @return bool True if the key should be scanned.
*/
function ai4seo_is_related_attachment_scannable_post_meta_key( string $meta_key ): bool {
$meta_key = strtolower( trim( $meta_key ) );
if ( '' === $meta_key ) {
return false;
}
// Exact builder/media keys are trusted even when the key name itself is not media-like.
if ( in_array( $meta_key, ai4seo_get_related_attachment_exact_post_meta_keys(), true ) ) {
return true;
}
// Keep BeTheme/Muffin Builder matching explicit so future mfn key changes are easy to audit.
if ( ai4seo_is_related_attachment_betheme_post_meta_key( $meta_key ) ) {
return true;
}
// Fall back to the generic media-term matcher used by nested value extraction.
return ai4seo_is_attachment_like_reference_key( $meta_key );
}
// =========================================================================================== \
/**
* Returns WooCommerce product variation thumbnail attachment IDs for a product.
*
* @param int $post_id Source post ID.
* @param string $post_type Source post type, if already known.
* @return array Variation thumbnail attachment IDs.
*/
function ai4seo_get_related_woocommerce_variation_attachment_post_ids( int $post_id, string $post_type = '' ): array {
// Normalize caller input before checking whether this source can own variation thumbnails.
$post_id = absint( $post_id );
$post_type = '' !== $post_type ? sanitize_key( $post_type ) : (string) get_post_type( $post_id );
// Skip non-product sources and installations where WooCommerce has not registered variations.
if ( $post_id <= 0 || 'product' !== $post_type || ! post_type_exists( 'product_variation' ) ) {
return array();
}
// Read variation child posts without language filtering, matching the attachment scan around this helper.
$variation_post_ids = ai4seo_with_wpml_all_languages(
function () use ( $post_id ) {
return get_posts(
array(
'post_parent' => $post_id,
'post_type' => 'product_variation',
'post_status' => array( 'publish', 'private' ),
'fields' => 'ids',
'posts_per_page' => -1,
'no_found_rows' => true,
'orderby' => 'ID',
'order' => 'DESC',
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Related media intentionally includes product variations in every language.
'suppress_filters' => true,
'lang' => 'all',
)
);
}
);
// Stop early when the product has no variation children with their own media relation.
if ( ! $variation_post_ids ) {
return array();
}
// Collect the normal WordPress featured-image relation from each variation.
$variation_thumbnail_attachment_post_ids = array();
foreach ( (array) $variation_post_ids as $this_variation_post_id ) {
$this_variation_thumbnail_post_id = get_post_thumbnail_id( absint( $this_variation_post_id ) );
if ( $this_variation_thumbnail_post_id ) {
$variation_thumbnail_attachment_post_ids[] = absint( $this_variation_thumbnail_post_id );
}
}
return ai4seo_normalize_related_attachment_post_ids( $variation_thumbnail_attachment_post_ids );
}
// =========================================================================================== \
/**
* Adds a valid related attachment post ID to a deduped ID map.
*
* @param array $related_attachment_post_ids Related attachment ID map.
* @param int $attachment_post_id Attachment post ID candidate.
* @return void
*/
function ai4seo_add_related_attachment_post_id( array &$related_attachment_post_ids, int $attachment_post_id ): void {
// Validate each candidate once so broad content/meta scans cannot add non-image posts.
$attachment_post_id = absint( $attachment_post_id );
if ( $attachment_post_id <= 0 ) {
return;
}
if ( isset( $related_attachment_post_ids[ $attachment_post_id ] ) ) {
return;
}
if ( ! ai4seo_is_related_attachment_post_id_valid( $attachment_post_id ) ) {
return;
}
$related_attachment_post_ids[ $attachment_post_id ] = $attachment_post_id;
}
// =========================================================================================== \
/**
* Adds multiple valid related attachment post IDs to a deduped ID map.
*
* @param array $related_attachment_post_ids Related attachment ID map.
* @param array $attachment_post_ids Attachment post ID candidates.
* @return void
*/
function ai4seo_add_related_attachment_post_ids( array &$related_attachment_post_ids, array $attachment_post_ids ): void {
// Funnel every batch through the single-ID validator to keep all sources consistent.
foreach ( $attachment_post_ids as $this_attachment_post_id ) {
ai4seo_add_related_attachment_post_id( $related_attachment_post_ids, absint( $this_attachment_post_id ) );
}
}
// =========================================================================================== \
/**
* Checks whether a candidate is a usable image attachment.
*
* @param int $attachment_post_id Attachment post ID.
* @return bool True if the attachment can be listed.
*/
function ai4seo_is_related_attachment_post_id_valid( int $attachment_post_id ): bool {
static $validated_attachment_post_ids = array();
// Cache validation by attachment ID because the same candidate can appear in content and meta.
$attachment_post_id = absint( $attachment_post_id );
if ( $attachment_post_id <= 0 ) {
return false;
}
if ( isset( $validated_attachment_post_ids[ $attachment_post_id ] ) ) {
return $validated_attachment_post_ids[ $attachment_post_id ];
}
$attachment_post = get_post( $attachment_post_id );
if ( ! $attachment_post || is_wp_error( $attachment_post ) || ! isset( $attachment_post->post_type ) ) {
$validated_attachment_post_ids[ $attachment_post_id ] = false;
return false;
}
if ( 'attachment' !== $attachment_post->post_type ) {
$validated_attachment_post_ids[ $attachment_post_id ] = false;
return false;
}
if ( ! in_array( $attachment_post->post_status, array( 'publish', 'future', 'inherit' ), true ) ) {
$validated_attachment_post_ids[ $attachment_post_id ] = false;
return false;
}
$attachment_post_mime_type = ai4seo_get_attachment_post_mime_type( $attachment_post_id );
$allowed_attachment_mime_types = ai4seo_get_allowed_attachment_mime_types();
$validated_attachment_post_ids[ $attachment_post_id ] = in_array( $attachment_post_mime_type, $allowed_attachment_mime_types, true );
return $validated_attachment_post_ids[ $attachment_post_id ];
}
// =========================================================================================== \
/**
* Normalizes related attachment post ID candidates while preserving first-seen order.
*
* @param array $attachment_post_ids Attachment post ID candidates.
* @return array Normalized attachment post IDs.
*/
function ai4seo_normalize_related_attachment_post_ids( array $attachment_post_ids ): array {
// Keep zero values here because the later validator is responsible for rejecting invalid IDs.
return array_values( array_unique( array_map( 'absint', $attachment_post_ids ) ) );
}
// =========================================================================================== \
/**
* Removes object nodes from safely decoded related-media data while enforcing traversal caps.
*
* @param mixed $value The decoded node.
* @param int $depth Current traversal depth.
* @param int $max_depth Maximum allowed traversal depth.
* @param int $max_nodes Maximum allowed node count.
* @param int $node_count Mutable traversed node count.
* @param bool $limit_exceeded Mutable limit state.
* @param bool $discard_node Mutable object/resource discard state.
* @return mixed A scalar or sanitized array node.
*/
function ai4seo_sanitize_related_attachment_serialized_node( $value, int $depth, int $max_depth, int $max_nodes, int &$node_count, bool &$limit_exceeded, bool &$discard_node ) {
$discard_node = false;
if ( $limit_exceeded || $depth > $max_depth ) {
$limit_exceeded = true;
return null;
}
++$node_count;
if ( $node_count > $max_nodes ) {
$limit_exceeded = true;
return null;
}
// Never inspect decoded objects; incomplete classes may contain attacker-controlled properties.
if ( is_object( $value ) || is_resource( $value ) ) {
$discard_node = true;
return null;
}
if ( ! is_array( $value ) ) {
return $value;
}
$sanitized_value = array();
foreach ( $value as $this_key => $this_value ) {
$discard_child_node = false;
$sanitized_child = ai4seo_sanitize_related_attachment_serialized_node(
$this_value,
$depth + 1,
$max_depth,
$max_nodes,
$node_count,
$limit_exceeded,
$discard_child_node
);
if ( $limit_exceeded ) {
return null;
}
if ( ! $discard_child_node ) {
$sanitized_value[ $this_key ] = $sanitized_child;
}
}
return $sanitized_value;
}
// =========================================================================================== \
/**
* Safely decodes serialized related-media data without instantiating stored objects.
*
* This decoder is intentionally scan-only: object nodes are discarded while safe sibling
* arrays and scalars remain available to the attachment-ID extractor.
*
* @param mixed $value Potentially serialized related-media value.
* @return array Decode state containing `was_serialized`, `is_decoded`, and `value` entries.
*/
function ai4seo_decode_related_attachment_serialized_value( $value ): array {
$decode_result = array(
'was_serialized' => false,
'is_decoded' => false,
'value' => null,
);
if ( ! is_string( $value ) ) {
return $decode_result;
}
$serialized_value = trim( $value );
$related_attachment_scan_limits = ai4seo_get_related_attachment_scan_limits();
$max_value_bytes = absint( $related_attachment_scan_limits['max_single_meta_value_bytes'] ?? 262144 );
$max_depth = absint( $related_attachment_scan_limits['max_serialized_value_depth'] ?? 8 );
$max_nodes = absint( $related_attachment_scan_limits['max_serialized_value_nodes'] ?? 2048 );
$is_legacy_custom_object = ai4seo_is_legacy_serialized_custom_object( $serialized_value );
$is_standard_serialized = is_serialized( $serialized_value );
if ( '' === $serialized_value || ( ! $is_legacy_custom_object && ! $is_standard_serialized ) ) {
return $decode_result;
}
$decode_result['was_serialized'] = true;
if ( $is_legacy_custom_object || strlen( $serialized_value ) > $max_value_bytes ) {
return $decode_result;
}
// Reject enum tokens before unserialize can autoload their declaring class on PHP 8.1+.
if ( ai4seo_serialized_value_contains_enum_token( $serialized_value ) ) {
return $decode_result;
}
try {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors.Discouraged -- Class instantiation is disabled, depth is bounded, and malformed third-party metadata must fail closed without a warning.
$decoded_value = @unserialize(
$serialized_value,
array(
'allowed_classes' => false,
'max_depth' => $max_depth,
)
);
} catch ( Throwable $exception ) {
return $decode_result;
}
// Distinguish the valid serialized false scalar from a bounded decoder failure.
if ( false === $decoded_value && 'b:0;' !== $serialized_value ) {
return $decode_result;
}
$node_count = 0;
$limit_exceeded = false;
$discard_node = false;
$decoded_value = ai4seo_sanitize_related_attachment_serialized_node(
$decoded_value,
0,
$max_depth,
$max_nodes,
$node_count,
$limit_exceeded,
$discard_node
);
if ( $limit_exceeded || $discard_node ) {
return $decode_result;
}
$decode_result['is_decoded'] = true;
$decode_result['value'] = $decoded_value;
return $decode_result;
}
// =========================================================================================== \
/**
* Extracts attachment ID candidates from a mixed content or metadata value.
*
* @param mixed $value The value to scan.
* @param string $context_key The source key or field name.
* @param int $depth Recursion depth guard.
* @param mixed $related_attachment_scan_state Optional mutable scan state.
* @return array Attachment ID candidates.
*/
function ai4seo_extract_related_attachment_post_ids_from_value( $value, string $context_key = '', int $depth = 0, &$related_attachment_scan_state = null ): array {
// Builder JSON can nest deeply; cap recursion so malformed values cannot consume the request.
if ( $depth > 8 ) {
return array();
}
$related_attachment_post_ids = array();
// Preserve nested array keys as context because keys often tell us whether numeric values are media references.
if ( is_array( $value ) ) {
if ( ! empty( $related_attachment_scan_state['verified_attachment_id'] ) && ai4seo_is_verified_attachment_reference_key( $context_key ) && isset( $value['id'] ) ) {
$target_id = $related_attachment_scan_state['verified_attachment_id'];
if ( ! in_array( $target_id, ai4seo_normalize_verified_attachment_ids( $value['id'] ), true ) ) {
return array();
}
if ( ! empty( $value['url'] ) && is_string( $value['url'] ) ) {
$url = ai4seo_normalize_generation_image_url( $value['url'], $related_attachment_scan_state['page_url'] ?? '' );
if ( ! isset( $related_attachment_scan_state['verified_attachment_urls'][ $url ] ) ) {
$other_id = attachment_url_to_postid( $url );
if ( $other_id && $other_id !== $target_id ) {
return array();
}
}
}
}
foreach ( $value as $this_key => $this_value ) {
$this_context_key = ai4seo_get_related_attachment_nested_context_key( $context_key, $this_key );
// Append child results without copying the growing ID list for every sibling.
foreach ( ai4seo_extract_related_attachment_post_ids_from_value( $this_value, $this_context_key, $depth + 1, $related_attachment_scan_state ) as $this_attachment_post_id ) {
$related_attachment_post_ids[] = $this_attachment_post_id;
}
}
return ai4seo_normalize_related_attachment_post_ids( $related_attachment_post_ids );
}
// Convert decoded objects into arrays so JSON-like builder data follows the same traversal path.
if ( is_object( $value ) ) {
return ai4seo_extract_related_attachment_post_ids_from_value( get_object_vars( $value ), $context_key, $depth + 1, $related_attachment_scan_state );
}
// Numeric values are only treated as IDs when the surrounding key is known to describe media.
if ( is_numeric( $value ) && ai4seo_is_attachment_like_reference_key( $context_key ) ) {
if ( ! empty( $related_attachment_scan_state['verified_attachment_id'] )
&& ( ! ai4seo_is_verified_attachment_reference_key( $context_key ) || ( ! is_int( $value ) && ( ! is_string( $value ) || ! ctype_digit( $value ) ) ) ) ) {
return array();
}
return array( absint( $value ) );
}
if ( ! is_string( $value ) || trim( $value ) === '' ) {
return array();
}
// WordPress and page builders may store arrays alongside object-bearing data in postmeta rows.
$serialized_decode_result = ai4seo_decode_related_attachment_serialized_value( $value );
if ( $serialized_decode_result['was_serialized'] ) {
if ( ! $serialized_decode_result['is_decoded'] ) {
return array();
}
// Scan only the sanitized tree so discarded object or enum bytes cannot become regex matches.
return ai4seo_extract_related_attachment_post_ids_from_value(
$serialized_decode_result['value'],
$context_key,
$depth + 1,
$related_attachment_scan_state
);
}
$trimmed_value = trim( $value );
// Elementor and block builders commonly store media references in JSON blobs.
if ( '' !== $trimmed_value && in_array( substr( $trimmed_value, 0, 1 ), array( '{', '[' ), true ) ) {
$json_value = json_decode( $trimmed_value, true );
if ( json_last_error() === JSON_ERROR_NONE && is_array( $json_value ) ) {
$related_attachment_post_ids = array_merge(
$related_attachment_post_ids,
ai4seo_extract_related_attachment_post_ids_from_value( $json_value, $context_key, $depth + 1, $related_attachment_scan_state )
);
}
}
// Fall back to pattern scanning for HTML, shortcodes, class names, URLs, and scalar meta values.
$related_attachment_post_ids = array_merge(
$related_attachment_post_ids,
ai4seo_extract_related_attachment_post_ids_from_string( $value, $context_key, $related_attachment_scan_state )
);
return ai4seo_normalize_related_attachment_post_ids( $related_attachment_post_ids );
}
// =========================================================================================== \
/**
* Builds a nested context key for related attachment scanning.
*
* @param string $context_key Parent context key.
* @param int|string $key Current array key.
* @return string Nested context key.
*/
function ai4seo_get_related_attachment_nested_context_key( string $context_key, $key ): string {
if ( ! is_string( $key ) ) {
return $context_key;
}
$key = trim( $key );
if ( '' === $key || ctype_digit( $key ) ) {
return $context_key;
}
if ( '' === $context_key ) {
return $key;
}
return $context_key . '.' . $key;
}
// =========================================================================================== \
/**
* Extracts attachment ID candidates from a string.
*
* @param string $content The string to scan.
* @param string $context_key The source key or field name.
* @param mixed $related_attachment_scan_state Optional mutable scan state.
* @return array Attachment ID candidates.
*/
function ai4seo_extract_related_attachment_post_ids_from_string( string $content, string $context_key = '', &$related_attachment_scan_state = null ): array {
// Unescape JSON-style slashes so URL and shortcode patterns can match the stored value.
$content = trim( str_replace( '\/', '/', $content ) );
if ( '' === $content ) {
return array();
}
$related_attachment_post_ids = array();
if ( ! empty( $related_attachment_scan_state['verified_attachment_id'] ) ) {
// Verification reuses this parser but accepts only typed values and native galleries.
if ( 'post_content' === $context_key ) {
if ( false !== strpos( $content, '[gallery' ) && preg_match_all( '/' . get_shortcode_regex( array( 'gallery' ) ) . '/s', $content, $galleries, PREG_SET_ORDER ) ) {
foreach ( $galleries as $gallery ) {
if ( '[' === $gallery[1] && ']' === $gallery[6] ) {
continue;
}
$attributes = shortcode_parse_atts( $gallery[3] );
if ( is_array( $attributes ) ) {
$related_attachment_post_ids = array_merge(
$related_attachment_post_ids,
ai4seo_get_verified_gallery_attachment_ids(
$attributes,
$related_attachment_scan_state['verified_attachment_id'],
$related_attachment_scan_state['source_post_id'] ?? 0
)
);
}
}
}
return ai4seo_normalize_related_attachment_post_ids( $related_attachment_post_ids );
}
if ( ! ai4seo_is_verified_attachment_reference_key( $context_key ) ) {
return array();
}
if ( preg_match( '/^[0-9,\s]+$/', $content ) ) {
return wp_parse_id_list( $content );
}
$url = ai4seo_normalize_generation_image_url( $content, $related_attachment_scan_state['page_url'] ?? '' );
return isset( $related_attachment_scan_state['verified_attachment_urls'][ $url ] )
? array( $related_attachment_scan_state['verified_attachment_id'] ) : array();
}
// Match common WordPress and builder patterns while intentionally ignoring ambiguous bare "id" keys.
$id_patterns = array(
'/\b(?:wp-image-|attachment[_-]|attachment-id-)([0-9]+)\b/i',
'/["\'](?:image_id|attachment_id|media_id|thumbnail_id|background_image)["\']\s*[:=]\s*["\']?([0-9]+)/i',
);
foreach ( $id_patterns as $this_id_pattern ) {
if ( preg_match_all( $this_id_pattern, $content, $matches ) ) {
foreach ( $matches[1] as $this_attachment_post_id ) {
$related_attachment_post_ids[] = absint( $this_attachment_post_id );
}
}
}
// Match galleries and other comma-separated media lists without assuming exclusive usage.
$id_list_patterns = array(
'/\b(?:ids|data-ids)=["\']([^"\']+)["\']/i',
'/\[(?:gallery|playlist)[^\]]*\bids=["\']?([^"\'\]\s]+(?:\s*,\s*[0-9]+)*)/i',
'/["\'](?:ids|gallery_ids|image_ids|attachment_ids)["\']\s*:\s*\[([0-9,\s"\']+)\]/i',
'/["\'](?:ids|gallery_ids|image_ids|attachment_ids)["\']\s*:\s*["\']([^"\']+)["\']/i',
);
foreach ( $id_list_patterns as $this_id_list_pattern ) {
if ( preg_match_all( $this_id_list_pattern, $content, $matches ) ) {
foreach ( $matches[1] as $this_attachment_post_id_list ) {
// JSON arrays can quote numeric IDs, while wp_parse_id_list expects plain separators.
$this_normalized_attachment_post_id_list = str_replace( array( '"', "'" ), '', $this_attachment_post_id_list );
$related_attachment_post_ids = array_merge( $related_attachment_post_ids, wp_parse_id_list( $this_normalized_attachment_post_id_list ) );
}
}
}
// Some custom fields store only a scalar ID or comma list, so require a media-like key first.
if ( ai4seo_is_attachment_like_reference_key( $context_key ) && preg_match( '/^[0-9,\s]+$/', $content ) ) {
$related_attachment_post_ids = array_merge( $related_attachment_post_ids, wp_parse_id_list( $content ) );
}
// Resolve local image URLs last because that can require attachment lookup work.
$related_attachment_post_ids = array_merge(
$related_attachment_post_ids,
ai4seo_extract_related_attachment_post_ids_from_image_urls( $content, $related_attachment_scan_state )
);
return ai4seo_normalize_related_attachment_post_ids( $related_attachment_post_ids );
}
// =========================================================================================== \
/**
* Extracts attachment IDs by resolving local image URLs from content.
*
* @param string $content The string to scan.
* @param mixed $related_attachment_scan_state Optional mutable scan state.
* @return array Attachment ID candidates.
*/
function ai4seo_extract_related_attachment_post_ids_from_image_urls( string $content, &$related_attachment_scan_state = null ): array {
$attachment_post_ids = array();
// Cover HTML attributes, CSS background values, and raw absolute URLs found in builder data.
$url_patterns = array(
'/\b(?:src|href)=["\']([^"\']+\.(?:jpe?g|png|gif|webp|avif)(?:\?[^"\']*)?)["\']/i',
'/url\(["\']?([^"\')]+\.(?:jpe?g|png|gif|webp|avif)(?:\?[^"\')]+)?)["\']?\)/i',
'/https?:\/\/[^\s"\'<>\\\\]+\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s"\'<>\\\\]*)?/i',
);
foreach ( $url_patterns as $this_url_pattern ) {
if ( ! preg_match_all( $this_url_pattern, $content, $matches ) ) {
continue;
}
$this_urls = isset( $matches[1] ) && is_array( $matches[1] ) && $matches[1]
? $matches[1]
: $matches[0];
foreach ( $this_urls as $this_url ) {
$this_url = trim( html_entity_decode( (string) $this_url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
if ( '' === $this_url ) {
continue;
}
// Count resolver attempts because ai4seo_get_attachment_id_from_src() can touch attachment lookup paths.
if ( is_array( $related_attachment_scan_state ) ) {
$max_image_url_attachment_lookups = absint( $related_attachment_scan_state['max_image_url_attachment_lookups'] ?? 0 );
$image_url_attachment_lookups = absint( $related_attachment_scan_state['image_url_attachment_lookups'] ?? 0 );
if ( $max_image_url_attachment_lookups > 0 && $image_url_attachment_lookups >= $max_image_url_attachment_lookups ) {
$related_attachment_scan_state['is_partial'] = true;
break 2;
}
$related_attachment_scan_state['image_url_attachment_lookups'] = $image_url_attachment_lookups + 1;
}
// Normalize root-relative and protocol-relative URLs before using WordPress attachment lookup.
if ( strpos( $this_url, '//' ) === 0 ) {
$this_url = set_url_scheme( $this_url );
} elseif ( strpos( $this_url, '/' ) === 0 ) {
$this_url = home_url( $this_url );
}
$attachment_post_id = ai4seo_get_attachment_id_from_src( $this_url );
if ( $attachment_post_id ) {
$attachment_post_ids[] = absint( $attachment_post_id );
}
}
}
return ai4seo_normalize_related_attachment_post_ids( $attachment_post_ids );
}
// =========================================================================================== \
/**
* Read declared gallery IDs or verify the target against a native parent-based gallery.
*
* Parent galleries inspect only the target attachment, never enumerate every sibling
* or execute shortcodes. Selection precedence and parent visibility follow gallery_shortcode().
*
* @param array $attributes Parsed native gallery attributes.
* @param int $attachment_id Target media ID.
* @param int $source_post_id Post containing the shortcode.
* @return array Declared IDs, or the verified target ID for a parent-based gallery.
*/
function ai4seo_get_verified_gallery_attachment_ids( array $attributes, int $attachment_id, int $source_post_id ): array {
if ( ! empty( $attributes['ids'] ) ) {
return ai4seo_normalize_verified_attachment_ids( $attributes['ids'] );
}
if ( ! empty( $attributes['include'] ) ) {
return ai4seo_normalize_verified_attachment_ids( $attributes['include'] );
}
$gallery_post_id = (int) ( $attributes['id'] ?? $source_post_id );
$attachment = get_post( $attachment_id );
if ( $gallery_post_id <= 0 || ! $attachment || 'attachment' !== $attachment->post_type
|| 'inherit' !== $attachment->post_status || 0 !== strpos( $attachment->post_mime_type, 'image/' )
|| $gallery_post_id !== (int) $attachment->post_parent
|| in_array( $attachment_id, wp_parse_id_list( $attributes['exclude'] ?? '' ), true ) ) {
return array();
}
$gallery_post = get_post( $gallery_post_id );
if ( ! $gallery_post
|| ( ! is_post_publicly_viewable( $gallery_post ) && ! current_user_can( 'read_post', $gallery_post_id ) )
|| post_password_required( $gallery_post ) ) {
return array();
}
return array( $attachment_id );
}
/**
* Validate media ID lists without converting partial numbers or floats to identities.
*
* @param mixed $value A scalar ID or explicit ID list.
* @return array Exact positive IDs.
*/
function ai4seo_normalize_verified_attachment_ids( $value ): array {
if ( is_string( $value ) && preg_match( '/^[0-9,\s]+$/', $value ) ) {
$value = preg_split( '/[,\s]+/', trim( $value ) );
}
$ids = array();
foreach ( (array) $value as $id ) {
if ( ( is_int( $id ) || ( is_string( $id ) && ctype_digit( $id ) ) ) && (int) $id > 0 ) {
$ids[] = (int) $id;
}
}
return array_values( array_unique( $ids ) );
}
/**
* Recognize defined media fields, excluding arbitrary IDs and image dimensions.
*
* @param string $context_key Nested builder field path.
* @return bool Whether the terminal field represents media.
*/
function ai4seo_is_verified_attachment_reference_key( string $context_key ): bool {
return 1 === preg_match( '/(?:^|\.)(?:_thumbnail_id|_product_image_gallery|attachment_id|attachment_ids|image_id|image_ids|media_id|media_ids|image|images|gallery|background_image|thumbnail|photo|picture|poster)(?:\.(?:id|ids|url))?$/i', $context_key );
}
/**
* Checks whether a key usually stores attachment references.
*
* @param string $context_key The source key or field name.
* @return bool True if the key likely points to media.
*/
function ai4seo_is_attachment_like_reference_key( string $context_key ): bool {
// Match broad media terms because builders use many custom key names for image references.
$context_key = strtolower( trim( $context_key ) );
if ( '' === $context_key ) {
return false;
}
$attachment_like_terms = array(
'attachment',
'background',
'featured',
'gallery',
'image',
'media',
'photo',
'picture',
'poster',
'thumbnail',
'_thumbnail_id',
'_product_image_gallery',
);
foreach ( $attachment_like_terms as $this_attachment_like_term ) {
if ( strpos( $context_key, $this_attachment_like_term ) !== false ) {
return true;
}
}
// Ambiguous keys such as id, ids, url, and src are ignored unless nested under a media-like context.
return false;
}
// =========================================================================================== \
/**
* Returns whether deep image usage search is supported for the current database size.
* Failed count lookups are treated as unsupported because the database size is unknown.
*
* @return array
*/
function ai4seo_get_deep_context_search_site_support_status(): array {
$current_num_posts_table_entries = ai4seo_get_current_posts_table_entries_count();
$current_num_postmeta_table_entries = ai4seo_get_current_postmeta_table_entries_count();
$blocking_reasons = array();
if ( $current_num_posts_table_entries < 0 ) {
$blocking_reasons[] = 'posts_count_unavailable';
} elseif ( $current_num_posts_table_entries >= AI4SEO_LARGE_SITE_POSTS_THRESHOLD ) {
$blocking_reasons[] = 'posts';
}
if ( $current_num_postmeta_table_entries < 0 ) {
$blocking_reasons[] = 'postmeta_count_unavailable';
} elseif ( $current_num_postmeta_table_entries >= AI4SEO_DEEP_CONTEXT_SEARCH_POSTMETA_THRESHOLD ) {
$blocking_reasons[] = 'postmeta';
}
return array(
'is_supported' => empty( $blocking_reasons ),
'blocking_reasons' => $blocking_reasons,
'posts_table_entries' => $current_num_posts_table_entries,
'postmeta_table_entries' => $current_num_postmeta_table_entries,
);
}
// =========================================================================================== \
/**
* Returns whether deep image usage search can be activated on the current site.
*
* @return bool
*/
function ai4seo_is_deep_context_search_supported_for_current_site(): bool {
$site_support_status = ai4seo_get_deep_context_search_site_support_status();
return (bool) $site_support_status['is_supported'];
}
// =========================================================================================== \
/**
* Deactivates deep image usage search and persists the default/off state.
*
* @return bool
*/
function ai4seo_disable_deep_context_search_for_images(): bool {
return ai4seo_update_setting( AI4SEO_SETTING_DEEP_CONTEXT_SEARCH_FOR_IMAGES, false );
}
// =========================================================================================== \
/**
* Disables deep image usage search if it is active on an unsupported site.
*
* @return bool True when the setting was disabled.
*/
function ai4seo_maybe_disable_deep_context_search_for_large_site(): bool {
$raw_settings = ai4seo_read_settings();
$is_deep_context_search_enabled = (bool) ( $raw_settings[ AI4SEO_SETTING_DEEP_CONTEXT_SEARCH_FOR_IMAGES ] ?? ai4seo_get_setting( AI4SEO_SETTING_DEEP_CONTEXT_SEARCH_FOR_IMAGES ) );
if ( ! $is_deep_context_search_enabled ) {
return false;
}
if ( ai4seo_is_deep_context_search_supported_for_current_site() ) {
return false;
}
return ai4seo_disable_deep_context_search_for_images();
}
// =========================================================================================== \
/**
* Runs an optional deep-context SELECT query with a database-level timeout.
*
* Returns an empty result when the current database cannot enforce a statement timeout,
* because these deep-context queries are optional and must not block cron execution.
*
* @param string $select_sql A fully prepared SELECT query.
* @param int $timeout_seconds The maximum database execution time in seconds.
* @param string $debug_context Short context for debug logs.
* @param bool|null $query_succeeded Receives whether the optional query actually completed.
* @param bool $record_timeout Whether to preserve the legacy timeout-disable side effect.
* @return array
*/
function ai4seo_get_col_with_optional_statement_timeout( string $select_sql, int $timeout_seconds = AI4SEO_DEEP_CONTEXT_SEARCH_QUERY_TIMEOUT_SECONDS, string $debug_context = '', ?bool &$query_succeeded = null, bool $record_timeout = true ): array {
global $wpdb;
$query_succeeded = false;
$select_sql = trim( $select_sql );
if ( ! $select_sql || ! preg_match( '/^SELECT\s/i', $select_sql ) ) {
ai4seo_debug_message( 630085035, 'Optional timed query skipped because it is not a SELECT query.', true );
return array();
}
$timeout_seconds = max( 1, absint( $timeout_seconds ) );
$timeout_support = ai4seo_get_database_statement_timeout_support();
if ( ! $timeout_support['supported'] ) {
ai4seo_debug_message( 630085036, 'Optional timed query skipped because statement timeouts are not supported by this database. ' . $debug_context );
return array();
}
$timed_select_sql = ai4seo_prepare_optional_timed_select_sql( $select_sql, $timeout_seconds, $timeout_support['engine'] );
if ( ! $timed_select_sql ) {
ai4seo_debug_message( 630085037, 'Optional timed query skipped because the timeout wrapper could not be built. ' . $debug_context, true );
return array();
}
$previous_suppress_errors = $wpdb->suppress_errors( true );
$previous_last_error = $wpdb->last_error;
$query_results = array();
$query_error = '';
$query_error_code = 0;
try {
// Safe: timed SQL wraps a SELECT that callers pass in fully prepared; the wrapper only adds a numeric timeout.
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Optional deep-context discovery requires a current uncached result and enforces a database-native statement timeout around caller-prepared SELECT SQL.
$query_results = $wpdb->get_col( $timed_select_sql );
$query_error = $wpdb->last_error;
} catch ( Throwable $e ) {
$query_error = $e->getMessage();
$query_error_code = (int) $e->getCode();
} finally {
$wpdb->suppress_errors( $previous_suppress_errors );
}
if ( $query_error ) {
$wpdb->last_error = $previous_last_error;
ai4seo_debug_message( 630085038, 'Optional timed query failed or timed out. ' . $debug_context . ' Error: ' . $query_error, true );
if ( $record_timeout && ai4seo_is_database_statement_timeout_error( $query_error, $query_error_code ) ) {
ai4seo_handle_deep_context_search_statement_timeout();
}
return array();
}
$wpdb->last_error = $previous_last_error;
if ( ! is_array( $query_results ) ) {
return array();
}
$query_succeeded = true;
return $query_results;
}
// =========================================================================================== \
/**
* Counts deep context search statement timeouts during one automated generation cron run.
*
* @return void
*/
function ai4seo_handle_deep_context_search_statement_timeout(): void {
if ( empty( $GLOBALS['ai4seo_is_running_automated_generation_cron_job'] ) ) {
return;
}
$GLOBALS['ai4seo_deep_context_search_statement_timeout_count'] = (int) ( $GLOBALS['ai4seo_deep_context_search_statement_timeout_count'] ?? 0 );
++$GLOBALS['ai4seo_deep_context_search_statement_timeout_count'];
if ( $GLOBALS['ai4seo_deep_context_search_statement_timeout_count'] < AI4SEO_DEEP_CONTEXT_SEARCH_MAX_TIMEOUTS_PER_CRON_RUN ) {
return;
}
ai4seo_disable_deep_context_search_for_images();
}
// =========================================================================================== \
/**
* Return the verified source selected by the attachment's current context policy.
*
* Fixed choices are reverified without falling back to another post. Automatic
* discovery checks the parent, then unique featured-image and optional deep-search
* matches. Legacy Related Media values are discovery hints only.
*
* @param int $attachment_post_id The attachment post ID.
* @param bool $require_editable_post Whether the current plugin user must be able to edit the context post.
* @return int Verified source post ID, or zero when no source can be selected safely.
*/
function ai4seo_get_first_attachment_using_post_id( int $attachment_post_id, bool $require_editable_post = false ): int {
$result = ai4seo_resolve_attachment_usage_context( $attachment_post_id, $require_editable_post );
return $result['post_id'];
}
/**
* Read the explicit media context decision without promoting legacy discovery hints.
*
* @param int $attachment_post_id Media ID.
* @return array Validated policy, including a read-success flag.
*/
function ai4seo_read_attachment_context_policy( int $attachment_post_id ): array {
global $wpdb;
$policy = array(
'mode' => 'automatic',
'post_id' => 0,
'read_succeeded' => true,
);
// Read authoritative rows so a concurrent first binding cannot be hidden by the metadata cache.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Context ownership requires current persisted rows.
$rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 2", $attachment_post_id, AI4SEO_POST_META_ATTACHMENT_CONTEXT_POLICY ) );
if ( $wpdb->last_error || ! is_array( $rows ) || count( $rows ) > 1 ) {
return array(
'mode' => 'invalid',
'post_id' => 0,
'read_succeeded' => false,
);
}
if ( ! $rows ) {
return $policy;
}
$value = json_decode( $rows[0], true );
if ( ! is_array( $value ) || ! isset( $value['mode'], $value['post_id'] )
|| ! is_int( $value['post_id'] )
|| ! ( ( 'fixed' === $value['mode'] && $value['post_id'] > 0 ) || ( 'ambiguous' === $value['mode'] && 0 === $value['post_id'] ) ) ) {
return array(
'mode' => 'invalid',
'post_id' => 0,
'read_succeeded' => false,
);
}
return array(
'mode' => $value['mode'],
'post_id' => $value['post_id'],
'read_succeeded' => true,
);
}
/**
* Persist a context choice under the existing per-resource database lock mechanism.
*
* @param int $attachment_post_id Media ID.
* @param string $mode Fixed, ambiguous, or automatic.
* @param int $post_id Source post for fixed choices.
* @param bool $replace Whether an explicit user action may replace an existing choice.
* @param array|null $state Shared source snapshot for this generation action.
* @return bool Whether the requested operation or preservation of an existing fixed choice succeeded.
*/
function ai4seo_update_attachment_context_policy( int $attachment_post_id, string $mode, int $post_id = 0, bool $replace = false, ?array &$state = null ): bool {
global $wpdb;
if ( ! ai4seo_is_wordpress_attachment_post( $attachment_post_id ) || ! in_array( $mode, array( 'fixed', 'ambiguous', 'automatic' ), true ) ) {
return false;
}
$lock_name = 'ai4seo_context_' . md5( $wpdb->postmeta . ':' . $attachment_post_id );
if ( ! ai4seo_acquire_database_advisory_lock( $lock_name ) ) {
return false;
}
try {
$current = ai4seo_read_attachment_context_policy( $attachment_post_id );
if ( ! $replace && 'fixed' === $current['mode'] ) {
return true;
}
if ( ! $current['read_succeeded'] && ! $replace ) {
return false;
}
if ( 'fixed' === $mode ) {
$verification = ai4seo_verify_attachment_usage_in_post( $attachment_post_id, $post_id, true, $state );
if ( ! $verification['verified'] ) {
return false;
}
}
if ( 'automatic' === $mode ) {
delete_post_meta( $attachment_post_id, AI4SEO_POST_META_ATTACHMENT_CONTEXT_POLICY );
} else {
$value = wp_json_encode(
array(
'mode' => $mode,
'post_id' => 'fixed' === $mode ? $post_id : 0,
)
);
update_post_meta( $attachment_post_id, AI4SEO_POST_META_ATTACHMENT_CONTEXT_POLICY, wp_slash( $value ) );
}
$stored = ai4seo_read_attachment_context_policy( $attachment_post_id );
return $stored['read_succeeded'] && $stored['mode'] === $mode && ( 'fixed' === $mode ? $post_id : 0 ) === $stored['post_id'];
} finally {
ai4seo_release_database_advisory_lock( $lock_name );
}
}
/**
* Build exact known URL identities, including WordPress-generated image variants.
*
* @param int $attachment_id Media ID.
* @param string $attachment_url Optional already resolved URL.
* @return array Exact normalized URL set.
*/
function ai4seo_get_attachment_context_image_urls( int $attachment_id, string $attachment_url = '' ): array {
$attachment_url = $attachment_url ? $attachment_url : (string) ai4seo_get_attachment_url( $attachment_id );
$urls = array_filter( array( $attachment_url ) );
$metadata = wp_get_attachment_metadata( $attachment_id );
if ( is_array( $metadata ) && ! empty( $metadata['file'] ) ) {
$uploads = wp_upload_dir();
$base = trailingslashit( $uploads['baseurl'] ) . $metadata['file'];
$urls[] = $base;
$dir = trailingslashit( dirname( $base ) );
foreach ( (array) ( $metadata['sizes'] ?? array() ) as $size ) {
if ( ! empty( $size['file'] ) ) {
$urls[] = $dir . $size['file'];
}
}
if ( ! empty( $metadata['original_image'] ) ) {
$urls[] = $dir . $metadata['original_image'];
}
}
$normalized = array();
foreach ( $urls as $url ) {
$url = ai4seo_normalize_generation_image_url( $url, $attachment_url );
if ( '' !== $url ) {
$normalized[ $url ] = true;
}
}
return $normalized;
}
/**
* Inspect real image tokens and retain text only from their own single-image container.
*
* @param string $html Saved markup.
* @param int $attachment_id Media ID.
* @param string $attachment_url Media URL.
* @param string $page_url Relative URL base.
* @param array $known_urls Optional shared image identity.
* @param int $source_post_id Source for native galleries without an explicit parent.
* @return array Image evidence and isolated visible text.
*/
function ai4seo_get_attachment_html_evidence( string $html, int $attachment_id, string $attachment_url, string $page_url = '', array $known_urls = array(), int $source_post_id = 0 ): array {
$result = array(
'verified' => false,
'text' => '',
'image_count' => 0,
'gallery_ids' => array(),
);
$processor = new WP_HTML_Tag_Processor( $html );
$containers = array();
$hidden = array();
$known_urls = $known_urls ? $known_urls : ai4seo_get_attachment_context_image_urls( $attachment_id, $attachment_url );
while ( $processor->next_token() ) {
$tag = $processor->get_tag();
if ( '#text' === $processor->get_token_type() && ! $hidden ) {
$shortcode_state = array(
'verified_attachment_id' => $attachment_id,
'source_post_id' => $source_post_id,
);
$result['gallery_ids'] = array_merge( $result['gallery_ids'], ai4seo_extract_related_attachment_post_ids_from_string( $processor->get_modifiable_text(), 'post_content', $shortcode_state ) );
foreach ( $containers as &$container ) {
if ( strlen( $container['text'] ) < 8192 ) {
$container['text'] .= ' ' . $processor->get_modifiable_text();
}
}
unset( $container );
continue;
}
if ( ! $tag ) {
continue;
}
// Raw-text elements already include their closing tag in this token.
if ( in_array( $tag, array( 'SCRIPT', 'STYLE' ), true ) ) {
continue;
}
// Match the visible-text parser's handling of an omitted HEAD closer.
if ( 'BODY' === $tag && ! $processor->is_tag_closer() && array( 'HEAD' ) === $hidden ) {
$hidden = array();
}
if ( in_array( $tag, array( 'HEAD', 'NOSCRIPT', 'TEMPLATE' ), true ) ) {
if ( $processor->is_tag_closer() ) {
if ( end( $hidden ) === $tag ) {
array_pop( $hidden );
}
} else {
$hidden[] = $tag;
}
continue;
}
if ( $hidden ) {
continue;
}
if ( in_array( $tag, array( 'FIGURE', 'LI' ), true ) ) {
if ( $processor->is_tag_closer() ) {
$container = array_pop( $containers );
if ( $container && $container['tag'] === $tag && $container['eligible']
&& 1 === $container['images'] && 1 === $container['matches'] && '' === $result['text'] ) {
$result['text'] = ai4seo_mb_substr( ai4seo_normalize_generation_visible_text( $container['text'] ), 0, 1000 );
}
} else {
$containers[] = array(
'tag' => $tag,
'eligible' => 'FIGURE' === $tag || $processor->has_class( 'kadence-blocks-gallery-item' ),
'images' => 0,
'matches' => 0,
'text' => '',
);
}
}
if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) {
++$result['image_count'];
$matches = ai4seo_generation_image_matches( $processor, $attachment_id, $attachment_url, $page_url, $known_urls );
$result['verified'] = $result['verified'] || $matches;
foreach ( $containers as &$container ) {
++$container['images'];
$container['matches'] += $matches ? 1 : 0;
}
unset( $container );
}
}
return $result;
}
/**
* Verify saved block attributes whose schema explicitly identifies image media.
*
* @param array $blocks Parsed WordPress blocks.
* @param int $attachment_id Media ID.
* @param int $depth Bounded block nesting.
* @param string|null $image_text Caption belonging only to a verified gallery item.
* @return bool Whether an explicit media reference exists.
*/
function ai4seo_attachment_blocks_reference_image( array $blocks, int $attachment_id, int $depth = 0, ?string &$image_text = null ): bool {
if ( $depth > 8 ) {
return false;
}
foreach ( $blocks as $block ) {
$attributes = (array) ( $block['attrs'] ?? array() );
$name = $block['blockName'] ?? '';
// Saved HTML is checked separately, including conflicting IDs on an image token.
if ( false === stripos( (string) ( $block['innerHTML'] ?? '' ), '
$post_id,
'verified' => false,
'complete' => true,
'reason' => 'not_found',
'text' => '',
);
if ( ! ai4seo_is_attachment_context_post_eligible( $post_id ) ) {
$result['reason'] = 'unavailable';
return $result;
}
if ( $require_editable_post && ! ai4seo_can_edit_post( $post_id ) ) {
$result['reason'] = 'forbidden';
return $result;
}
if ( null === $state ) {
$state = array();
}
$key = $attachment_id . ':' . $post_id;
if ( isset( $state['verified'][ $key ] ) ) {
return $state['verified'][ $key ];
}
$post = get_post( $post_id );
$body = (string) $post->post_content;
if ( strlen( $body ) > 2097152 ) {
$result['complete'] = false;
$result['reason'] = 'incomplete';
return $result;
}
$scan = ai4seo_get_empty_related_attachment_scan_result();
$meta = ai4seo_get_related_attachment_scannable_post_meta_rows( $post_id, $scan );
if ( $wpdb->last_error ) {
$result['complete'] = false;
$result['reason'] = 'read_failed';
return $result;
}
$bytes = strlen( $body );
foreach ( $meta as $row ) {
$bytes += strlen( $row['meta_value'] );
}
$remaining = $state['remaining_bytes'] ?? AI4SEO_ATTACHMENT_CONTEXT_MAX_BYTES;
if ( $bytes > $remaining ) {
$result['complete'] = false;
$result['reason'] = 'budget_exhausted';
return $result;
}
$state['remaining_bytes'] = $remaining - $bytes;
$state['urls'] = $state['urls'] ?? ai4seo_get_attachment_context_image_urls( $attachment_id );
$url = (string) ai4seo_get_attachment_url( $attachment_id );
$html = ai4seo_get_attachment_html_evidence( $body, $attachment_id, $url, (string) get_permalink( $post_id ), $state['urls'], $post_id );
$block_text = '';
$result['verified'] = $html['verified'] || ai4seo_attachment_blocks_reference_image( parse_blocks( $body ), $attachment_id, 0, $block_text );
$result['text'] = $html['text'] ? $html['text'] : $block_text;
$result['complete'] = ! $scan['is_partial'];
$extraction_state = ai4seo_get_related_attachment_scan_state();
$extraction_state['verified_attachment_id'] = $attachment_id;
$extraction_state['verified_attachment_urls'] = $state['urls'];
$extraction_state['page_url'] = (string) get_permalink( $post_id );
$result['verified'] = $result['verified'] || in_array( $attachment_id, $html['gallery_ids'], true );
foreach ( $meta as $row ) {
// Only complete selected values can prove a reference; truncated prefixes are never evidence.
if ( $scan['is_partial'] ) {
continue;
}
$value = $row['meta_value'];
if ( '_thumbnail_id' === $row['meta_key'] ) {
$result['verified'] = $result['verified'] || ( ctype_digit( $value ) && $attachment_id === (int) $value );
continue;
}
// Reuse the sanitized builder decoder and media-key-aware extractor for structured data.
// HTML values go through the exact token matcher rather than the discovery regex.
if ( false !== stripos( $value, '
array(),
'complete' => true,
);
$limit = AI4SEO_ATTACHMENT_CONTEXT_MAX_CANDIDATES + 1;
if ( 'thumbnail' === $kind ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Current, bounded relation candidates must reflect external media edits.
$ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %s ORDER BY post_id LIMIT %d", (string) $attachment_id, $limit ) );
$result['complete'] = ! $wpdb->last_error;
} elseif ( ! ai4seo_get_setting( AI4SEO_SETTING_DEEP_CONTEXT_SEARCH_FOR_IMAGES ) ) {
$ids = array();
} elseif ( ! ai4seo_is_deep_context_search_supported_for_current_site() ) {
$ids = array();
$result['complete'] = false;
} else {
// Broad discovery deliberately includes numeric candidates; exact parsing decides afterwards.
$id_like = '%' . $wpdb->esc_like( (string) $attachment_id ) . '%';
$image_url = (string) ai4seo_get_attachment_url( $attachment_id );
$url_like = '%' . $wpdb->esc_like( $image_url ? $image_url : '__missing_image_url__' ) . '%';
$content_ok = false;
$meta_ok = false;
$content = ai4seo_get_col_with_optional_statement_timeout(
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID != %d AND post_type NOT IN ('attachment','revision','nav_menu_item') AND post_status NOT IN ('trash','auto-draft') AND (post_content LIKE %s OR post_content LIKE %s) ORDER BY ID LIMIT %d", $attachment_id, $id_like, $url_like, $limit ),
AI4SEO_DEEP_CONTEXT_SEARCH_QUERY_TIMEOUT_SECONDS,
'Verified image context candidates',
$content_ok,
false
);
$meta = ai4seo_get_col_with_optional_statement_timeout(
$wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE post_id != %d AND (meta_value LIKE %s OR meta_value LIKE %s) ORDER BY post_id LIMIT %d", $attachment_id, $id_like, $url_like, $limit ),
AI4SEO_DEEP_CONTEXT_SEARCH_QUERY_TIMEOUT_SECONDS,
'Verified image context metadata candidates',
$meta_ok,
false
);
$legacy = ai4seo_get_attachment_related_post_id( $attachment_id );
$ids = array_merge( $legacy ? array( $legacy ) : array(), $content, $meta );
$result['complete'] = $content_ok && $meta_ok;
}
$ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $ids ) ) ) );
$result['complete'] = $result['complete'] && count( $ids ) < $limit;
$result['post_ids'] = array_slice( $ids, 0, AI4SEO_ATTACHMENT_CONTEXT_MAX_CANDIDATES );
$state['candidates'][ $cache_key ] = $result;
return $result;
}
/**
* Build only the allowed source identity and the isolated image text.
*
* @param array $verification Verified source.
* @param bool $include_post_identity Whether the relation supports the post identity.
* @return string API-compatible context.
*/
function ai4seo_build_verified_attachment_context( array $verification, bool $include_post_identity ): string {
if ( empty( $verification['verified'] ) ) {
return '';
}
$text = '';
if ( $include_post_identity ) {
$post_id = $verification['post_id'];
$text = "Sub Page Title: '" . sanitize_text_field( get_the_title( $post_id ) ) . "'. URL: '" . esc_url_raw( get_permalink( $post_id ) ) . "'. WordPress Post Type: '" . sanitize_key( get_post_type( $post_id ) ) . "'.";
}
if ( '' !== $verification['text'] ) {
$text .= " Image surrounding content: '" . $verification['text'] . "'.";
}
return trim( $text );
}
/**
* Resolve one image context without changing choices or silently replacing a fixed source.
*
* @param int $attachment_id Media ID.
* @param bool $require_editable_post Enforce source access for interactive callers.
* @param array|null $state Sources already verified during the same generation action.
* @return array Policy, source, verification status, and API context.
*/
function ai4seo_resolve_attachment_usage_context( int $attachment_id, bool $require_editable_post = false, ?array &$state = null ): array {
$policy = ai4seo_read_attachment_context_policy( $attachment_id );
$result = array(
'mode' => $policy['mode'],
'post_id' => 0,
'verified' => false,
'reason' => 'not_found',
'context' => '',
);
if ( ! ai4seo_is_wordpress_attachment_post( $attachment_id ) || ! $policy['read_succeeded'] ) {
$result['reason'] = 'read_failed';
return $result;
}
if ( 'ambiguous' === $policy['mode'] ) {
$result['reason'] = 'ambiguous';
return $result;
}
$state = $state ?? array();
if ( 'fixed' === $policy['mode'] ) {
$verification = ai4seo_verify_attachment_usage_in_post( $attachment_id, $policy['post_id'], $require_editable_post, $state );
} else {
$parent = absint( get_post_field( 'post_parent', $attachment_id ) );
$verification = ai4seo_verify_attachment_usage_in_post( $attachment_id, $parent, $require_editable_post, $state );
if ( ! $verification['complete'] ) {
$result['reason'] = $verification['reason'];
return $result;
}
if ( ! $verification['verified'] ) {
foreach ( array( 'thumbnail', 'deep' ) as $kind ) {
$candidates = ai4seo_find_attachment_context_candidates( $attachment_id, $kind, $state );
$verified = array();
$complete = $candidates['complete'];
$visited = $state['visited'] ?? ( $parent ? array( $parent => true ) : array() );
foreach ( $candidates['post_ids'] as $candidate_id ) {
if ( count( $visited ) >= AI4SEO_ATTACHMENT_CONTEXT_MAX_CANDIDATES && ! isset( $visited[ $candidate_id ] ) ) {
$complete = false;
break;
}
$visited[ $candidate_id ] = true;
$state['visited'] = $visited;
$check = ai4seo_verify_attachment_usage_in_post( $attachment_id, $candidate_id, $require_editable_post, $state );
$complete = $complete && $check['complete'];
if ( $check['verified'] ) {
$verified[] = $check;
}
}
if ( ! $complete || count( $verified ) > 1 ) {
$result['reason'] = $complete ? 'ambiguous' : 'incomplete';
return $result;
}
if ( 1 === count( $verified ) ) {
$verification = $verified[0];
$result['context'] = ai4seo_build_verified_attachment_context( $verification, 'thumbnail' === $kind );
break;
}
}
} else {
$result['context'] = ai4seo_build_verified_attachment_context( $verification, true );
}
}
$result['reason'] = $verification['reason'];
$result['verified'] = $verification['verified'];
if ( $verification['verified'] ) {
$result['post_id'] = $verification['post_id'];
if ( 'fixed' === $policy['mode'] ) {
$result['context'] = ai4seo_build_verified_attachment_context( $verification, true );
}
}
return $result;
}
/**
* Bind a generation action's verified sources without overwriting an existing fixed choice.
*
* @param int $attachment_id Media ID.
* @param array $source_post_ids All selected source posts for this medium.
* @param array|null $state Reusable source snapshot within this generation action.
* @return array Preparation result; invalid sources permit context-free generation.
*/
function ai4seo_prepare_attachment_context_policy( int $attachment_id, array $source_post_ids, ?array &$state = null ): array {
$result = array(
'succeeded' => true,
'reason' => '',
'contextless' => false,
);
$policy = ai4seo_read_attachment_context_policy( $attachment_id );
if ( ! $policy['read_succeeded'] ) {
return array(
'succeeded' => false,
'reason' => 'read_failed',
'contextless' => true,
);
}
if ( 'fixed' === $policy['mode'] || ! $source_post_ids ) {
return $result;
}
$verified = array();
$state = $state ?? array();
$complete = true;
foreach ( array_unique( array_map( 'absint', $source_post_ids ) ) as $source_id ) {
$check = ai4seo_verify_attachment_usage_in_post( $attachment_id, $source_id, true, $state );
$complete = $complete && $check['complete'];
if ( $check['verified'] ) {
$verified[] = $source_id;
}
}
if ( ! $verified || ! $complete ) {
return array(
'succeeded' => true,
'reason' => 'not_found',
'contextless' => true,
);
}
$mode = 1 === count( $verified ) ? 'fixed' : 'ambiguous';
$result['succeeded'] = ai4seo_update_attachment_context_policy( $attachment_id, $mode, 'fixed' === $mode ? $verified[0] : 0, false, $state );
$result['reason'] = $result['succeeded'] ? $mode : 'save_failed';
return $result;
}
/**
* Returns the first eligible post ID from a list of candidates.
*
* @param array $candidate_post_ids List of post IDs.
* @param bool $require_editable_post Whether the current plugin user must be able to edit the candidate.
* @return int first eligible post id or 0
*/
function ai4seo_get_first_eligible_attachment_context_post_id( array $candidate_post_ids, bool $require_editable_post = false ): int {
foreach ( $candidate_post_ids as $candidate_post_id ) {
$candidate_post_id = absint( $candidate_post_id );
if ( ai4seo_is_attachment_context_post_eligible( $candidate_post_id )
&& ( ! $require_editable_post || ai4seo_can_edit_post( $candidate_post_id ) ) ) {
return $candidate_post_id;
}
}
return 0;
}
// =========================================================================================== \
/**
* Checks whether a post can be used for attachment context.
*
* @param int $post_id the post id.
* @return bool true if eligible
*/
function ai4seo_is_attachment_context_post_eligible( int $post_id ): bool {
$post_id = absint( $post_id );
if ( $post_id <= 0 ) {
return false;
}
$post = get_post( $post_id );
if ( ! $post ) {
return false;
}
if ( in_array( $post->post_status, array( 'trash', 'auto-draft' ), true ) ) {
return false;
}
if ( in_array( $post->post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true ) ) {
return false;
}
return true;
}
// =========================================================================================== \
/**
* Returns the frontend URL for a public attachment context post.
*
* @param int $post_id the post id.
* @return string public frontend url or empty string
*/
function ai4seo_get_attachment_context_frontend_post_url( int $post_id ): string {
$post_id = absint( $post_id );
if ( ! ai4seo_is_attachment_context_post_eligible( $post_id ) ) {
return '';
}
$post = get_post( $post_id );
if ( ! $post ) {
return '';
}
// Only expose links that should resolve as normal frontend pages for the current site.
if ( function_exists( 'is_post_publicly_viewable' ) ) {
$is_post_publicly_viewable = (bool) is_post_publicly_viewable( $post );
} else {
$post_status = get_post_status_object( $post->post_status );
$post_type = get_post_type_object( $post->post_type );
$is_post_publicly_viewable = (bool) (
$post_status
&& $post_status->public
&& $post_type
&& $post_type->publicly_queryable
);
}
if ( ! $is_post_publicly_viewable ) {
return '';
}
$post_url = get_permalink( $post );
return $post_url ? (string) $post_url : '';
}
// =========================================================================================== \
/**
* Returns the language of the attachment
*
* @param int $attachment_post_id the attachment post id.
* @return string the language of the attachment
*/
function ai4seo_get_attachments_language( int $attachment_post_id ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Preserve the public attachment-language signature.
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 925515687, 'Prevented loop', true );
return '';
}
return sanitize_text_field( ai4seo_get_setting( AI4SEO_SETTING_ATTACHMENT_ATTRIBUTES_GENERATION_LANGUAGE ) );
}
/**
* Normalize attachment-attribute identifiers to the canonical string domain.
*
* @param array $attachment_attribute_identifiers Raw attachment-attribute identifiers.
* @return array Canonical string identifiers.
*/
function ai4seo_normalize_attachment_attribute_identifier_list( array $attachment_attribute_identifiers ): array {
// Retain only exact identifier strings so later strict comparisons cannot coerce scalar lookalikes.
$normalized_attachment_attribute_identifiers = array();
foreach ( $attachment_attribute_identifiers as $attachment_attribute_identifier ) {
if ( ! is_string( $attachment_attribute_identifier ) || '' === $attachment_attribute_identifier ) {
continue;
}
$normalized_attachment_attribute_identifiers[] = $attachment_attribute_identifier;
}
return array_values( array_unique( $normalized_attachment_attribute_identifiers ) );
}
/**
* Retrieves the active attachment attributes.
*
* @return array The active attachment attributes.
*/
function ai4seo_get_active_attachment_attributes(): array {
$active_attachment_attributes = ai4seo_get_setting( AI4SEO_SETTING_ACTIVE_ATTACHMENT_ATTRIBUTES );
if ( ! is_array( $active_attachment_attributes ) ) {
return array();
}
return ai4seo_normalize_attachment_attribute_identifier_list( $active_attachment_attributes );
}
/**
* Retrieves the supported attachment post types
*
* @param bool $require_active_attachment_attributes The require active attachment attributes value.
* @return array the supported attachment post types
*/
function ai4seo_get_supported_attachment_post_types( bool $require_active_attachment_attributes = true ): array {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 867135627, 'Prevented loop', true );
return array();
}
if ( $require_active_attachment_attributes ) {
$ai4seo_active_attachment_attributes = ai4seo_get_active_attachment_attributes();
if ( ! $ai4seo_active_attachment_attributes ) {
return array();
}
}
$supported_attachment_post_types = array( 'attachment' );
if ( ai4seo_is_plugin_or_theme_active( AI4SEO_THIRD_PARTY_PLUGIN_NEXTGEN_GALLERY ) ) {
$supported_attachment_post_types[] = AI4SEO_NEXTGEN_GALLERY_POST_TYPE;
}
return $supported_attachment_post_types;
}
/**
* Calculate the generation credit cost for one attachment post.
*
* @param array|null $only_this_attachment_attributes Optional attachment attribute identifiers to include.
* @return int Credit cost per attachment post.
*/
function ai4seo_calculate_attachment_attributes_credits_cost_per_attachment_post( $only_this_attachment_attributes = null ): int {
$attachment_attributes_price_table = ai4seo_get_attachment_attributes_price_table( $only_this_attachment_attributes );
if ( empty( $attachment_attributes_price_table ) ) {
return 1;
}
// calculate total costs.
return array_sum( $attachment_attributes_price_table );
}
/**
* Return credit prices for active attachment attributes.
*
* @param array|null $only_this_attachment_attributes Optional attachment attribute identifiers to include.
* @return array Attachment attribute identifiers mapped to credit costs.
*/
function ai4seo_get_attachment_attributes_price_table( $only_this_attachment_attributes = null ): array {
$attachment_field_descriptors = ai4seo_get_generation_field_descriptors( 'attachment_attributes' );
// Keep a non-empty caller filter restrictive even when all submitted identifiers normalize away.
$active_attachment_attributes = ai4seo_get_active_attachment_attributes();
$restrict_to_requested_attachment_attributes = is_array( $only_this_attachment_attributes ) && ! empty( $only_this_attachment_attributes );
if ( is_array( $only_this_attachment_attributes ) ) {
$only_this_attachment_attributes = ai4seo_normalize_attachment_attribute_identifier_list( $only_this_attachment_attributes );
}
if ( empty( $active_attachment_attributes ) ) {
return array();
}
$price_table = array();
foreach ( $active_attachment_attributes as $this_active_attachment_attribute_identifier ) {
if ( $restrict_to_requested_attachment_attributes && ! in_array( $this_active_attachment_attribute_identifier, $only_this_attachment_attributes, true ) ) {
continue;
}
if ( ! defined( 'AI4SEO_ATTACHMENT_ATTRIBUTES_DETAILS' ) ) {
$price_table[ $this_active_attachment_attribute_identifier ] = 1; // fallback to 1 credit per attribute.
continue;
}
$price_table[ $this_active_attachment_attribute_identifier ] = $attachment_field_descriptors[ $this_active_attachment_attribute_identifier ]['flat-credits-cost'] ?? 1;
}
return $price_table;
}
/**
* Return display names for active attachment attributes.
*
* @param array|null $active_attachment_attributes Optional active attribute identifiers.
* @return array Active attachment attribute display names.
*/
function ai4seo_get_active_attachment_attributes_names( $active_attachment_attributes = null ): array {
$attachment_field_descriptors = ai4seo_get_generation_field_descriptors( 'attachment_attributes' );
// Align optional caller input with the canonical setting domain used by the default accessor.
if ( null === $active_attachment_attributes ) {
$active_attachment_attributes = ai4seo_get_active_attachment_attributes();
} elseif ( is_array( $active_attachment_attributes ) ) {
$active_attachment_attributes = ai4seo_normalize_attachment_attribute_identifier_list( $active_attachment_attributes );
} else {
$active_attachment_attributes = array();
}
$active_attachment_attributes_names = array();
foreach ( $attachment_field_descriptors as $ai4seo_this_attachment_attribute_identifier => $ai4seo_this_attachment_attribute_details ) {
if ( in_array( $ai4seo_this_attachment_attribute_identifier, $active_attachment_attributes, true ) && isset( $ai4seo_this_attachment_attribute_details['name'] ) ) {
$active_attachment_attributes_names[] = $ai4seo_this_attachment_attribute_details['name'];
}
}
return $active_attachment_attributes_names;
}
// endregion
// ___________________________________________________________________________________________.