diff --git a/DESIGN.md b/DESIGN.md index be98b4a..28589fa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -332,6 +332,40 @@ Avoid: - Describing a policy as a security control or as a guaranteed performance measurement. - Hiding an existing editable comment merely because the post no longer accepts new comments. +#### Moderator comment movement + +Reference: `includes/Admin/Comment_Movement.php` + +Use WordPress's native Comments row action and admin form, not the frontend +inline editor or an AJAX modal. Moving changes a comment's audience and thread, +so it requires a separate preview and an explicitly labelled Confirm move button. + +Follow: + +- Keep selection, non-mutating preview and outcome distinct. Show source and + destination titles/IDs/statuses, moving/remaining counts, root detachment and + the exact child-handling policy before confirmation. +- Explain that approved comments can become visible on the destination. Never + display comment bodies, authors or inaccessible post titles in a preview or + recovery error. +- Use associated labels, a fieldset/legend for the move modes, native submit + buttons, a cancel link and standard WordPress notice classes. Keep all actions + keyboard-operable without JavaScript and preserve the entered destination on + a validation failure. +- Show an unresolved recovery link before offering another move. Distinguish + successful movement, known rollback and an uncertain/interrupted outcome; + never report a failed write or count update as success. +- Keep recovery a separate nonce-protected form. Explain that it restores only + placements and refuses new replies or unrelated changes. + +Responsive/accessibility notes: + +- Inherit WordPress admin typography, focus treatments and responsive controls; + do not apply the unfinished brand-token template to this native screen. +- Use natural-width controls and wrapping paragraphs/labels, with no fixed-width + preview table or horizontal scrolling on narrow screens. No animations or + additional frontend assets are needed. + ## 8. Do's and Don'ts diff --git a/includes/Admin/Admin_Settings.php b/includes/Admin/Admin_Settings.php index 55509ed..4ef3fb1 100644 --- a/includes/Admin/Admin_Settings.php +++ b/includes/Admin/Admin_Settings.php @@ -30,6 +30,9 @@ public function __construct() { new Tabs\Settings(); new Tabs\Integrations(); new Tabs\Support(); + + // Comment movement is deliberately confined to the native admin interface. + new Comment_Movement(); } /** diff --git a/includes/Admin/Comment_Movement.php b/includes/Admin/Comment_Movement.php new file mode 100644 index 0000000..db35642 --- /dev/null +++ b/includes/Admin/Comment_Movement.php @@ -0,0 +1,667 @@ +authorize_comment( $comment ) ) ) { + $actions['sce_move'] = '' . esc_html__( 'Move', 'simple-comment-editing' ) . ''; + } + return $actions; + } + + /** + * Read only scalar form fields; arrays, negatives and malformed IDs fail closed. + * + * @param string $name Field name. + * @param int $type INPUT_POST or INPUT_GET. + * @return string + */ + private function input( $name, $type = INPUT_POST ) { + $value = filter_input( $type, $name, FILTER_UNSAFE_RAW, FILTER_REQUIRE_SCALAR ); + return is_string( $value ) ? sanitize_text_field( $value ) : ''; + } + + /** + * Parse a positive integer without coercing invalid input into a valid ID. + * + * @param string $value Submitted value. + * @return int + */ + private function positive_id( $value ) { + return ctype_digit( $value ) && (string) (int) $value === $value ? (int) $value : 0; + } + + /** + * Build a local admin URL. + * + * @param array $args Query arguments. + * @return string + */ + private function page_url( $args = array() ) { + return add_query_arg( array_merge( array( 'page' => 'sce-move-comment' ), $args ), admin_url( 'edit-comments.php' ) ); + } + + /** + * Require moderation plus read/edit access to a real comment-enabled post. + * + * @param int $post_id Post ID. + * @return \WP_Post|\WP_Error + */ + private function authorize_post( $post_id ) { + $post = $post_id > 0 ? get_post( $post_id ) : null; + if ( ! current_user_can( 'moderate_comments' ) || ! $post || ! current_user_can( 'edit_post', $post_id ) || ! current_user_can( 'read_post', $post_id ) ) { + return new \WP_Error( 'access', __( 'You cannot move comments to or from this post.', 'simple-comment-editing' ) ); + } + if ( in_array( $post->post_status, array( 'trash', 'auto-draft', 'inherit' ), true ) || ! post_type_supports( $post->post_type, 'comments' ) || 'product' === $post->post_type ) { + return new \WP_Error( 'post', __( 'Choose a comment-enabled post that is not trashed, an attachment, or a WooCommerce product. Product reviews are not supported by this tool.', 'simple-comment-editing' ) ); + } + return $post; + } + + /** + * Authorize every affected comment, not merely the selected root. + * + * @param \WP_Comment|null $comment Comment to check. + * @return true|\WP_Error + */ + private function authorize_comment( $comment ) { + if ( ! $comment || ! current_user_can( 'moderate_comments' ) || ! current_user_can( 'edit_comment', $comment->comment_ID ) ) { + return new \WP_Error( 'access', __( 'You cannot move this comment.', 'simple-comment-editing' ) ); + } + $post = $this->authorize_post( (int) $comment->comment_post_ID ); + if ( is_wp_error( $post ) ) { + return $post; + } + if ( ! in_array( $comment->comment_type, array( '', 'comment' ), true ) || metadata_exists( 'comment', $comment->comment_ID, 'rating' ) || in_array( $comment->comment_approved, array( 'spam', 'trash', 'post-trashed' ), true ) ) { + return new \WP_Error( 'type', __( 'Only ordinary comments outside Spam and Trash can be moved. Product reviews, pingbacks and trackbacks are not supported.', 'simple-comment-editing' ) ); + } + return true; + } + + /** + * Snapshot only placement fields; never persist comment text or author data. + * + * @param \WP_Comment $comment Comment to snapshot. + * @return array + */ + private function placement( $comment ) { + return array( + 'post' => (int) $comment->comment_post_ID, + 'parent' => (int) $comment->comment_parent, + ); + } + + /** + * Build a bounded, deterministic preview including descendants left behind. + * + * @param int $comment_id Selected root. + * @param int $destination Destination post. + * @param bool $subtree Include descendants. + * @return array|\WP_Error + */ + private function plan( $comment_id, $destination, $subtree ) { + $root = $comment_id > 0 ? get_comment( $comment_id ) : null; + if ( ! $root ) { + return new \WP_Error( 'access', __( 'You cannot move this comment.', 'simple-comment-editing' ) ); + } + $access = $this->authorize_comment( $root ); + if ( is_wp_error( $access ) ) { + return $access; + } + $post = $this->authorize_post( $destination ); + if ( is_wp_error( $post ) ) { + return $post; + } + if ( 'open' !== $post->comment_status ) { + return new \WP_Error( 'closed', __( 'The destination post must accept comments.', 'simple-comment-editing' ) ); + } + $source = (int) $root->comment_post_ID; + if ( $source === $destination ) { + return new \WP_Error( 'same_post', __( 'The destination must be a different post.', 'simple-comment-editing' ) ); + } + + // Validate the original ancestor chain before using it for reparenting. + $seen = array( $comment_id => true ); + $parent = (int) $root->comment_parent; + while ( $parent ) { + $ancestor = get_comment( $parent ); + if ( isset( $seen[ $parent ] ) || ! $ancestor || (int) $ancestor->comment_post_ID !== $source || count( $seen ) > 500 ) { + return new \WP_Error( 'thread', __( 'The existing thread is invalid. Repair its parents before moving it.', 'simple-comment-editing' ) ); + } + $seen[ $parent ] = true; + $parent = (int) $ancestor->comment_parent; + } + + $thread = array( $comment_id => $this->placement( $root ) ); + $frontier = array( $comment_id ); + while ( $frontier ) { + // Do not constrain the post: malformed cross-post descendants must fail. + $children = get_comments( + array( + 'parent__in' => $frontier, + 'status' => 'any', + 'type' => 'all', + 'number' => 501, + 'orderby' => 'comment_ID', + 'order' => 'ASC', + 'hierarchical' => false, + ) + ); + $frontier = array(); + foreach ( $children as $child ) { + $id = (int) $child->comment_ID; + $access = $this->authorize_comment( $child ); + if ( is_wp_error( $access ) ) { + return $access; + } + if ( isset( $thread[ $id ] ) || (int) $child->comment_post_ID !== $source || count( $thread ) >= 500 ) { + return new \WP_Error( 'thread', __( 'The thread is invalid or exceeds the 500-comment safety limit. No comments were moved.', 'simple-comment-editing' ) ); + } + $thread[ $id ] = $this->placement( $child ); + $frontier[] = $id; + } + } + ksort( $thread, SORT_NUMERIC ); + $before = array(); + $after = $thread; + foreach ( $thread as $id => $placement ) { + if ( $id === $comment_id || $subtree ) { + $before[ $id ] = $placement; + $after[ $id ]['post'] = $destination; + $after[ $id ]['parent'] = $id === $comment_id ? 0 : $placement['parent']; + } elseif ( $placement['parent'] === $comment_id ) { + $before[ $id ] = $placement; + $after[ $id ]['parent'] = (int) $root->comment_parent; + } + } + $source_post = $this->authorize_post( $source ); + if ( is_wp_error( $source_post ) ) { + return $source_post; + } + return array( + 'root' => $comment_id, + 'source' => $source, + 'destination' => $destination, + 'subtree' => $subtree, + 'source_state' => array( $source_post->post_status, (bool) $source_post->post_password ), + 'destination_state' => array( $post->post_status, (bool) $post->post_password ), + 'before' => $before, + 'thread_before' => $thread, + 'thread_after' => $after, + ); + } + + /** + * Bind confirmation to the exact placement snapshot and selected move mode. + * + * @param array $plan Preview data. + * @return string + */ + private function digest( $plan ) { + return hash_hmac( 'sha256', wp_json_encode( $plan ), wp_salt( 'nonce' ) ); + } + + /** + * Render selection and a separate, non-mutating preview. + */ + public function render_page() { + if ( ! current_user_can( 'moderate_comments' ) ) { + wp_die( esc_html__( 'You cannot move comments.', 'simple-comment-editing' ), '', array( 'response' => 403 ) ); + } + echo '
' . esc_html__( 'Use Move in the Comments list, or enter a comment ID.', 'simple-comment-editing' ) . '
'; + return; + } + $root = get_comment( $id ); + if ( ! $root ) { + wp_die( esc_html__( 'You cannot move this comment.', 'simple-comment-editing' ), '', array( 'response' => 403 ) ); + } + $access = $this->authorize_comment( $root ); + if ( is_wp_error( $access ) ) { + wp_die( esc_html( $access->get_error_message() ), '', array( 'response' => 403 ) ); + } + $destination = $this->positive_id( $this->input( 'destination' ) ); + $subtree = 'subtree' === $this->input( 'mode' ); + $nonce = $this->input( '_wpnonce' ); + if ( $nonce ) { + if ( ! wp_verify_nonce( $nonce, 'sce_move_preview_' . $id ) ) { + wp_die( esc_html__( 'The preview expired. Start again from the Comments list.', 'simple-comment-editing' ), '', array( 'response' => 403 ) ); + } + $plan = $this->plan( $id, $destination, $subtree ); + if ( is_wp_error( $plan ) ) { + echo '' . esc_html( $plan->get_error_message() ) . '
' . esc_html( sprintf( /* translators: 1: comment ID, 2: source post title. */ __( 'Move comment #%1$d from "%2$s".', 'simple-comment-editing' ), $id, get_the_title( $root->comment_post_ID ) ) ) . '
'; + echo ''; + } + + /** + * Show the exact destination, exposure and threading effect before confirming. + * + * @param array $plan Validated preview. + */ + private function render_preview( $plan ) { + echo '' . esc_html( sprintf( /* translators: 1: moved comment count, 2: descendant count left behind. */ __( '%1$d comment(s) will move; %2$d descendant(s) will remain on the original post.', 'simple-comment-editing' ), $plan['subtree'] ? count( $plan['thread_before'] ) : 1, $plan['subtree'] ? 0 : count( $plan['thread_before'] ) - 1 ) ) . '
'; + echo '' . esc_html__( 'The moved comment becomes a top-level comment. When replies stay behind, direct children are reparented to its original parent (or become top-level); deeper replies keep their parents. When replies move, their internal relationships are preserved.', 'simple-comment-editing' ) . '
'; + echo '' . esc_html__( 'Moving changes where these comments are visible. Approval, content and metadata are preserved; approved comments may become public on the destination.', 'simple-comment-editing' ) . '
'; + echo ''; + } + + /** + * Output an escaped hidden form field. + * + * @param string $name Field name. + * @param string|int $value Field value. + */ + private function hidden( $name, $value ) { + echo ''; + } + + /** + * Confirm only the exact preview, and only through authenticated admin POST. + */ + public function handle_move() { + $snapshot = $this->input( 'snapshot' ); + if ( ! current_user_can( 'moderate_comments' ) || ! wp_verify_nonce( $this->input( '_wpnonce' ), 'sce_confirm_move_' . $snapshot ) ) { + wp_die( esc_html__( 'The move was not authorized. Preview it again.', 'simple-comment-editing' ), '', array( 'response' => 403 ) ); + } + $plan = $this->plan( $this->positive_id( $this->input( 'comment_id' ) ), $this->positive_id( $this->input( 'destination' ) ), 'subtree' === $this->input( 'mode' ) ); + if ( is_wp_error( $plan ) ) { + wp_die( esc_html( $plan->get_error_message() ), '', array( 'response' => 400 ) ); + } + if ( ! hash_equals( $this->digest( $plan ), $snapshot ) ) { + wp_die( esc_html__( 'The thread changed after preview. Preview it again; nothing was moved.', 'simple-comment-editing' ), '', array( 'response' => 409 ) ); + } + $this->finish( $this->execute( $plan, wp_generate_uuid4(), false ) ); + } + + /** + * Restore using the server-side journal, never client-supplied parent mappings. + */ + public function handle_restore() { + $id = $this->input( 'operation' ); + if ( ! current_user_can( 'moderate_comments' ) || ! wp_verify_nonce( $this->input( '_wpnonce' ), 'sce_restore_' . $id ) ) { + wp_die( esc_html__( 'Recovery was not authorized.', 'simple-comment-editing' ), '', array( 'response' => 403 ) ); + } + $record = $this->record( $id ); + if ( ! $record || is_wp_error( $this->authorize_post( $record['plan']['source'] ) ) || is_wp_error( $this->authorize_post( $record['plan']['destination'] ) ) ) { + wp_die( esc_html__( 'You cannot access this recovery record.', 'simple-comment-editing' ), '', array( 'response' => 403 ) ); + } + $this->finish( $this->execute( $record['plan'], $id, true ) ); + } + + /** + * Read a private, non-autoloaded journal by its random operation ID. + * + * @param string $id Operation UUID. + * @return array|false + */ + private function record( $id ) { + return wp_is_uuid( $id ) ? get_option( '_sce_comment_move_' . $id, false ) : false; + } + + /** + * Show accurate outcome and a nonce-protected recovery action. + * + * @param string $id Operation UUID. + */ + private function render_operation( $id ) { + $record = $this->record( $id ); + if ( ! $record || is_wp_error( $this->authorize_post( $record['plan']['source'] ) ) || is_wp_error( $this->authorize_post( $record['plan']['destination'] ) ) ) { + wp_die( esc_html__( 'You cannot access this recovery record.', 'simple-comment-editing' ), '', array( 'response' => 403 ) ); + } + $messages = array( + 'prepared' => __( 'The move was interrupted. Restore the original placement before trying another move.', 'simple-comment-editing' ), + 'moved' => __( 'The comments were moved and both posts’ counts were refreshed.', 'simple-comment-editing' ), + 'restored' => __( 'The original placement was restored and both posts’ counts were refreshed.', 'simple-comment-editing' ), + 'cancelled' => __( 'The move could not proceed or the thread changed. No placement changes were committed. Preview the move again.', 'simple-comment-editing' ), + 'restore_refused' => __( 'Recovery could not proceed. No recovery changes were committed. Check for new replies, changed parents or lost permissions before retrying; the journal is retained.', 'simple-comment-editing' ), + 'recovery_required' => __( 'The move or count refresh could not finish. The recovery record is retained. Restore the original placement before trying another move.', 'simple-comment-editing' ), + ); + echo '' . esc_html( $messages[ $record['status'] ] ) . '
' . esc_html( sprintf( /* translators: %s: recovery record ID. */ __( 'Recovery record: %s. It stores original post and parent IDs, not comment content.', 'simple-comment-editing' ), $id ) ) . '
'; + if ( ! in_array( $record['status'], array( 'restored', 'cancelled' ), true ) ) { + echo '' . esc_html__( 'Recovery refuses to overwrite changed placements or new replies. Content, approval and metadata are never restored or overwritten.', 'simple-comment-editing' ) . '
'; + } + echo '' . esc_html__( 'Back to Comments', 'simple-comment-editing' ) . '
'; + } + + /** + * Verify recovery has not acquired new replies or unrelated placement edits. + * + * @param array $plan Original plan. + * @return bool + */ + private function can_restore( $plan ) { + if ( is_wp_error( $this->authorize_post( $plan['source'] ) ) || is_wp_error( $this->authorize_post( $plan['destination'] ) ) ) { + return false; + } + foreach ( $plan['thread_after'] as $id => $after ) { + $comment = get_comment( $id ); + if ( ! $comment || is_wp_error( $this->authorize_comment( $comment ) ) ) { + return false; + } + $current = $this->placement( $comment ); + if ( $current !== $after && $current !== $plan['thread_before'][ $id ] ) { + return false; + } + // Validate the proposed graph, including ancestors changed since the move. + $seen = array( $id => true ); + $parent = $plan['thread_before'][ $id ]['parent']; + while ( $parent ) { + if ( isset( $seen[ $parent ] ) || count( $seen ) > 500 ) { + return false; + } + $seen[ $parent ] = true; + if ( isset( $plan['thread_before'][ $parent ] ) ) { + $placement = $plan['thread_before'][ $parent ]; + } else { + $ancestor = get_comment( $parent ); + if ( ! $ancestor ) { + return false; + } + $placement = $this->placement( $ancestor ); + } + if ( $placement['post'] !== $plan['source'] ) { + return false; + } + $parent = $placement['parent']; + } + } + $children = get_comments( + array( + 'parent__in' => array_keys( $plan['thread_after'] ), + 'status' => 'any', + 'type' => 'all', + 'number' => 501, + 'fields' => 'ids', + ) + ); + return ! array_diff( array_map( 'intval', $children ), array_keys( $plan['thread_after'] ) ); + } + + /** + * Apply placement-only writes atomically, retaining a durable recovery journal. + * + * @param array $plan Original validated plan. + * @param string $id Operation UUID. + * @param bool $restore Restore rather than move. + * @return string|\WP_Error Operation UUID or an error before any mutation. + * @throws \RuntimeException Internally caught to roll back a failed operation. + */ + private function execute( $plan, $id, $restore ) { + global $wpdb; + // MyISAM cannot provide rollback. Refuse rather than offer false atomicity. + foreach ( array( $wpdb->comments, $wpdb->posts, $wpdb->options ) as $table ) { + $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table ) ); + if ( 'innodb' !== strtolower( (string) $engine ) ) { + return new \WP_Error( 'engine', __( 'Moving requires InnoDB comment, post and option tables with transaction support. No comments were moved.', 'simple-comment-editing' ) ); + } + } + $lock = 'sce_move_' . md5( $wpdb->comments ); + if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ) ) { + return new \WP_Error( 'busy', __( 'Another move is in progress, or database locks are unavailable. No comments were moved.', 'simple-comment-editing' ) ); + } + $transaction = false; + $committing = false; + $record = $restore ? $this->record( $id ) : array( + 'plan' => $plan, + 'actor' => get_current_user_id(), + 'created' => time(), + 'status' => 'prepared', + ); + // An exception before reading the marker must never clear unknown recovery. + $pending = null; + try { + if ( ! $record ) { + return new \WP_Error( 'journal', __( 'The recovery record is unavailable. No comments were changed.', 'simple-comment-editing' ) ); + } + $pending = get_option( '_sce_comment_move_pending', '' ); + if ( $pending && ( ! $restore || $pending !== $id ) ) { + return new \WP_Error( 'pending', __( 'An earlier move needs recovery. Open Comments → Move comment.', 'simple-comment-editing' ) ); + } + if ( ! $restore && ! add_option( '_sce_comment_move_' . $id, $record, '', false ) ) { + return new \WP_Error( 'journal', __( 'The recovery record could not be saved. No comments were moved.', 'simple-comment-editing' ) ); + } + update_option( '_sce_comment_move_pending', $id, false ); + // Set isolation for this transaction only, not the shared connection default. + if ( get_option( '_sce_comment_move_pending' ) !== $id || false === $wpdb->query( 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE' ) || false === $wpdb->query( 'START TRANSACTION' ) ) { + throw new \RuntimeException( 'journal_or_transaction' ); + } + $transaction = true; + $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN (%d, %d) FOR UPDATE", $plan['source'], $plan['destination'] ) ); + if ( $wpdb->last_error ) { + throw new \RuntimeException( 'post_lock' ); + } + // Lock both posts' comment ranges, including potential new descendants. + $locked = $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID IN (%d, %d) FOR UPDATE", $plan['source'], $plan['destination'] ) ); + if ( $wpdb->last_error ) { + throw new \RuntimeException( 'lock' ); + } + clean_comment_cache( $locked ); + clean_post_cache( $plan['source'] ); + clean_post_cache( $plan['destination'] ); + if ( $restore ) { + $valid = $this->can_restore( $plan ); + } else { + $current = $this->plan( $plan['root'], $plan['destination'], $plan['subtree'] ); + $valid = ! is_wp_error( $current ) && hash_equals( $this->digest( $plan ), $this->digest( $current ) ); + } + if ( ! $valid ) { + throw new \RuntimeException( 'changed' ); + } + foreach ( $plan['before'] as $comment_id => $before ) { + $comment = get_comment( $comment_id ); + if ( ! $comment ) { + throw new \RuntimeException( 'missing_comment' ); + } + $target = $restore ? $before : $plan['thread_after'][ $comment_id ]; + if ( $this->placement( $comment ) === $target ) { + continue; + } + // Deliberately avoid re-sanitizing content or triggering approval changes. + $result = $wpdb->update( + $wpdb->comments, + array( + 'comment_post_ID' => $target['post'], + 'comment_parent' => $target['parent'], + ), + array( + 'comment_ID' => $comment_id, + 'comment_post_ID' => $comment->comment_post_ID, + 'comment_parent' => $comment->comment_parent, + ), + array( '%d', '%d' ), + array( '%d', '%d', '%d' ) + ); + if ( 1 !== $result ) { + throw new \RuntimeException( 'write' ); + } + } + if ( ! $this->refresh( $plan ) ) { + throw new \RuntimeException( 'counts' ); + } + // wpdb may reconnect after a lost connection, discarding its transaction. + if ( ! $this->owns_lock( $lock ) ) { + throw new \RuntimeException( 'connection' ); + } + $committing = true; + if ( false === $wpdb->query( 'COMMIT' ) ) { + throw new \RuntimeException( 'commit' ); + } + $transaction = false; + // Other requests must never retain objects loaded before the commit. + clean_comment_cache( array_keys( $plan['thread_before'] ) ); + clean_post_cache( $plan['source'] ); + clean_post_cache( $plan['destination'] ); + $record['status'] = $restore ? 'restored' : 'moved'; + if ( ! update_option( '_sce_comment_move_' . $id, $record, false ) && get_option( '_sce_comment_move_' . $id ) !== $record ) { + throw new \RuntimeException( 'journal' ); + } + delete_option( '_sce_comment_move_pending' ); + } catch ( \Throwable $error ) { + $known_rollback = ! $committing && $this->owns_lock( $lock ); + if ( $transaction ) { + $known_rollback = false !== $wpdb->query( 'ROLLBACK' ) && $known_rollback; + } + $record['status'] = $known_rollback ? ( $restore ? 'restore_refused' : 'cancelled' ) : 'recovery_required'; + update_option( '_sce_comment_move_' . $id, $record, false ); + // A rejected stale preview must not leave a global recovery deadlock. + if ( $known_rollback && null !== $pending && ( ! $restore || ! $pending ) ) { + delete_option( '_sce_comment_move_pending' ); + } + clean_comment_cache( array_keys( $plan['thread_before'] ) ); + clean_post_cache( $plan['source'] ); + clean_post_cache( $plan['destination'] ); + } finally { + $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); + } + return $id; + } + + /** + * Detect a reconnect before claiming a transaction or rollback succeeded. + * + * @param string $lock Advisory lock name. + * @return bool + */ + private function owns_lock( $lock ) { + global $wpdb; + return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_USED_LOCK(%s) = CONNECTION_ID()', $lock ) ); + } + + /** + * Invalidate placements and recalculate approved counts for both posts. + * + * @param array $plan Movement plan. + * @return bool + */ + private function refresh( $plan ) { + global $wpdb; + clean_comment_cache( array_keys( $plan['thread_before'] ) ); + $success = true; + foreach ( array( $plan['source'], $plan['destination'] ) as $post_id ) { + // Core returns true even when its UPDATE fails. Verify the intended count. + $expected = null; + $observe = static function ( $updated_post_id, $new_count ) use ( $post_id, &$expected ) { + if ( (int) $updated_post_id === $post_id ) { + $expected = (int) $new_count; + } + }; + add_action( 'wp_update_comment_count', $observe, PHP_INT_MAX, 2 ); + try { + $updated = wp_update_comment_count_now( $post_id ); + } finally { + remove_action( 'wp_update_comment_count', $observe, PHP_INT_MAX ); + } + $stored = $wpdb->get_var( $wpdb->prepare( "SELECT comment_count FROM {$wpdb->posts} WHERE ID = %d", $post_id ) ); + $success = $success && $updated && null !== $stored && null !== $expected && (int) $stored === $expected && ! $wpdb->last_error; + clean_post_cache( $post_id ); + } + return $success; + } + + /** + * Redirect after POST, so refresh cannot repeat a successful mutation. + * + * @param string|\WP_Error $result Operation ID or error. + */ + private function finish( $result ) { + if ( is_wp_error( $result ) ) { + wp_die( + esc_html( $result->get_error_message() ), + '', + array( + 'response' => 409, + 'back_link' => true, + ) + ); + } + wp_safe_redirect( $this->page_url( array( 'operation' => $result ) ) ); + exit; + } +} diff --git a/readme.txt b/readme.txt index 0b5397e..e0f21d6 100644 --- a/readme.txt +++ b/readme.txt @@ -86,6 +86,47 @@ Lazy Load for Comments uses the DOM-insertion contract rather than a private plu The GitHub README records browser verification with Ajaxify Comments 3.2.2 and Lazy Load for Comments 2.1.0 on Twenty Twenty-Five, including the Ajaxify comments-container selector required by that theme. += Can moderators move a comment to another post? = +Yes. In Dashboard -> Comments, use a comment's Move action (or Comments -> Move +comment). Enter the destination post ID, choose only that comment or its entire +reply subtree, then preview and explicitly confirm. This is an admin-only tool: +it requires comment moderation, edit access to every affected comment, and read +and edit access to both posts. It does not add public commenter controls. + +The moved root becomes top-level on the destination. If replies stay behind, +direct children inherit the root's former parent on the original post (or become +top-level); deeper replies keep their parents. If replies move, their internal +parents are preserved. The preview shows both posts, their statuses, and the +number of comments moving or staying. Moving approved comments can expose them +on the destination; content, approval and metadata are not changed. + +The destination must accept comments. Trashed posts, attachments, WooCommerce +products/reviews, pingbacks, trackbacks, spam/trashed comments, invalid threads, +and threads over 500 comments are rejected. Closed source posts are allowed. +InnoDB comment, post and option tables plus database advisory locks and +serializable transactions are required; unsupported storage fails without moving +comments. Placement and both approved-comment counts are updated in one +transaction. A changed preview or a known rolled-back failure can be retried +after previewing again; it does not block later moves. + += How can I recover an interrupted or mistaken move? = +Before any placement changes, SCE saves a non-autoloaded private option named +`_sce_comment_move_