get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, cached by Cache::remember(). $wpdb->prepare( 'SELECT id, title, slug, description, settings FROM %i WHERE taxonomy = %s ORDER BY title ASC', Tables::name( 'terms' ), 'topic' ), ARRAY_A ); return array_map( array( __CLASS__, 'format_topic' ), (array) $rows ); } ); } /** * Finds a topic by ID. * * @param int $id Topic ID. * @return array|null */ public static function topic( $id ) { foreach ( self::topics() as $topic ) { if ( absint( $id ) === $topic['id'] ) { return $topic; } } return null; } /** * Finds a topic by slug. * * @param string $slug Topic slug. * @return array|null */ public static function topic_by_slug( $slug ) { foreach ( self::topics() as $topic ) { if ( $topic['slug'] === $slug ) { return $topic; } } return null; } /** * Creates or updates a topic. * * @param array $input Title, description, color. * @param int $id Topic ID to update, 0 to create. * @return array|WP_Error The topic. */ public static function save_topic( array $input, $id = 0 ) { global $wpdb; $title = mb_substr( sanitize_text_field( (string) ( $input['title'] ?? '' ) ), 0, 60 ); if ( '' === $title ) { return new WP_Error( 'spacebay_topic_title', __( 'Please enter a topic name.', 'spacebay' ), array( 'status' => 400 ) ); } foreach ( self::topics() as $topic ) { if ( (int) $topic['id'] !== (int) $id && mb_strtolower( $topic['title'] ) === mb_strtolower( $title ) ) { return new WP_Error( 'spacebay_topic_exists', __( 'A topic with this name already exists.', 'spacebay' ), array( 'status' => 400 ) ); } } $color = sanitize_hex_color( (string) ( $input['color'] ?? '' ) ); $data = array( 'title' => $title, 'description' => mb_substr( sanitize_text_field( (string) ( $input['description'] ?? '' ) ), 0, 200 ), 'settings' => wp_json_encode( array( 'color' => $color ? strtolower( $color ) : '' ) ), ); $table = Tables::name( 'terms' ); if ( $id ) { if ( ! self::topic( $id ) ) { return new WP_Error( 'spacebay_topic_not_found', __( 'Topic not found.', 'spacebay' ), array( 'status' => 404 ) ); } $wpdb->update( $table, $data, array( 'id' => absint( $id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } else { $data['taxonomy'] = 'topic'; $data['slug'] = self::unique_slug( 'topic', $title ); $data['created_at'] = Time::now(); $wpdb->insert( $table, $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $id = (int) $wpdb->insert_id; } Cache::flush( 'terms' ); return self::topic( $id ); } /** * Deletes a topic and removes it from posts. * * @param int $id Topic ID. * @return bool */ public static function delete_topic( $id ) { global $wpdb; if ( ! self::topic( $id ) ) { return false; } $wpdb->delete( Tables::name( 'term_relations' ), array( 'term_id' => absint( $id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->delete( Tables::name( 'terms' ), array( 'id' => absint( $id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Cache::flush( 'terms' ); return true; } /** * Keeps only valid topic IDs, at most three. * * @param array $ids Requested IDs. * @return int[] */ public static function valid_topic_ids( array $ids ) { $valid = array(); foreach ( array_unique( array_map( 'absint', $ids ) ) as $id ) { if ( self::topic( $id ) ) { $valid[] = $id; } } return array_slice( $valid, 0, self::MAX_TOPICS ); } /** * Finds a hashtag term ID by its text. * * @param string $tag Lower-case hashtag without "#". * @return int 0 when unknown. */ public static function hashtag_id( $tag ) { global $wpdb; return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, indexed lookup. $wpdb->prepare( 'SELECT id FROM %i WHERE taxonomy = %s AND slug = %s', Tables::name( 'terms' ), 'hashtag', self::hashtag_slug( $tag ) ) ); } /** * Replaces the topics and hashtags of a post. * * @param int $post_id Post ID. * @param int[] $topic_ids Valid topic IDs. * @param string[] $hashtags Lower-case hashtags. * @return void */ public static function set_post_terms( $post_id, array $topic_ids, array $hashtags ) { global $wpdb; $relations = Tables::name( 'term_relations' ); $wpdb->delete( $relations, array( 'object_id' => absint( $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $term_ids = $topic_ids; foreach ( array_slice( array_unique( $hashtags ), 0, 20 ) as $tag ) { $term_ids[] = self::ensure_hashtag( $tag ); } foreach ( array_unique( array_filter( $term_ids ) ) as $term_id ) { $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $relations, array( 'term_id' => (int) $term_id, 'object_id' => absint( $post_id ), ) ); } } /** * Removes all term links of posts. * * @param int[] $post_ids Post IDs. * @return void */ public static function delete_for_posts( array $post_ids ) { global $wpdb; foreach ( array_map( 'absint', $post_ids ) as $post_id ) { $wpdb->delete( Tables::name( 'term_relations' ), array( 'object_id' => $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. } } /** * Topics and hashtags of several posts. * * @param int[] $post_ids Post IDs. * @return array */ public static function for_posts( array $post_ids ) { global $wpdb; $post_ids = array_values( array_filter( array_map( 'absint', $post_ids ) ) ); if ( ! $post_ids ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom tables, one query per feed page. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The IN() list has one %d per ID. "SELECT r.object_id, t.id, t.taxonomy, t.slug FROM %i r INNER JOIN %i t ON t.id = r.term_id WHERE r.object_id IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholders are built from %d only. array_merge( array( Tables::name( 'term_relations' ), Tables::name( 'terms' ) ), $post_ids ) ), ARRAY_A ); $map = array(); foreach ( (array) $rows as $row ) { $post_id = (int) $row['object_id']; if ( ! isset( $map[ $post_id ] ) ) { $map[ $post_id ] = array( 'topics' => array(), 'hashtags' => array(), ); } if ( 'topic' === $row['taxonomy'] ) { $topic = self::topic( (int) $row['id'] ); if ( $topic ) { $map[ $post_id ]['topics'][] = $topic; } } else { $map[ $post_id ]['hashtags'][] = $row['slug']; } } return $map; } /** * Returns the ID of a hashtag, creating it when needed. * * @param string $tag Hashtag text. * @return int */ private static function ensure_hashtag( $tag ) { global $wpdb; $id = self::hashtag_id( $tag ); if ( $id ) { return $id; } $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. Tables::name( 'terms' ), array( 'taxonomy' => 'hashtag', 'title' => mb_substr( $tag, 0, 50 ), 'slug' => self::hashtag_slug( $tag ), 'created_at' => Time::now(), ) ); return (int) $wpdb->insert_id; } /** * Storage form of a hashtag: lower case, at most 50 characters. * * @param string $tag Hashtag text. * @return string */ public static function hashtag_slug( $tag ) { return mb_substr( mb_strtolower( preg_replace( '/[^\p{L}\p{N}_]/u', '', (string) $tag ) ), 0, 50 ); } /** * Returns a topic slug no other topic uses. * * @param string $taxonomy Taxonomy. * @param string $title Title. * @return string */ private static function unique_slug( $taxonomy, $title ) { global $wpdb; $base = Slug::make( $title, 'topic' ); $slug = $base; $suffix = 2; while ( $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uniqueness check must read fresh data. $wpdb->prepare( 'SELECT id FROM %i WHERE taxonomy = %s AND slug = %s', Tables::name( 'terms' ), $taxonomy, $slug ) ) ) { $slug = $base . '-' . $suffix; ++$suffix; } return $slug; } /** * Formats a topic row. * * @param array $row Row. * @return array */ public static function format_topic( array $row ) { $settings = json_decode( (string) $row['settings'], true ); return array( 'id' => (int) $row['id'], 'title' => $row['title'], 'slug' => $row['slug'], 'description' => (string) $row['description'], 'color' => isset( $settings['color'] ) ? (string) $settings['color'] : '', ); } }