post_type || $post->post_parent ) { return; }
$children = get_posts( array( 'post_type' => 'kconv_form', 'post_status' => 'any', 'post_parent' => $id, 'posts_per_page' => -1, 'fields' => 'ids' ) );
foreach ( $children as $child ) { wp_delete_post( $child, true ); }
}
public static function transaction_active() { return self::$active; }
public static function storage_error() { if ( self::$active ) { self::$storage_error = true; } }
private static function error( $code, $message, $status = 400 ) { return new WP_Error( $code, $message, array( 'status' => $status ) ); }
private static function uncertain() {
return self::error( 'kconv_authoring_uncertain', __( 'The save result could not be confirmed. Your edits are still here. Open the current saved form in a separate tab and compare it before trying again.', 'killerconversions' ), 503 );
}
public static function public_id( $id ) {
$post = get_post( $id );
return $post && 'kconv_form' === $post->post_type && $post->post_parent ? (int) $post->post_parent : (int) $id;
}
public static function working_id( $id ) {
$child = (int) get_post_meta( $id, '_kconv_authoring_working', true );
$post = $child ? get_post( $child ) : null;
return $post && 'kconv_form' === $post->post_type && 'draft' === $post->post_status && (int) $post->post_parent === (int) $id ? $child : 0;
}
/** Read directly from storage: cache state cannot establish an optimistic-save precondition. */
public static function revision( $id ) {
if ( ! $id ) { return 'new'; }
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$post = $wpdb->get_row( $wpdb->prepare( 'SELECT ID,post_parent,post_title,post_status FROM %i WHERE ID=%d AND post_type=%s', $wpdb->posts, $id, 'kconv_form' ), ARRAY_A );
if ( ! $post || $wpdb->last_error ) { return ''; }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT meta_key,meta_value FROM %i WHERE post_id=%d ORDER BY meta_key,meta_id', $wpdb->postmeta, $id ), ARRAY_A );
if ( ! is_array( $rows ) || $wpdb->last_error ) { return ''; }
$rows = array_values( array_filter( $rows, function ( $row ) { return 0 === strpos( $row['meta_key'], '_kconv_' ) && ! in_array( $row['meta_key'], array( '_kconv_authoring_history', '_kconv_authoring_base', '_kconv_resume_snapshot_version' ), true ); } ) );
return hash( 'sha256', wp_json_encode( array( $post, $rows ) ) );
}
public static function context( $id ) {
$root = self::public_id( $id ); $root_form = $root ? KCONV_Core::get_form( $root ) : null;
$working = $root_form ? self::working_id( $root ) : 0;
$form = $working ? KCONV_Core::get_form( $working ) : $root_form;
return array( 'root' => $root, 'form' => $form, 'working' => $working, 'live' => $root_form && 'publish' === $root_form['status'], 'root_revision' => $working ? (string) get_post_meta( $working, '_kconv_authoring_base', true ) : self::revision( $root ) );
}
/** A read-only publish result is bound to the committed design, current administrator and login session. */
public static function published_result( $id ) {
$id = absint( $id );
if ( ! $id || ! current_user_can( 'manage_options' ) || ! current_user_can( 'edit_post', $id ) ) { return ''; }
$post = get_post( $id );
if ( ! $post || 'kconv_form' !== $post->post_type || $post->post_parent || 'publish' !== $post->post_status ) { return ''; }
$kind = isset( $_GET['kconv_published'] ) && is_string( $_GET['kconv_published'] ) ? sanitize_key( wp_unslash( $_GET['kconv_published'] ) ) : '';
$nonce = isset( $_GET['kconv_publish_nonce'] ) && is_string( $_GET['kconv_publish_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['kconv_publish_nonce'] ) ) : '';
$revision = self::revision( $id );
if ( ! in_array( $kind, array( 'new', 'update' ), true ) || '' === $revision ) { return ''; }
if ( ! wp_verify_nonce( $nonce, 'kconv_published_result_' . get_current_blog_id() . '_' . $id . '_' . $kind . '_' . $revision ) ) { return ''; }
return $kind;
}
public static function assets( $hook ) {
if ( ! KCONV_Admin_Scope::plugin( array( 'killerconversions' ) ) ) { return; }
wp_enqueue_script( 'kconv-authoring', KCONV_URL . 'assets/authoring.js', array( 'kconv-admin', 'kconv-workspace' , 'wp-i18n' ), KCONV_VERSION, true ); wp_set_script_translations( 'kconv-authoring', 'killerconversions', KCONV_DIR . 'languages' );
wp_enqueue_style( 'kconv-authoring', KCONV_URL . 'assets/authoring.css', array( 'kconv-workspace', 'kconv-readability' ), KCONV_VERSION );
wp_localize_script( 'kconv-authoring', 'KCONV_Authoring', array( 'url' => admin_url( 'admin-ajax.php' ) ) );
}
public static function hidden( $context, $form ) {
echo '';
}
public static function history_panel( $form ) {
$root = self::public_id( $form['id'] );
$history = $root ? get_post_meta( $root, '_kconv_authoring_history', true ) : array();
echo '' . esc_html__( 'Saved design history', 'killerconversions' ) . '
' . esc_html__( 'The last 10 saved designs stay on this site. Restore brings questions, logic, title and appearance into the editor for review. Recipient addresses, credentials, privacy settings and entries are never restored.', 'killerconversions' ) . '
' . esc_html__( 'Your first saved design will appear here.', 'killerconversions' ) . '
'; }
echo '';
if ( $root && 'publish' === get_post_status( $root ) ) { echo '' . esc_html__( 'Pause this published form', 'killerconversions' ) . '
' . esc_html__( 'Save the current edits and unpublish this form. Visitors will see that it is unavailable. Existing entries stay in Inbox. You can publish it again later.', 'killerconversions' ) . '
'; }
echo '';
}
public static function track_update( $check, $id, $key, $value, $extra = null ) {
if ( self::$active && in_array( (int) $id, self::$touched, true ) && 0 === strpos( $key, '_kconv_' ) ) { self::$expected[ $id ][ $key ] = array( 'value' => (string) maybe_serialize( $value ) ); }
return $check;
}
public static function track_delete( $check, $id, $key, $value, $all = false ) {
if ( self::$active && in_array( (int) $id, self::$touched, true ) && 0 === strpos( $key, '_kconv_' ) ) { self::$expected[ $id ][ $key ] = array( 'deleted' => true ); }
return $check;
}
private static function verify_writes() {
global $wpdb;
if ( self::$storage_error ) { throw new RuntimeException( 'storage' ); }
foreach ( self::$expected as $id => $keys ) {
foreach ( $keys as $key => $expected ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$values = $wpdb->get_col( $wpdb->prepare( 'SELECT meta_value FROM %i WHERE post_id=%d AND meta_key=%s', $wpdb->postmeta, $id, $key ) );
$invalid = isset( $expected['deleted'] ) ? (bool) $values : ( ! $values || count( array_filter( (array) $values, function ( $value ) use ( $expected ) { return (string) $value !== $expected['value']; } ) ) );
if ( $wpdb->last_error || ! is_array( $values ) || $invalid ) { throw new RuntimeException( 'storage' ); }
}
}
}
private static function transactional_storage() {
global $wpdb;
$tables = array( $wpdb->posts, $wpdb->postmeta );
if ( class_exists( 'KCONV_Draft_Privacy' ) && KCONV_Draft_Privacy::exists() ) { $tables[] = KCONV_Draft_Privacy::table(); $tables[] = KCONV_Draft_Privacy::subjects(); }
if ( class_exists( 'KCONV_Delivery' ) ) { $tables[] = KCONV_Delivery::table(); $tables[] = KCONV_Delivery::table( 'events' ); }
foreach ( $tables as $table ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$row = $wpdb->get_row( $wpdb->prepare( 'SHOW TABLE STATUS WHERE Name=%s', $table ), ARRAY_A );
if ( ! $row || $wpdb->last_error || 'innodb' !== strtolower( $row['Engine'] ) ) { return false; }
}
return true;
}
private static function clean() { foreach ( self::$touched as $id ) { clean_post_cache( $id ); wp_cache_delete( $id, 'post_meta' ); } }
private static function copy_configuration( $from, $to, $publishing = false ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT meta_key,meta_value FROM %i WHERE post_id=%d ORDER BY meta_id', $wpdb->postmeta, $from ), ARRAY_A );
if ( ! is_array( $rows ) || $wpdb->last_error ) { throw new RuntimeException( 'storage' ); }
$sql = $wpdb->prepare( 'DELETE FROM %i WHERE post_id=%d AND meta_key LIKE %s AND meta_key NOT LIKE %s', $wpdb->postmeta, $to, $wpdb->esc_like( '_kconv_' ) . '%', $wpdb->esc_like( '_kconv_authoring_' ) . '%' );
if ( $publishing ) { $sql .= $wpdb->prepare( ' AND meta_key<>%s', '_kconv_resume_snapshot_version' ); }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache. Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache. copy_configuration() constructs its SQL from two separately prepared fragments.
if ( false === $wpdb->query( $sql ) ) { throw new RuntimeException( 'storage' ); }
foreach ( $rows as $row ) {
if ( $publishing && '_kconv_resume_snapshot_version' === $row['meta_key'] ) { continue; }
if ( 0 !== strpos( $row['meta_key'], '_kconv_' ) || 0 === strpos( $row['meta_key'], '_kconv_authoring_' ) ) { continue; }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( false === $wpdb->insert( $wpdb->postmeta, array( 'post_id' => $to, 'meta_key' => $row['meta_key'], 'meta_value' => $row['meta_value'] ) ) ) { throw new RuntimeException( 'storage' ); }
}
clean_post_cache( $to ); wp_cache_delete( $to, 'post_meta' );
}
private static function remove_working( $root, $child ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( false === $wpdb->delete( $wpdb->postmeta, array( 'post_id' => $child ) ) || false === $wpdb->delete( $wpdb->posts, array( 'ID' => $child, 'post_parent' => $root, 'post_type' => 'kconv_form', 'post_status' => 'draft' ) ) ) { throw new RuntimeException( 'storage' ); }
unset( self::$expected[ $child ] ); delete_post_meta( $root, '_kconv_authoring_working' );
}
private static function design( $id ) {
$form = KCONV_Core::get_form( $id ); if ( ! $form ) { throw new RuntimeException( 'storage' ); }
$settings = KCONV_Core::appearance_settings( $form['settings'] );
foreach ( array( 'submit_label', 'success_message' ) as $key ) { $settings[ $key ] = $form['settings'][ $key ]; }
$pro = get_post_meta( $id, '_kconv_pro', true );
return array_merge( array_intersect_key( $form, array_flip( array( 'estimate', 'steps', 'logic' ) ) ), array( 'title' => $form['title'], 'fields' => $form['fields'], 'settings' => $settings, 'pro' => array( 'rules' => is_array( $pro ) && isset( $pro['rules'] ) ? $pro['rules'] : array(), 'steps' => is_array( $pro ) && isset( $pro['steps'] ) ? $pro['steps'] : array() ) ) );
}
private static function remember( $root, $id, $label ) {
$history = get_post_meta( $root, '_kconv_authoring_history', true ); if ( ! is_array( $history ) ) { $history = array(); }
$design = self::design( $id ); $digest = hash( 'sha256', wp_json_encode( $design ) ); $last = end( $history );
if ( $last && isset( $last['digest'] ) && hash_equals( $last['digest'], $digest ) ) { return; }
$history[] = array( 'id' => wp_generate_uuid4(), 'at' => gmdate( 'Y-m-d H:i:s' ), 'author' => get_current_user_id(), 'label' => $label, 'digest' => $digest, 'design' => $design );
update_post_meta( $root, '_kconv_authoring_history', wp_slash( array_slice( $history, -self::HISTORY_LIMIT ) ) );
}
private static function authorized( $raw ) {
if ( ! current_user_can( 'manage_options' ) ) { return false; }
if ( ! isset( $raw['_wpnonce'] ) || ! is_string( $raw['_wpnonce'] ) ) { return false; }
return (bool) wp_verify_nonce( sanitize_text_field( $raw['_wpnonce'] ), 'kconv_save' );
}
/** One save boundary for AJAX and ordinary POST. No external operation occurs here. */
public static function save( $raw ) {
try {
if ( ! self::authorized( $raw ) ) { return self::error( 'kconv_authoring_permission', __( 'Your editor session expired or you cannot manage forms. Keep your edits open and sign in again before retrying.', 'killerconversions' ), 403 ); }
// Extension operations hold their guard until all reads/writes and rollback complete.
try {
$allowed = apply_filters( 'kconv_authoring_operation_allowed', true, $raw );
if ( is_wp_error( $allowed ) ) { return $allowed; }
if ( true !== $allowed ) { return new WP_Error( 'kconv_operation_unavailable', __( 'This operation is unavailable. No saved data has changed.', 'killerconversions' ), array( 'status' => 403 ) ); }
return self::save_configuration( $raw );
} finally { do_action( 'kconv_authoring_operation_finished', $raw ); }
} catch ( Throwable $exception ) {
// Admission or finalization can fail before or after a commit. Never replay the save
// or turn an unknown result into a claim that it succeeded or was rolled back.
return self::uncertain();
}
}
private static function save_configuration( $raw ) {
if ( ! self::authorized( $raw ) ) { return self::error( 'kconv_authoring_permission', __( 'Your editor session expired or you cannot manage forms. Keep your edits open and sign in again before retrying.', 'killerconversions' ), 403 ); }
if ( ! isset( $raw['kconv_authoring_complete'], $raw['form_id'], $raw['kconv_authoring_revision'], $raw['kconv_authoring_root'], $raw['kconv_authoring_root_revision'] ) || '1' !== $raw['kconv_authoring_complete'] || ! is_scalar( $raw['form_id'] ) || ! ctype_digit( (string) $raw['form_id'] ) || ! is_scalar( $raw['kconv_authoring_root'] ) || ! ctype_digit( (string) $raw['kconv_authoring_root'] ) || ! is_string( $raw['kconv_authoring_revision'] ) || ! is_string( $raw['kconv_authoring_root_revision'] ) ) { return self::error( 'kconv_authoring_incomplete', __( 'The editor request is incomplete. No changes were saved. Keep this page open; reload a separate tab after checking your host’s input limits.', 'killerconversions' ) ); }
$id = (int) $raw['form_id']; $root = (int) $raw['kconv_authoring_root'];
$intent = isset( $raw['kconv_save_intent'] ) && is_string( $raw['kconv_save_intent'] ) ? $raw['kconv_save_intent'] : 'draft';
if ( ! in_array( $intent, array( 'draft', 'publish', 'unpublish' ), true ) ) { return self::error( 'kconv_authoring_intent', __( 'Use one of this form’s save or publish actions.', 'killerconversions' ) ); }
if ( $id ? ( 'kconv_form' !== get_post_type( $id ) || self::public_id( $id ) !== $root ) : $root ) { return self::error( 'kconv_authoring_missing', __( 'This form no longer exists. Your edits remain in this editor.', 'killerconversions' ), 404 ); }
if ( ! isset( $raw['schema'], $raw['title'] ) || ! is_string( $raw['schema'] ) || strlen( $raw['schema'] ) > 200000 || ! is_string( $raw['title'] ) ) { return self::error( 'kconv_authoring_schema', __( 'The form configuration is incomplete or too large. No changes were saved.', 'killerconversions' ) ); }
$schema = KCONV_Core::sanitize_schema( json_decode( $raw['schema'], true, 20 ) ); if ( is_wp_error( $schema ) ) { return $schema; }
$title = mb_substr( sanitize_text_field( $raw['title'] ), 0, 200 ); if ( '' === trim( $title ) ) { return self::error( 'kconv_authoring_title', __( 'Give this form a name.', 'killerconversions' ) ); }
if ( ! self::transactional_storage() ) { return self::error( 'kconv_authoring_storage', __( 'Safe saving requires transactional form and delivery tables. Ask your host to restore InnoDB storage; your saved form is unchanged.', 'killerconversions' ), 503 ); }
global $wpdb;
$lock = 'kconv-author-' . substr( hash( 'sha256', $wpdb->prefix . ':' . ( $root ? $root : 'new-' . get_current_user_id() ) ), 0, 40 );
self::$cache_suspended = wp_suspend_cache_addition();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,2)', $lock ) ) ) { return self::error( 'kconv_authoring_busy', __( 'Another save is finishing. Your edits are still here; try saving again.', 'killerconversions' ), 409 ); }
self::$touched = $id ? array_values( array_unique( array( $id, $root ) ) ) : array(); self::$expected = array(); self::$storage_error = false;
$commit_attempted = false;
try {
self::clean();
if ( ! hash_equals( self::revision( $id ), $raw['kconv_authoring_revision'] ) || ! hash_equals( self::revision( $root ), $raw['kconv_authoring_root_revision'] ) ) { return self::error( 'kconv_authoring_conflict', __( 'Another editor changed this form. Your edits have not been saved or overwritten. Open the latest version in a separate tab, compare your changes, and copy the changes you want to keep.', 'killerconversions' ), 409 ); }
$root_form = $root ? KCONV_Core::get_form( $root ) : null;
if ( $id && ! $root_form ) { return self::error( 'kconv_authoring_missing', __( 'The saved form could not be read.', 'killerconversions' ), 404 ); }
$child = $root ? self::working_id( $root ) : 0;
if ( $id && $id !== $root && $child !== $id ) { return self::error( 'kconv_authoring_conflict', __( 'This working draft is no longer current. Open the latest form before continuing.', 'killerconversions' ), 409 ); }
$candidate = array_merge( array( 'id' => $id, 'title' => $title, 'status' => 'publish' === $intent ? 'publish' : 'draft' ), $schema );
$errors = apply_filters( 'kconv_validate_form_save', new WP_Error(), $candidate, $raw );
if ( ! is_wp_error( $errors ) ) { return self::error( 'kconv_authoring_validator', __( 'A form validator failed. No changes were saved.', 'killerconversions' ), 500 ); }
if ( $errors->has_errors() ) { return $errors; }
// Legacy draft-envelope migration owns its own transactions. Complete its monotonic
// preservation step under the save lock BEFORE starting the configuration transaction.
$changing_live = $root_form && ( 'draft' !== $intent || ! $child && 'publish' !== $root_form['status'] );
if ( $changing_live ) {
$prepared = apply_filters( 'kconv_prepare_form_save', new WP_Error(), $root_form, $candidate, $raw );
if ( ! is_wp_error( $prepared ) || $prepared->has_errors() ) { return is_wp_error( $prepared ) ? $prepared : self::error( 'kconv_authoring_prepare', __( 'Saved visitor drafts could not be preserved. No form changes were saved.', 'killerconversions' ), 503 ); }
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new RuntimeException( 'storage' ); }
self::$cache_suspended = wp_suspend_cache_addition( true ); self::$active = true;
if ( $root ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( ! $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE ID IN (%d,%d) ORDER BY ID FOR UPDATE', $wpdb->posts, $root, $id ) ) || $wpdb->last_error ) { throw new RuntimeException( 'storage' ); }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$wpdb->get_col( $wpdb->prepare( 'SELECT meta_id FROM %i WHERE post_id IN (%d,%d) FOR UPDATE', $wpdb->postmeta, $root, $id ) ); if ( $wpdb->last_error ) { throw new RuntimeException( 'storage' ); }
if ( ! hash_equals( self::revision( $id ), $raw['kconv_authoring_revision'] ) || ! hash_equals( self::revision( $root ), $raw['kconv_authoring_root_revision'] ) ) { throw new RuntimeException( 'conflict' ); }
self::remember( $root, $id, __( 'Before changes', 'killerconversions' ) );
}
$target = $id;
if ( 'draft' === $intent && $root_form && 'publish' === $root_form['status'] && ! $child ) {
$target = wp_insert_post( wp_slash( array( 'post_type' => 'kconv_form', 'post_parent' => $root, 'post_status' => 'draft', 'post_title' => $title ) ), true );
if ( is_wp_error( $target ) || ! $target ) { throw new RuntimeException( 'storage' ); }
self::$touched[] = (int) $target; self::copy_configuration( $root, $target );
update_post_meta( $root, '_kconv_authoring_working', $target );
update_post_meta( $target, '_kconv_authoring_base', self::revision( $root ) ); $child = $target;
} elseif ( 'draft' !== $intent && $child ) { $target = $root; self::copy_configuration( $child, $root, true ); }
$result = wp_insert_post( wp_slash( array( 'ID' => $target, 'post_parent' => $root && $target !== $root ? $root : 0, 'post_type' => 'kconv_form', 'post_title' => $title, 'post_status' => 'publish' === $intent ? 'publish' : 'draft' ) ), true );
if ( is_wp_error( $result ) || ! $result ) { throw new RuntimeException( 'storage' ); }
$target = (int) $result; if ( ! in_array( $target, self::$touched, true ) ) { self::$touched[] = $target; }
if ( ! $root ) { $root = $target; }
update_post_meta( $target, '_kconv_schema', wp_slash( $schema ) );
$saved = KCONV_Core::get_form( $target ); if ( ! $saved || $saved['title'] !== $title || $saved['status'] !== ( 'publish' === $intent ? 'publish' : 'draft' ) ) { throw new RuntimeException( 'storage' ); }
do_action( 'kconv_form_saved', $target, $saved, $raw );
self::remember( $root, $target, 'publish' === $intent ? __( 'Published', 'killerconversions' ) : ( 'unpublish' === $intent ? __( 'Unpublished', 'killerconversions' ) : __( 'Draft saved', 'killerconversions' ) ) );
if ( 'draft' !== $intent && $child ) { self::remove_working( $root, $child ); }
self::verify_writes();
$commit_attempted = true;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( false === $wpdb->query( 'COMMIT' ) ) { throw new RuntimeException( 'storage' ); }
self::$active = false; self::clean();
// Optional guidance observes a durable manual save, never an attempted or rolled-back write.
// An extension's guidance failure must not report an already committed form as rolled back.
try { do_action( 'kconv_authoring_committed', $target, $saved, $raw ); } catch ( Throwable $guidance_error ) { /* The form is already committed. */ }
$url = admin_url( 'admin.php?page=killerconversions&edit=' . $root . '&saved=1&saved_as=' . $intent );
if ( 'publish' === $intent ) {
$kind = $root_form && 'publish' === $root_form['status'] ? 'update' : 'new';
$revision = self::revision( $root );
if ( '' !== $revision ) {
$url = add_query_arg( array( 'kconv_published' => $kind, 'kconv_publish_nonce' => wp_create_nonce( 'kconv_published_result_' . get_current_blog_id() . '_' . $root . '_' . $kind . '_' . $revision ) ), $url );
if ( 'new' === $kind ) { $url .= '#kconv-publish-next'; }
}
}
return array( 'id' => $root, 'working_id' => 'draft' === $intent ? $target : 0, 'intent' => $intent, 'url' => $url );
} catch ( Throwable $exception ) {
$rolled_back = false;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( self::$active ) { $rolled_back = false !== $wpdb->query( 'ROLLBACK' ); } self::$active = false; self::clean();
// A failed COMMIT response can follow a successful database commit. A later
// ROLLBACK cannot prove that earlier commit was undone.
if ( $commit_attempted || ! $rolled_back ) { return self::uncertain(); }
return self::error( 'conflict' === $exception->getMessage() ? 'kconv_authoring_conflict' : 'kconv_authoring_storage', 'conflict' === $exception->getMessage() ? __( 'The saved form changed during your save. Your edits remain here; compare with the latest version before retrying.', 'killerconversions' ) : __( 'The complete form could not be saved. All form and delivery-setting changes were rolled back. Your edits remain here; retry after storage is available.', 'killerconversions' ), 'conflict' === $exception->getMessage() ? 409 : 503 );
} finally {
self::$active = false;
try { wp_suspend_cache_addition( self::$cache_suspended ); }
finally {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
}
}
}
private static function respond( $result ) {
if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); wp_send_json_error( array( 'code' => $result->get_error_code(), 'messages' => $result->get_error_messages() ), is_array( $data ) && isset( $data['status'] ) ? $data['status'] : 400 ); }
wp_send_json_success( $result );
}
public static function ajax_save() {
if ( ! current_user_can( 'manage_options' ) ) { self::respond( self::error( 'kconv_authoring_permission', __( 'Your editor session expired or you cannot manage forms. Keep your edits open and sign in again before retrying.', 'killerconversions' ), 403 ) ); }
if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) ) { self::respond( self::error( 'kconv_authoring_permission', __( 'Your editor session expired or you cannot manage forms. Keep your edits open and sign in again before retrying.', 'killerconversions' ), 403 ) ); }
$nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
if ( ! wp_verify_nonce( $nonce, 'kconv_save' ) ) { self::respond( self::error( 'kconv_authoring_permission', __( 'Your editor session expired or you cannot manage forms. Keep your edits open and sign in again before retrying.', 'killerconversions' ), 403 ) ); }
self::respond( self::save( wp_unslash( $_POST ) ) );
}
public static function ajax_history() {
if ( ! current_user_can( 'manage_options' ) ) { self::respond( self::error( 'permission', __( 'Sign in again before restoring a design.', 'killerconversions' ), 403 ) ); }
if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) ) { self::respond( self::error( 'permission', __( 'Sign in again before restoring a design.', 'killerconversions' ), 403 ) ); }
$nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
if ( ! wp_verify_nonce( $nonce, 'kconv_save' ) ) { self::respond( self::error( 'permission', __( 'Sign in again before restoring a design.', 'killerconversions' ), 403 ) ); }
$raw = wp_unslash( $_POST );
$root = isset( $raw['root'] ) && is_scalar( $raw['root'] ) ? absint( $raw['root'] ) : 0;
$key = isset( $raw['revision'] ) && is_string( $raw['revision'] ) ? $raw['revision'] : '';
if ( ! $root || 'kconv_form' !== get_post_type( $root ) || self::public_id( $root ) !== $root ) { self::respond( self::error( 'missing', __( 'This form no longer exists.', 'killerconversions' ), 404 ) ); }
$history = get_post_meta( $root, '_kconv_authoring_history', true );
foreach ( is_array( $history ) ? $history : array() as $item ) { if ( $item['id'] === $key ) { if ( ! class_exists( 'KCONV_Pro' ) && ( ! empty( $item['design']['pro']['rules'] ) || ! empty( $item['design']['pro']['steps'] ) ) ) { self::respond( self::error( 'pro_required', __( 'Activate the matching Pro plugin before restoring this design’s logic.', 'killerconversions' ), 409 ) ); } self::respond( array( 'design' => $item['design'] ) ); } }
self::respond( self::error( 'missing', __( 'This design is no longer retained. Reload to see the latest history.', 'killerconversions' ), 404 ) );
}
public static function ajax_discard() {
if ( ! current_user_can( 'manage_options' ) ) { self::respond( self::error( 'permission', __( 'Sign in again before discarding a draft.', 'killerconversions' ), 403 ) ); }
if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) ) { self::respond( self::error( 'permission', __( 'Sign in again before discarding a draft.', 'killerconversions' ), 403 ) ); }
$nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
if ( ! wp_verify_nonce( $nonce, 'kconv_save' ) ) { self::respond( self::error( 'permission', __( 'Sign in again before discarding a draft.', 'killerconversions' ), 403 ) ); }
$raw = wp_unslash( $_POST );
$id = isset( $raw['form_id'] ) && is_scalar( $raw['form_id'] ) ? absint( $raw['form_id'] ) : 0; $root = self::public_id( $id );
if ( ! $id || $root === $id || self::working_id( $root ) !== $id || ! isset( $raw['kconv_authoring_revision'] ) || ! is_string( $raw['kconv_authoring_revision'] ) ) { self::respond( self::error( 'missing', __( 'This working draft is no longer current.', 'killerconversions' ), 409 ) ); }
global $wpdb; $lock = 'kconv-author-' . substr( hash( 'sha256', $wpdb->prefix . ':' . $root ), 0, 40 );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( ! self::transactional_storage() || '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,2)', $lock ) ) ) { self::respond( self::error( 'busy', __( 'The draft could not be discarded. Try again shortly.', 'killerconversions' ), 409 ) ); }
$result = null; self::$touched = array( $id, $root ); self::$expected = array(); self::$storage_error = false; self::$cache_suspended = wp_suspend_cache_addition();
try {
self::clean();
if ( self::working_id( $root ) !== $id || ! hash_equals( self::revision( $id ), $raw['kconv_authoring_revision'] ) ) { throw new RuntimeException( 'conflict' ); }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new RuntimeException( 'storage' ); } self::$cache_suspended = wp_suspend_cache_addition( true ); self::$active = true;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
$wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE ID IN (%d,%d) ORDER BY ID FOR UPDATE', $wpdb->posts, $root, $id ) ); if ( $wpdb->last_error || ! hash_equals( self::revision( $id ), $raw['kconv_authoring_revision'] ) ) { throw new RuntimeException( 'conflict' ); }
self::remember( $root, $id, __( 'Discarded draft design', 'killerconversions' ) ); self::remove_working( $root, $id ); self::verify_writes();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
if ( false === $wpdb->query( 'COMMIT' ) ) { throw new RuntimeException( 'storage' ); } self::$active = false;
$result = array( 'url' => admin_url( 'admin.php?page=killerconversions&edit=' . $root ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
} catch ( Throwable $exception ) { if ( self::$active ) { $wpdb->query( 'ROLLBACK' ); } $result = self::error( 'discard', __( 'The draft was not discarded. Reload a separate tab to review its latest version.', 'killerconversions' ), 409 ); }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic form authoring verifies direct post/meta writes under InnoDB and a named lock, then explicitly clears post/meta caches. This must observe current revision and committed writes rather than a stale object cache.
finally { self::$active = false; wp_suspend_cache_addition( self::$cache_suspended ); self::clean(); $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); }
self::respond( $result );
}
}