' . esc_html__( 'Custom Instructions', 'ai-for-seo' ) . ': ' . esc_html__( 'The stored instructions cannot be edited safely. They will be preserved when other fields are saved.', 'ai-for-seo' ) . '
' . esc_html__( 'An administrator can use Help → Inspect post for diagnosis. After the data has been corrected, close and reopen this entry to check it again. Keep any unsaved changes before reopening.', 'ai-for-seo' ) . '
';
}
return $html . '
';
}
/**
* Classify submitted fields against the editor's original values without changing storage.
*
* @param array $state Current authoritative editor state.
* @param array $updates Requested text fields, including custom_instructions when present.
* @param array $baseline Original field fingerprints.
* @param array $recovery Existing explicit metadata resolutions.
* @return array Per-field admission and current readable values.
*/
function ai4seo_classify_editor_save_fields( array $state, array $updates, array $baseline, array $recovery = array() ): array {
$result = array();
foreach ( $updates as $field => $value ) {
$current = 'custom_instructions' === $field ? $state['instructions'] : ( $state['fields'][ $field ] ?? array() );
$status = 'unavailable';
if ( ! empty( $current['writable'] ) ) {
$status = isset( $baseline[ $field ] ) && hash_equals( $current['fingerprint'], $baseline[ $field ] ) ? 'ready' : 'conflict';
}
if ( isset( $recovery['resolutions'][ $field ], $state['storage']['conflicts'][ $field ] ) ) {
$safe = ai4seo_filter_metadata_recovery_updates( array( $field => $value ), $state['storage'], $recovery );
$status = $safe ? 'ready' : 'conflict';
}
$result[ $field ] = array(
'status' => $status,
'known' => ! empty( $current['known'] ),
'current' => ! empty( $current['known'] ) ? $current['value'] : null,
'fingerprint' => $current['fingerprint'] ?? '',
);
}
return $result;
}
/**
* Explain a failed reservation without treating damaged ownership as an empty queue.
* This read is diagnostic only; the shared fence remains the sole admission authority.
*
* @param string $context Queue context.
* @return string Translated diagnostic.
*/
function ai4seo_get_editor_reservation_notice( string $context ): string {
$options = ai4seo_get_bulk_generation_queue_options_by_context( $context );
foreach ( array( 'pending', 'processing', 'force_overwrite' ) as $key ) {
if ( isset( $options[ $key ] ) && ! ai4seo_inspect_post_state_candidates( $options[ $key ], array() )['complete'] ) {
return __( 'Texts could not be reserved because queue data is unreadable. Your drafts are retained. Use Help → Inspect post for diagnosis; waiting alone may not resolve this.', 'ai-for-seo' );
}
}
$snapshot = ai4seo_uses_post_state_tables()
? array(
'exists' => true,
'value' => ai4seo_read_post_state_work( AI4SEO_POST_STATE_BUCKETS[ $options['processing'] ] ),
)
: ai4seo_get_raw_option_snapshot( ai4seo_get_processing_claim_lease_option_name( $options['processing'] ) );
$leases = array();
if ( null === $snapshot || ( $snapshot['exists'] && ( ! ai4seo_validate_processing_claim_leases( $snapshot['value'], $leases ) || ! ai4seo_validate_processing_claim_lease_context( $options['processing'], $leases ) ) ) ) {
return __( 'Generation ownership data could not be verified. Texts were not overwritten and your drafts are retained. Use Help → Inspect post for diagnosis.', 'ai-for-seo' );
}
return __( 'Texts could not be reserved safely. Generation may still own this entry, or its previous operation needs recovery. Your drafts are retained.', 'ai-for-seo' );
}
/**
* Save a validated editor request through existing writers and their generation fence.
* Legacy clients continue using their existing handlers without this additive protocol.
*
* @param int $post_id Authorized entry.
* @param string $context Editor context.
* @param array $updates Validated text updates.
* @param array $request Sanitized editor request.
* @param array $recovery Existing metadata conflict resolutions.
* @return array|WP_Error Field results or invalid-request error.
*/
function ai4seo_save_editor_field_changes( int $post_id, string $context, array $updates, array $request, array $recovery = array() ) {
$taxonomy_proxy = ai4seo_is_taxonomy_proxy( $post_id );
if ( $taxonomy_proxy && ( 'metadata' !== $context || ! ai4seo_can_edit_taxonomy_proxy( $post_id )
|| ! ai4seo_is_database_advisory_lock_owned_by_current_connection( ai4seo_get_taxonomy_proxy_sync_lock_name() ) ) ) {
return new WP_Error( 'taxonomy_editor_unavailable', __( 'This taxonomy entry could not be reserved safely. Your drafts have been retained.', 'ai-for-seo' ) );
}
$prefix = $context . '_editor_';
$baseline = $request[ $prefix . 'baseline' ] ?? null;
if ( ! is_array( $baseline ) ) {
return new WP_Error( 728451921, __( 'The editor baseline is missing. Keep your drafts and reopen this entry before saving.', 'ai-for-seo' ) );
}
if ( array_key_exists( $prefix . 'custom_instructions', $request ) ) {
if ( ! is_string( $request[ $prefix . 'custom_instructions' ] ) ) {
return new WP_Error( 728451921, __( 'Custom instructions must be text.', 'ai-for-seo' ) );
}
$updates['custom_instructions'] = ai4seo_normalize_custom_instructions_value( $request[ $prefix . 'custom_instructions' ] );
}
foreach ( $updates as $field => &$value ) {
if ( ! is_string( $baseline[ $field ] ?? null ) || 1 !== preg_match( '/\A[0-9a-f]{64}\z/D', $baseline[ $field ] ) ) {
return new WP_Error( 728451921, __( 'The editor baseline is incomplete. Keep your drafts and reopen this entry before saving.', 'ai-for-seo' ) );
}
$value = 'custom_instructions' === $field ? $value : ai4seo_normalize_editor_input_value( (string) $value );
}
unset( $value );
$state = ai4seo_read_editor_state( $post_id, $context );
$output_before = $taxonomy_proxy ? ai4seo_read_metadata_output_state( $post_id ) : array();
$activity = 'metadata' === $context ? 'metadata-bulk-generated' : 'attachment-attributes-bulk-generated';
$error_token = ai4seo_get_recent_activity_error_token( $post_id, $activity );
$fields = ai4seo_classify_editor_save_fields( $state, $updates, $baseline, $recovery );
$attempted = array();
$details = array( 'commit_state' => 'not_committed' );
$fence = array();
$warnings = array();
$response = array();
$texts = array_diff_key( $updates, array( 'custom_instructions' => true ) );
$ready_text = array_filter(
$texts,
static function ( $field ) use ( $fields ): bool {
return 'ready' === $fields[ $field ]['status'];
},
ARRAY_FILTER_USE_KEY
);
if ( $ready_text ) {
$persist = static function () use ( $post_id, $context, $taxonomy_proxy, $texts, $baseline, $recovery, &$fields, &$attempted, &$details ): bool {
$current = ai4seo_read_editor_state( $post_id, $context );
$fields = array_replace( $fields, ai4seo_classify_editor_save_fields( $current, $texts, $baseline, $recovery ) );
foreach ( $texts as $field => $value ) {
if ( 'ready' === $fields[ $field ]['status'] ) {
$attempted[ $field ] = $value;
}
}
if ( ! $attempted ) {
return false;
}
if ( 'metadata' === $context ) {
$recovery['expected_metadata'] = $current['storage']['active_metadata'];
if ( $taxonomy_proxy ) {
$recovery['taxonomy_proxy'] = true;
return ai4seo_save_active_metadata_to_postmeta( $post_id, $attempted, false, $details, array(), array(), $recovery );
}
ai4seo_update_active_metadata( $post_id, $attempted, true, $details, $recovery );
return ! empty( $details['active_metadata_succeeded'] ) || ! empty( $details['confirmed_fields'] );
}
return ai4seo_update_attachment_attributes( $post_id, $attempted, true, $details, $current['values'] );
};
ai4seo_save_manual_editor_values_with_generation_fence( $post_id, $context, $persist, $fence, $details );
if ( empty( $fence['reservation_succeeded'] ) ) {
$warnings[] = ai4seo_get_editor_reservation_notice( $context );
}
}
// Instructions have their own conditional writer and can succeed independently of text admission.
if ( isset( $fields['custom_instructions'] ) && 'ready' === $fields['custom_instructions']['status']
&& ( ! $taxonomy_proxy || ( ai4seo_can_edit_taxonomy_proxy( $post_id )
&& ai4seo_is_database_advisory_lock_owned_by_current_connection( ai4seo_get_taxonomy_proxy_sync_lock_name() ) ) ) ) {
$key = ai4seo_get_custom_instructions_postmeta_key_by_bulk_generation_queue_context( $context );
if ( $taxonomy_proxy ) {
$saved = ai4seo_save_manual_editor_values_with_generation_fence(
$post_id,
$context,
static function () use ( $post_id, $key, $updates, $state ): bool {
return ai4seo_save_custom_instructions_postmeta( $post_id, $key, $updates['custom_instructions'], $state['instructions']['value'] );
}
);
} else {
$saved = ai4seo_save_custom_instructions_postmeta( $post_id, $key, $updates['custom_instructions'], $state['instructions']['value'] );
}
$fields['custom_instructions']['status'] = $saved ? 'saved' : 'unconfirmed';
}
$after = ai4seo_read_editor_state( $post_id, $context );
if ( $taxonomy_proxy ) {
if ( $attempted ) {
ai4seo_refresh_taxonomy_metadata_output_cache( $post_id, $output_before, $details );
$warnings = array_merge( $warnings, $details['warnings'] ?? array() );
}
$confirmed = ai4seo_get_confirmed_active_metadata_updates( $attempted, $after['storage'] );
$response['metadata_editor'] = array(
'post_id' => $post_id,
'metadata' => $confirmed,
'saved_fields' => array_keys( $confirmed ),
'storage' => ai4seo_metadata_recovery_editor_state( $after['storage'] ),
'conflicts' => $after['storage']['conflicts'],
'unresolved_fields' => array_keys( $after['storage']['conflicts'] ),
'status' => count( $confirmed ) === count( $attempted ) ? 'complete' : 'partial',
'submitted_changes_saved' => count( $confirmed ) === count( $attempted ),
'generatable' => ai4seo_can_edit_taxonomy_proxy( $post_id ) && ai4seo_is_plugin_update_context_ready( 'metadata' ),
);
} elseif ( 'metadata' === $context && $attempted ) {
$response = ai4seo_build_metadata_editor_save_response( $post_id, $attempted, $details, $fence );
$warnings = array_merge( $warnings, $response['metadata_editor']['warnings'] );
}
foreach ( $fields as $field => &$result ) {
$current = 'custom_instructions' === $field ? $after['instructions'] : $after['fields'][ $field ];
if ( 'ready' === $result['status'] ) {
$confirmed = 'metadata' === $context
? isset( $response['metadata_editor']['metadata'][ $field ] ) && $updates[ $field ] === $response['metadata_editor']['metadata'][ $field ]
: ! empty( $fence['persistence_succeeded'] ) && $current['known'] && $updates[ $field ] === $current['value'];
$result['status'] = $confirmed ? 'saved' : ( ! isset( $attempted[ $field ] ) || 'not_committed' === ( $details['commit_state'] ?? '' ) ? 'not_saved' : 'unconfirmed' );
}
if ( 'saved' === $result['status'] && ( ! $current['known'] || $current['value'] !== $updates[ $field ] ) ) {
$result['status'] = 'unconfirmed';
}
$result['known'] = $current['known'];
$result['current'] = $current['known'] ? $current['value'] : null;
$result['fingerprint'] = $current['fingerprint'];
}
unset( $result );
$statistics_pending = ! empty( $fence['persistence_succeeded'] ) && empty( $fence['coverage_succeeded'] );
$ownership_pending = ! empty( $fence['reservation_succeeded'] )
&& empty( $fence['release_succeeded'] ) && empty( $fence['rollback_succeeded'] );
if ( $statistics_pending ) {
$warnings[] = __( 'Statistics notice: completeness statistics may still be incomplete or outdated. This does not affect confirmed saved texts.', 'ai-for-seo' );
}
if ( $ownership_pending ) {
$warnings[] = __( 'Generation ownership could not be released safely. Keep any remaining drafts and reopen this entry before continuing.', 'ai-for-seo' );
}
$response['editor_save'] = array(
'post_id' => $post_id,
'context' => $context,
'fields' => $fields,
'warnings' => array_values( array_unique( $warnings ) ),
'reload_required' => $ownership_pending || ! empty( $response['metadata_editor']['reload_required'] ),
);
$pending = array_filter(
$fields,
static function ( array $field ): bool {
return 'saved' !== $field['status'];
}
);
if ( $texts && ! $pending && ! $warnings && ! empty( $fence['coverage_succeeded'] ) && ! empty( $fence['release_succeeded'] )
&& '' !== $error_token && ai4seo_resolve_recent_activity_error( $post_id, $activity, $error_token ) ) {
$response['resolved_activity_tokens'][ $activity ] = $error_token;
}
return $response;
}