get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, single row by primary key. $wpdb->prepare( 'SELECT ' . self::COLUMNS . ' FROM %i WHERE id = %d', Tables::name( 'comments' ), absint( $id ) ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- COLUMNS is a class constant. ARRAY_A ); return $row ? self::normalize( $row ) : null; } /** * Lists top-level comments of a post, oldest first, with all their replies. * * @param int $post_id Post ID. * @param int $page Page number, from 1. * @param int $per_page Top-level comments per page. * @return array{items: array[], total: int} */ public static function for_post( $post_id, $page = 1, $per_page = 20 ) { global $wpdb; $table = Tables::name( 'comments' ); $per_page = max( 1, min( 50, absint( $per_page ) ) ); $offset = ( max( 1, absint( $page ) ) - 1 ) * $per_page; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Paged comment list; COLUMNS is a class constant. $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(id) FROM %i WHERE post_id = %d AND parent_id = 0 AND status = %s', $table, absint( $post_id ), 'published' ) ); $top = $wpdb->get_results( $wpdb->prepare( 'SELECT ' . self::COLUMNS . ' FROM %i WHERE post_id = %d AND parent_id = 0 AND status = %s ORDER BY is_pinned DESC, created_at ASC, id ASC LIMIT %d OFFSET %d', $table, absint( $post_id ), 'published', $per_page, $offset ), ARRAY_A ); $items = array_map( array( __CLASS__, 'normalize' ), (array) $top ); // phpcs:enable $ids = wp_list_pluck( $items, 'id' ); if ( $ids ) { $items = array_merge( $items, self::descendants( $ids, self::MAX_DESCENDANTS ) ); } return array( 'items' => $items, 'total' => $total, ); } /** * Adds a comment or reply. * * @param array $post Post row. * @param array $space Space row of the post. * @param array $input Raw input: message, parent_id. * @param int $user_id Author. * @return array|WP_Error The new comment row. */ public static function create( array $post, array $space, array $input, $user_id ) { global $wpdb; if ( ! PostAccess::can_comment( $space, $user_id ) ) { return new WP_Error( 'spacebay_cannot_comment', __( 'Join this space to comment.', 'spacebay' ), array( 'status' => 403 ) ); } $parent_id = absint( $input['parent_id'] ?? 0 ); if ( $parent_id ) { $parent = self::find( $parent_id ); if ( ! $parent || $parent['post_id'] !== $post['id'] || 'published' !== $parent['status'] ) { return new WP_Error( 'spacebay_bad_parent', __( 'The comment you are replying to no longer exists.', 'spacebay' ), array( 'status' => 400 ) ); } // A reply keeps its real parent; at the depth cap it lands beside the comment it answers. $parent_id = self::depth( $parent ) >= self::max_depth() ? $parent['parent_id'] : $parent['id']; } $limited = RateLimit::hit( 'comment', $user_id, 20 ); if ( is_wp_error( $limited ) ) { return $limited; } $screen = Moderation::screen( array( 'message' => (string) ( $input['message'] ?? '' ) ), $space, $user_id, 'comment' ); if ( is_wp_error( $screen ) ) { return $screen; } $rendered = self::render( $screen['texts']['message'] ); if ( is_wp_error( $rendered ) ) { return $rendered; } $now = Time::now(); $status = '' === $screen['hold'] ? 'published' : 'pending'; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'comments' ), array( 'post_id' => $post['id'], 'parent_id' => $parent_id, 'user_id' => absint( $user_id ), 'message' => $rendered['text'], 'message_rendered' => $rendered['html'], 'status' => $status, 'created_at' => $now, 'updated_at' => $now, ) ); $comment = self::find( (int) $wpdb->insert_id ); delete_transient( 'spacebay_admin_stats' ); if ( 'pending' === $status ) { Moderation::held( 'comment', $comment, $screen['hold'], $screen['note'] ); return $comment; } Posts::touch( $post['id'] ); /** * Fires after a comment is added, including when a moderator * approves a comment that was held for review. * * @since 1.0.0 * * @param array $comment Comment row. * @param array $post Post row. * @param int[] $mentions IDs of mentioned members. */ do_action( 'spacebay_comment_created', $comment, $post, $rendered['mentions'] ); return $comment; } /** * Edits the text of a comment. * * @param array $comment Comment row. * @param string $message New text. * @param array $space Space row, used to check blocked words. * @param int $user_id Editor. * @return array|WP_Error Updated row. */ public static function update( array $comment, $message, array $space = array(), $user_id = 0 ) { global $wpdb; if ( $user_id ) { $limited = RateLimit::hit( 'edit', $user_id, 20 ); if ( is_wp_error( $limited ) ) { return $limited; } } if ( $space ) { $screen = Moderation::screen( array( 'message' => (string) $message ), $space, $user_id, 'comment_edit' ); if ( is_wp_error( $screen ) ) { return $screen; } $message = $screen['texts']['message']; } $rendered = self::render( $message ); if ( is_wp_error( $rendered ) ) { return $rendered; } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'comments' ), array( 'message' => $rendered['text'], 'message_rendered' => $rendered['html'], 'updated_at' => Time::now(), ), array( 'id' => $comment['id'] ) ); return self::find( $comment['id'] ); } /** * Changes the status of a comment and recounts the post's comments. * * @param array $comment Comment row. * @param string $status "published" or "pending". * @return array Updated row. */ public static function set_status( array $comment, $status ) { global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'comments' ), array( 'status' => 'pending' === $status ? 'pending' : 'published' ), array( 'id' => $comment['id'] ) ); Posts::touch( $comment['post_id'] ); delete_transient( 'spacebay_admin_stats' ); return self::find( $comment['id'] ); } /** * The depth cap, filterable. * * @return int */ public static function max_depth() { /** * Filters the deepest reply level (a top-level comment is level 1). * * @since 1.0.0 * * @param int $max Depth cap, default 5. */ return max( 1, (int) apply_filters( 'spacebay_comment_max_depth', self::MAX_DEPTH ) ); } /** * Depth of a comment: 1 for a top-level comment. * * @param array $comment Comment row. * @return int */ public static function depth( array $comment ) { $depth = 1; $seen = array( $comment['id'] => true ); while ( $comment['parent_id'] && ! isset( $seen[ $comment['parent_id'] ] ) ) { $comment = self::find( $comment['parent_id'] ); if ( ! $comment ) { break; } $seen[ $comment['id'] ] = true; ++$depth; } return $depth; } /** * IDs of the direct children of comments. * * @param int[] $parent_ids Comment IDs. * @param int $user_id Only children by this user, 0 for all. * @return int[] */ private static function child_ids( array $parent_ids, $user_id = 0 ) { global $wpdb; if ( ! $parent_ids ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $parent_ids ), '%d' ) ); if ( $user_id ) { $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup query. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. "SELECT id FROM %i WHERE parent_id IN ($placeholders) AND user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholders are %d only. array_merge( array( Tables::name( 'comments' ) ), $parent_ids, array( $user_id ) ) ) ); } else { $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup query. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. "SELECT id FROM %i WHERE parent_id IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholders are %d only. array_merge( array( Tables::name( 'comments' ) ), $parent_ids ) ) ); } return array_map( 'intval', (array) $ids ); } /** * Published (or all) descendants of comments at any depth, oldest first. * * Reads one level per query, so the cost follows the depth, not the number * of comments. * * @param int[] $root_ids Comment IDs. * @param int $limit Most rows to return. * @param bool $all Include pending comments. * @return array[] Comment rows. */ private static function descendants( array $root_ids, $limit, $all = false ) { global $wpdb; $level = array_map( 'absint', $root_ids ); $seen = array_fill_keys( $level, true ); $found = array(); $rounds = 0; $size = 0; while ( $level && $size < $limit && $rounds < 100 ) { ++$rounds; $placeholders = implode( ',', array_fill( 0, count( $level ), '%d' ) ); $columns = self::COLUMNS; if ( $all ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One query per level. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. "SELECT $columns FROM %i WHERE parent_id IN ($placeholders) ORDER BY created_at ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholders are %d only; COLUMNS is a class constant. array_merge( array( Tables::name( 'comments' ) ), $level ) ), ARRAY_A ); } else { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One query per level. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. "SELECT $columns FROM %i WHERE parent_id IN ($placeholders) AND status = %s ORDER BY created_at ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholders are %d and %s only; COLUMNS is a class constant. array_merge( array( Tables::name( 'comments' ) ), $level, array( 'published' ) ) ), ARRAY_A ); } $level = array(); foreach ( (array) $rows as $row ) { $row = self::normalize( $row ); if ( isset( $seen[ $row['id'] ] ) ) { continue; } $seen[ $row['id'] ] = true; $found[] = $row; $level[] = $row['id']; ++$size; } } usort( $found, static function ( $a, $b ) { $by_time = strcmp( $a['created_at'], $b['created_at'] ); return 0 !== $by_time ? $by_time : $a['id'] <=> $b['id']; } ); return array_slice( $found, 0, $limit ); } /** * Deletes a comment and its replies. * * With $keep_replies, replies by other members are kept and move up to the * deleted comment's own parent (top level when it had none). The author's * own direct replies are deleted with it, and the replies to those move up * as well. Without it, every descendant is deleted. * * @param array $comment Comment row. * @param bool $keep_replies Keep other members' replies. * @return array{ids: int[], parent_id: int, promoted_ids: int[]} Deleted IDs, the * parent the kept replies now hang on, and their IDs. */ public static function delete( array $comment, $keep_replies = false ) { global $wpdb; $table = Tables::name( 'comments' ); $promoted = array(); if ( $keep_replies ) { $own = self::child_ids( array( $comment['id'] ), $comment['user_id'] ); $ids = array_merge( array( $comment['id'] ), $own ); $promoted = array_values( array_diff( self::child_ids( array( $comment['id'] ) ), $own ) ); $promoted = array_values( array_unique( array_merge( $promoted, self::child_ids( $own ) ) ) ); if ( $promoted ) { $placeholders = implode( ',', array_fill( 0, count( $promoted ), '%d' ) ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->prepare( "UPDATE %i SET parent_id = %d WHERE id IN ($placeholders)", array_merge( array( $table, $comment['parent_id'] ), $promoted ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- One %d per ID. ); } } else { $ids = array_merge( array( $comment['id'] ), wp_list_pluck( self::descendants( array( $comment['id'] ), PHP_INT_MAX, true ), 'id' ) ); } $ids = array_map( 'intval', $ids ); Reactions::delete_for( 'comment', $ids ); foreach ( $ids as $id ) { $wpdb->delete( $table, array( 'id' => $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } Posts::touch( $comment['post_id'] ); delete_transient( 'spacebay_admin_stats' ); /** * Fires after a comment and its replies are deleted. * * @since 1.0.0 * @since 1.0.0 Added the $ids parameter. * * @param array $comment The deleted comment row. * @param int[] $ids IDs of every deleted comment: the comment and the replies deleted with it. */ do_action( 'spacebay_comment_deleted', $comment, $ids ); return array( 'ids' => $ids, 'parent_id' => (int) $comment['parent_id'], 'promoted_ids' => $promoted, ); } /** * Deletes all comments of a post. * * @param int $post_id Post ID. * @return void */ public static function delete_for_post( $post_id ) { global $wpdb; $table = Tables::name( 'comments' ); $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup query. $wpdb->prepare( 'SELECT id FROM %i WHERE post_id = %d', $table, absint( $post_id ) ) ); Reactions::delete_for( 'comment', array_map( 'intval', (array) $ids ) ); $wpdb->delete( $table, array( 'post_id' => absint( $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } /** * Deletes all comments written by a user. * * @param int $user_id User ID. * @return void */ public static function delete_for_user( $user_id ) { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup query. $wpdb->prepare( 'SELECT ' . self::COLUMNS . ' FROM %i WHERE user_id = %d', Tables::name( 'comments' ), absint( $user_id ) ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- COLUMNS is a class constant. ARRAY_A ); foreach ( (array) $rows as $row ) { $comment = self::find( (int) $row['id'] ); if ( $comment ) { self::delete( $comment ); } } } /** * Renders and validates comment text. * * @param string $message Raw text. * @return array|WP_Error Renderer output. */ private static function render( $message ) { $rendered = Renderer::render( (string) $message ); if ( '' === $rendered['text'] ) { return new WP_Error( 'spacebay_comment_empty', __( 'Please write a comment first.', 'spacebay' ), array( 'status' => 400 ) ); } if ( mb_strlen( $rendered['text'] ) > self::MAX_LENGTH ) { return new WP_Error( 'spacebay_comment_too_long', sprintf( /* translators: %s: Maximum number of characters. */ __( 'Comments can be up to %s characters long.', 'spacebay' ), number_format_i18n( self::MAX_LENGTH ) ), array( 'status' => 400 ) ); } return $rendered; } /** * Casts a comment row. * * @param array $row Raw row. * @return array */ public static function normalize( array $row ) { return array( 'id' => (int) $row['id'], 'post_id' => (int) $row['post_id'], 'parent_id' => (int) $row['parent_id'], 'user_id' => (int) $row['user_id'], 'message' => (string) $row['message'], 'message_rendered' => (string) $row['message_rendered'], 'status' => $row['status'], 'reactions_count' => (int) $row['reactions_count'], 'created_at' => $row['created_at'], 'updated_at' => $row['updated_at'], ); } }