', esc_html__( 'Use this setting to control whether new patterns need moderation before showing up (pending) or not (published).', 'wporg-patterns' ) );
+}
+
+/**
+ * Render the flag threshold field.
+ *
+ * @return void
+ */
+function render_threshold_field() {
+ $current = get_option( 'wporg-pattern-flag_threshold' );
+ ?>
+
+ %s
',
+ esc_html__( 'Use this setting to change the number of times a pattern can be reported before it is automatically unpublished (set to pending) while awaiting review.', 'wporg-patterns' )
+ );
+}
+
+/**
+ * Display the Block Patterns settings page.
+ *
+ * @return void
+ */
+function render_page() {
+ require_once dirname( __DIR__ ) . '/views/admin-settings.php';
+}
diff --git a/content/plugins/pattern-directory/includes/admin-stats.php b/content/plugins/pattern-directory/includes/admin-stats.php
new file mode 100644
index 0000000..3395ffc
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/admin-stats.php
@@ -0,0 +1,249 @@
+cap->edit_posts,
+ PATTERN_POST_TYPE . '-stats',
+ __NAMESPACE__ . '\render_subpage'
+ );
+}
+
+/**
+ * Render the stats subpage.
+ *
+ * @return void
+ */
+function render_subpage() {
+ $schema = get_meta_field_schema();
+ $current_data = get_snapshot_data();
+ $snapshot_info = get_snapshot_meta_data();
+ $next_snapshot = wp_get_scheduled_event( PATTERN_POST_TYPE . '_record_snapshot' );
+ $inputs = get_export_form_inputs();
+ $export_label = EXPORT_ACTION;
+
+ require dirname( __DIR__ ) . '/views/admin-stats.php';
+}
+
+/**
+ * Get meta data about existing snapshots.
+ *
+ * @return array
+ */
+function get_snapshot_meta_data() {
+ $earliest_snapshot = get_snapshots( array(
+ 'order' => 'asc',
+ 'numberposts' => 1,
+ ) );
+ $latest_snapshot_query = get_snapshots(
+ array(
+ 'order' => 'desc',
+ 'numberposts' => 1,
+ ),
+ true
+ );
+
+ $total_snapshots = $latest_snapshot_query->found_posts;
+ $earliest_date = '';
+ $latest_date = '';
+
+ if ( $total_snapshots > 0 ) {
+ $latest_snapshot = $latest_snapshot_query->get_posts();
+ $earliest_date = get_the_date( 'Y-m-d', reset( $earliest_snapshot ) );
+ $latest_date = get_the_date( 'Y-m-d', reset( $latest_snapshot ) );
+ }
+
+ return array(
+ 'total_snapshots' => $total_snapshots,
+ 'earliest_date' => $earliest_date,
+ 'latest_date' => $latest_date,
+ );
+}
+
+/**
+ * Collect and validate the export form inputs.
+ *
+ * @return array
+ */
+function get_export_form_inputs() {
+ $date_filter = function( $string ) {
+ $success = preg_match( '|([0-9]{4}\-[0-9]{2}\-[0-9]{2})|', $string, $match );
+
+ if ( $success ) {
+ return $match[1];
+ }
+
+ return '';
+ };
+
+ $input_config = array(
+ 'start' => array(
+ 'filter' => FILTER_CALLBACK,
+ 'options' => $date_filter,
+ ),
+ 'end' => array(
+ 'filter' => FILTER_CALLBACK,
+ 'options' => $date_filter,
+ ),
+ 'action' => FILTER_DEFAULT,
+ '_wpnonce' => FILTER_DEFAULT,
+ );
+
+ $defaults = array_fill_keys( array_keys( $input_config ), '' );
+ $inputs = filter_input_array( INPUT_POST, $input_config );
+
+ return wp_parse_args( $inputs, $defaults );
+}
+
+/**
+ * Process an export form submission.
+ *
+ * @return void
+ */
+function handle_csv_export() {
+ require_once __DIR__ . '/class-export-csv.php';
+ $csv = new \WordCamp\Utilities\Export_CSV();
+
+ $action = EXPORT_ACTION;
+ $cpt = get_post_type_object( PATTERN_POST_TYPE );
+ $info = get_snapshot_meta_data();
+ $inputs = get_export_form_inputs();
+ $schema = get_meta_field_schema();
+
+ if ( $action !== $inputs['action'] ) {
+ return;
+ }
+
+ if ( ! current_user_can( $cpt->cap->edit_posts ) ) {
+ $csv->error->add( 'no_permission', 'Sorry, you do not have permission to do this.' );
+ $csv->emit_file();
+ }
+
+ if ( ! wp_verify_nonce( $inputs['_wpnonce'], EXPORT_ACTION ) ) {
+ $csv->error->add( 'invalid_nonce', 'Nonce failed. Try refreshing the screen.' );
+ $csv->emit_file();
+ }
+
+ try {
+ $start_date = new \DateTime( $inputs['start'] );
+ $end_date = new \DateTime( $inputs['end'] );
+ $earliest = new \DateTime( $info['earliest_date'] );
+ $latest = new \DateTime( $info['latest_date'] );
+ } catch ( \Exception $exception ) {
+ $csv->error->add(
+ 'invalid_date',
+ $exception->getMessage()
+ );
+ $csv->emit_file();
+ }
+
+ $csv->set_filename( array(
+ 'patterns-snapshots',
+ $start_date->format( 'Ymd' ),
+ $end_date->format( 'Ymd' ),
+ ) );
+
+ $csv->set_column_headers( array_merge(
+ array( 'Date' ),
+ array_keys( $schema['properties'] )
+ ) );
+
+ if ( $start_date < $earliest ) {
+ $csv->error->add(
+ 'invalid_date',
+ sprintf(
+ 'Date range must begin %s or later.',
+ $earliest->format( 'Y-m-d' )
+ )
+ );
+ $csv->emit_file();
+ }
+
+ if ( $end_date > $latest ) {
+ $csv->error->add(
+ 'invalid_date',
+ sprintf(
+ 'Date range must end %s or earlier.',
+ $latest->format( 'Y-m-d' )
+ )
+ );
+ $csv->emit_file();
+ }
+
+ if ( $start_date > $end_date ) {
+ $csv->error->add(
+ 'invalid_date',
+ 'Date range start must be less than or equal to date range end.'
+ );
+ $csv->emit_file();
+ }
+
+ $query_args = array(
+ 'order' => 'asc',
+ 'posts_per_page' => -1,
+ 'date_query' => array(
+ array(
+ 'after' => $start_date->format( 'Y-m-d' ),
+ 'before' => $end_date->format( 'Y-m-d' ),
+ 'inclusive' => true,
+ ),
+ ),
+ );
+
+ $snapshots = get_snapshots( $query_args );
+
+ if ( ! $snapshots ) {
+ $csv->error->add(
+ 'no_data',
+ 'No snapshots were found.'
+ );
+ $csv->emit_file();
+ }
+
+ $data = array_map(
+ function( $snapshot ) use ( $schema ) {
+ $date = get_the_date( 'Y-m-d', $snapshot );
+ $row = array( $date );
+
+ foreach ( array_keys( $schema['properties'] ) as $key ) {
+ $row[] = $snapshot->$key;
+ }
+
+ return $row;
+ },
+ $snapshots
+ );
+
+ $csv->add_data_rows( $data );
+
+ $csv->emit_file();
+}
diff --git a/content/plugins/pattern-directory/includes/admin.php b/content/plugins/pattern-directory/includes/admin.php
new file mode 100644
index 0000000..b608788
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/admin.php
@@ -0,0 +1,63 @@
+get_node( 'new-wporg-pattern' );
+ if ( $new_pattern ) {
+ $new_pattern->href = site_url( 'new-pattern/' );
+ $wp_admin_bar->add_node( $new_pattern );
+ }
+
+ // Top-level "+ New" link, if New Block Pattern is the only item.
+ $new_content = $wp_admin_bar->get_node( 'new-content' );
+ if ( $new_content && str_contains( $new_content->href, POST_TYPE ) ) {
+ $new_content->href = site_url( 'new-pattern/' );
+ $wp_admin_bar->add_node( $new_content );
+ }
+
+ // "Edit Block Pattern" link.
+ if ( is_singular( POST_TYPE ) ) {
+ $edit_pattern = $wp_admin_bar->get_node( 'edit' );
+ if ( $edit_pattern ) {
+ $pattern_id = wp_get_post_parent_id() ?: get_the_ID();
+ $edit_pattern->href = site_url( "pattern/$pattern_id/edit/" );
+ if ( wp_get_post_parent_id() !== 0 ) {
+ $edit_pattern->title = __( 'Edit Original Pattern', 'wporg-patterns' );
+ }
+ $wp_admin_bar->add_node( $edit_pattern );
+ }
+
+ // Add a link to the post in wp-admin if the user is a moderator.
+ $post_type = get_post_type_object( POST_TYPE );
+ if ( current_user_can( $post_type->cap->edit_others_posts ) ) {
+ $wp_admin_bar->add_node( array(
+ 'id' => 'edit-admin',
+ 'title' => 'Moderate Pattern',
+ 'parent' => 'edit-actions', // this node is added by wporg-mu-plugins.
+ 'href' => get_edit_post_link(),
+ ) );
+ }
+ }
+}
diff --git a/content/plugins/pattern-directory/includes/badges.php b/content/plugins/pattern-directory/includes/badges.php
new file mode 100644
index 0000000..f27a8b7
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/badges.php
@@ -0,0 +1,72 @@
+post_author );
+ } elseif ( 'publish' === $old_status && 'publish' !== $new_status ) {
+ // If the user has no published patterns, remove the badge.
+ $other_posts = get_posts( [
+ 'post_type' => PATTERN_POST_TYPE,
+ 'post_status' => 'publish',
+ 'author' => $post->post_author,
+ 'exclude' => $post->ID,
+ 'numberposts' => 1,
+ 'fields' => 'ids',
+ ] );
+
+ if ( ! $other_posts ) {
+ remove_badge( 'pattern-author', $post->post_author );
+ }
+ }
+}
+
+/**
+ * Remove the 'Patterns Team' badge from a user when they're removed from the Patterns site.
+ */
+function remove_user_from_blog( $user_id ) {
+ if ( function_exists( 'WordPressdotorg\Profiles\remove_badge' ) ) {
+ remove_badge( 'patterns-team', $user_id );
+ }
+}
+
+/**
+ * Add/Remove the 'Patterns Team' badge from a user when their role changes.
+ *
+ * The badge is added for all roles except for Contributor and Subscriber.
+ * The badge is removed when the role is set to Contributor or Subscriber.
+ */
+function set_user_role( $user_id, $role ) {
+ if ( ! function_exists( 'WordPressdotorg\Profiles\assign_badge' ) ) {
+ return;
+ }
+
+ if ( 'subscriber' === $role || 'contributor' === $role ) {
+ remove_badge( 'patterns-team', $user_id );
+ } else {
+ assign_badge( 'patterns-team', $user_id );
+ }
+}
diff --git a/content/plugins/pattern-directory/includes/class-export-csv.php b/content/plugins/pattern-directory/includes/class-export-csv.php
new file mode 100644
index 0000000..09478cd
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/class-export-csv.php
@@ -0,0 +1,341 @@
+error = new \WP_Error();
+
+ $options = wp_parse_args( $options, array(
+ 'filename' => array(),
+ 'headers' => array(),
+ 'data' => array(),
+ ) );
+
+ if ( ! empty( $options['filename'] ) ) {
+ $this->set_filename( $options['filename'] );
+ }
+
+ if ( ! empty( $options['headers'] ) ) {
+ $this->set_column_headers( $options['headers'] );
+ }
+
+ if ( ! empty( $options['data'] ) ) {
+ $this->add_data_rows( $options['data'] );
+ }
+ }
+
+ /**
+ * Specify the name for the CSV file.
+ *
+ * This method takes an array of string segments that will be concatenated into a single file name string.
+ * It is not necessary to include the file name suffix (.csv).
+ *
+ * Example:
+ *
+ * array( 'Payment Activity', '2017-01-01', '2017-12-31' )
+ *
+ * will become:
+ *
+ * payment-activity_2017-01-01_2017-12-31.csv
+ *
+ * @param array|string $name_segments One or more string segments that will comprise the CSV file name.
+ *
+ * @return bool True if the file name was successfully set. Otherwise false.
+ */
+ public function set_filename( $name_segments ) {
+ if ( ! is_array( $name_segments ) ) {
+ $name_segments = (array) $name_segments;
+ }
+
+ $name_segments = array_map( function( $segment ) {
+ $segment = strtolower( $segment );
+ $segment = str_replace( '_', '-', $segment );
+ $segment = sanitize_file_name( $segment );
+ $segment = str_replace( '.csv', '', $segment );
+
+ return $segment;
+ }, $name_segments );
+
+ if ( ! empty( $name_segments ) ) {
+ $this->filename = implode( '_', $name_segments ) . '.csv';
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Set the first row of the CSV file as headers for each column.
+ *
+ * If used, this also determines how many columns each row should have. Note that, while optional, this method
+ * must be used before data rows are added.
+ *
+ * @param array $headers The column header strings.
+ *
+ * @return bool True if the column headers were successfully set. Otherwise false.
+ */
+ public function set_column_headers( array $headers ) {
+ if ( ! empty( $this->data_rows ) ) {
+ $this->error->add(
+ 'csv_error',
+ 'Column headers cannot be set after data rows have been added.'
+ );
+
+ return false;
+ }
+
+ $this->header_row = array_map( 'sanitize_text_field', $headers );
+
+ return true;
+ }
+
+ /**
+ * Add a single row of data to the CSV file.
+ *
+ * @param array $row A single row of data.
+ *
+ * @return bool True if the data row was successfully added. Otherwise false.
+ */
+ public function add_row( array $row ) {
+ $column_count = 0;
+
+ if ( ! empty( $this->header_row ) ) {
+ $column_count = count( $this->header_row );
+ } elseif ( ! empty( $this->data_rows ) ) {
+ $column_count = count( $this->data_rows[0] );
+ }
+
+ if ( $column_count && count( $row ) !== $column_count ) {
+ $this->error->add(
+ 'csv_error',
+ sprintf(
+ 'Could not add row because it has %d columns, when it should have %d.',
+ absint( count( $row ) ),
+ absint( $column_count )
+ )
+ );
+
+ return false;
+ }
+
+ $this->data_rows[] = array_map( 'sanitize_text_field', $row );
+
+ return true;
+ }
+
+ /**
+ * Wrapper method for adding multiple data rows at once.
+ *
+ * @param array $data
+ *
+ * @return void
+ */
+ public function add_data_rows( array $data ) {
+ foreach ( $data as $row ) {
+ $result = $this->add_row( $row );
+
+ if ( ! $result ) {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Escape an array of strings to be used in a CSV context.
+ *
+ * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks,
+ * information disclosure, and arbitrary command execution.
+ *
+ * @see http://www.contextis.com/resources/blog/comma-separated-vulnerabilities/
+ * @see https://hackerone.com/reports/72785
+ *
+ * Derived from CampTix_Plugin::esc_csv.
+ *
+ * Note that this method is not recursive, so should only be used for individual data rows, not an entire data set.
+ *
+ * @param array $fields
+ *
+ * @return array
+ */
+ public static function esc_csv( array $fields ) {
+ $active_content_triggers = array( '=', '+', '-', '@' );
+
+ /*
+ * Formulas that follow all common delimiters need to be escaped, because the user may choose any delimiter
+ * when importing a file into their spreadsheet program. Different delimiters are also used as the default
+ * in different locales. For example, Windows + Russian uses `;` as the delimiter, rather than a `,`.
+ *
+ * The file encoding can also effect the behavior; e.g., opening/importing as UTF-8 will enable newline
+ * characters as delimiters.
+ */
+ $delimiters = array( ',', ';', ':', '|', '^', "\n", "\t", ' ' );
+
+ foreach ( $fields as $index => $field ) {
+ // Escape trigger characters at the start of a new field
+ $first_cell_character = mb_substr( $field, 0, 1 );
+ $is_trigger_character = in_array( $first_cell_character, $active_content_triggers, true );
+ $is_delimiter = in_array( $first_cell_character, $delimiters, true );
+
+ if ( $is_trigger_character || $is_delimiter ) {
+ $field = "'" . $field;
+ }
+
+ // Escape trigger characters that follow delimiters
+ foreach ( $delimiters as $delimiter ) {
+ foreach ( $active_content_triggers as $trigger ) {
+ $field = str_replace( $delimiter . $trigger, $delimiter . "'" . $trigger, $field );
+ }
+ }
+
+ $fields[ $index ] = $field;
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Generate the contents of the CSV file.
+ *
+ * @return string
+ */
+ protected function generate_file_content() {
+ if ( empty( $this->data_rows ) ) {
+ $this->error->add(
+ 'csv_error',
+ 'No data.'
+ );
+
+ return '';
+ }
+
+ ob_start();
+
+ $csv = fopen( 'php://output', 'w' );
+
+ if ( ! empty( $this->header_row ) ) {
+ fputcsv( $csv, self::esc_csv( $this->header_row ) );
+ }
+
+ foreach ( $this->data_rows as $row ) {
+ fputcsv( $csv, self::esc_csv( $row ) );
+ }
+
+ fclose( $csv );
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Output the CSV file, or a text file with error messages.
+ */
+ public function emit_file() {
+ if ( ! $this->filename ) {
+ $this->error->add(
+ 'csv_error',
+ 'Could not generate a CSV file without a file name.'
+ );
+ }
+
+ $content = $this->generate_file_content();
+
+ header( 'Cache-control: private' );
+ header( 'Pragma: private' );
+ header( 'Expires: Mon, 26 Jul 1997 05:00:00 GMT' ); // As seen in CampTix_Plugin::summarize_admin_init.
+
+ if ( ! empty( $this->error->get_error_messages() ) ) {
+ header( 'Content-Type: text' );
+ header( 'Content-Disposition: attachment; filename="error.txt"' );
+
+ foreach ( $this->error->get_error_codes() as $code ) {
+ foreach ( $this->error->get_error_messages( $code ) as $message ) {
+ echo "$code: $message\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+ }
+
+ die();
+ }
+
+ header( 'Content-Type: text/csv' );
+ header( sprintf( 'Content-Disposition: attachment; filename="%s"', sanitize_file_name( $this->filename ) ) );
+
+ echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+
+ die();
+ }
+
+ /**
+ * Save the CSV file to a local directory.
+ *
+ * @param string $location The path of the directory to save the file in.
+ *
+ * @return bool|string
+ */
+ public function save_file( $location ) {
+ if ( ! $this->filename ) {
+ $this->error->add(
+ 'csv_error',
+ 'Could not generate a CSV file without a file name.'
+ );
+ }
+
+ if ( ! wp_is_writable( $location ) ) {
+ $this->error->add(
+ 'filesystem_error',
+ 'The specified location is not writable.'
+ );
+
+ return false;
+ }
+
+ $full_path = trailingslashit( $location ) . $this->filename;
+ $content = $this->generate_file_content();
+
+ $file = fopen( $full_path, 'w' );
+ fwrite( $file, $content );
+ fclose( $file );
+
+ return $full_path;
+ }
+}
diff --git a/content/plugins/pattern-directory/includes/class-rest-favorite-controller.php b/content/plugins/pattern-directory/includes/class-rest-favorite-controller.php
new file mode 100644
index 0000000..150716b
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/class-rest-favorite-controller.php
@@ -0,0 +1,124 @@
+ WP_REST_Server::READABLE,
+ 'callback' => __NAMESPACE__ . '\get_items',
+ 'permission_callback' => __NAMESPACE__ . '\permissions_check',
+ )
+ );
+
+ $args = array(
+ 'id' => array(
+ 'validate_callback' => function( $param, $request, $key ) {
+ return is_numeric( $param );
+ },
+ ),
+ );
+ register_rest_route(
+ 'wporg/v1',
+ 'pattern-favorites',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => __NAMESPACE__ . '\create_item',
+ 'args' => $args,
+ 'permission_callback' => __NAMESPACE__ . '\permissions_check',
+ )
+ );
+ register_rest_route(
+ 'wporg/v1',
+ 'pattern-favorites',
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => __NAMESPACE__ . '\delete_item',
+ 'args' => $args,
+ 'permission_callback' => __NAMESPACE__ . '\permissions_check',
+ )
+ );
+}
+
+/**
+ * Check if a given request has access to favorites.
+ * The only requirement for anything "favorite" is to be logged in.
+ *
+ * @return WP_Error|bool
+ */
+function permissions_check() {
+ if ( ! is_user_logged_in() ) {
+ return new WP_Error(
+ 'rest_authorization_required',
+ __( 'You must be logged in to favorite a pattern.', 'wporg-patterns' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+}
+
+/**
+ * Get the list of favorites for the current user.
+ *
+ * @param WP_REST_Request $request Full data about the request.
+ * @return WP_Error|WP_REST_Response
+ */
+function get_items( $request ) {
+ $favorites = get_favorites();
+ return new WP_REST_Response( $favorites, 200 );
+}
+
+/**
+ * Save a pattern to the user's favorites.
+ *
+ * @param WP_REST_Request $request Full data about the request.
+ * @return WP_Error|WP_REST_Response
+ */
+function create_item( $request ) {
+ $pattern_id = $request['id'];
+ $success = add_favorite( $pattern_id );
+
+ if ( $success ) {
+ $count = get_favorite_count( $pattern_id );
+ return new WP_REST_Response( $count, 200 );
+ }
+
+ return new WP_Error(
+ 'favorite-failed',
+ __( 'Unable to favorite this pattern.', 'wporg-patterns' ),
+ array( 'status' => 500 )
+ );
+}
+
+/**
+ * Remove a pattern from the user's favorites.
+ *
+ * @param WP_REST_Request $request Full data about the request.
+ * @return WP_Error|WP_REST_Response
+ */
+function delete_item( $request ) {
+ $pattern_id = $request['id'];
+ $success = remove_favorite( $pattern_id );
+
+ if ( $success ) {
+ $count = get_favorite_count( $pattern_id );
+ return new WP_REST_Response( $count, 200 );
+ }
+
+ return new WP_Error(
+ 'unfavorite-failed',
+ __( 'Unable to remove this pattern from your favorites.', 'wporg-patterns' ),
+ array( 'status' => 500 )
+ );
+}
diff --git a/content/plugins/pattern-directory/includes/class-rest-flags-controller.php b/content/plugins/pattern-directory/includes/class-rest-flags-controller.php
new file mode 100644
index 0000000..11447ac
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/class-rest-flags-controller.php
@@ -0,0 +1,285 @@
+parent_post_type = PATTERN;
+ }
+
+ /**
+ * Retrieves an array of endpoint arguments from the item schema for the controller.
+ *
+ * @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
+ * checked for required values and may fall-back to a given default, this is not done
+ * on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
+ * @return array Endpoint arguments.
+ */
+ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
+ $endpoint_args = $this->get_item_schema();
+
+ if ( WP_REST_Server::CREATABLE === $method ) {
+ $endpoint_args['properties'] = array_intersect_key(
+ $endpoint_args['properties'],
+ array(
+ 'parent' => true,
+ 'excerpt' => true,
+ FLAG_TAX => true,
+ )
+ );
+ } elseif ( WP_REST_Server::EDITABLE === $method ) {
+ $endpoint_args['properties'] = array_intersect_key(
+ $endpoint_args['properties'],
+ array(
+ 'status' => true,
+ FLAG_TAX => true,
+ )
+ );
+ }
+
+ return rest_get_endpoint_args_for_schema( $endpoint_args, $method );
+ }
+
+ /**
+ * Checks if a given request has access to read posts.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ *
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $parent_post_type = get_post_type_object( PATTERN );
+
+ if ( ! current_user_can( $parent_post_type->cap->edit_posts ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to view pattern flags.', 'wporg-patterns' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return parent::get_items_permissions_check( $request );
+ }
+
+ /**
+ * Checks if a given request has access to read a post.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ *
+ * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ $post = $this->get_post( $request['id'] );
+ if ( is_wp_error( $post ) ) {
+ return $post;
+ }
+
+ $parent = $this->get_parent( $post->post_parent );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ if ( ! current_user_can( 'edit_post', $parent->ID ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to view flags for this pattern.', 'wporg-patterns' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return parent::get_item_permissions_check( $request );
+ }
+
+ /**
+ * Checks if a given request has access to create a post.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ *
+ * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise.
+ */
+ public function create_item_permissions_check( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ return new WP_Error(
+ 'rest_post_exists',
+ __( 'Cannot create existing post.', 'wporg-patterns' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! is_user_logged_in() ) {
+ return new WP_Error(
+ 'rest_authorization_required',
+ __( 'You must be logged in to submit a flag.', 'wporg-patterns' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $parent = $this->get_parent( $request['parent'] );
+ if ( is_wp_error( $parent ) ) {
+ return $parent;
+ }
+
+ if ( ! is_post_publicly_viewable( $parent ) ) {
+ return new WP_Error(
+ 'rest_invalid_post',
+ __( 'Flags cannot be submitted for this pattern.', 'wporg-patterns' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ // Check if the user has already submitted a flag for the pattern.
+ $flag_check = new WP_Query( array(
+ 'post_type' => $this->post_type,
+ 'post_parent' => $parent->ID,
+ 'post_status' => 'pending',
+ 'author' => get_current_user_id(),
+ ) );
+ if ( $flag_check->found_posts > 0 ) {
+ return new WP_Error(
+ 'rest_already_flagged',
+ __( 'You have already flagged this pattern.', 'wporg-patterns' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Prepares a single post for create or update.
+ *
+ * @param WP_REST_Request $request Request object.
+ *
+ * @return \stdClass|WP_Error Post object or WP_Error.
+ */
+ protected function prepare_item_for_database( $request ) {
+ $schema = $this->get_item_schema();
+
+ $prepared_post = parent::prepare_item_for_database( $request );
+
+ $prepared_post->post_author = get_current_user_id();
+
+ if ( ! isset( $request['status'] ) ) {
+ $prepared_post->post_status = $schema['properties']['status']['default'];
+ }
+
+ foreach ( $request['wporg-pattern-flag-reason'] as $term_id ) {
+ if ( ! term_exists( $term_id, FLAG_TAX ) ) {
+ return new WP_Error(
+ 'rest_invalid_term_id',
+ __( 'Invalid term ID.', 'wporg-patterns' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ return $prepared_post;
+ }
+
+ /**
+ * Retrieves the post's schema, conforming to JSON Schema.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ $schema = parent::get_item_schema();
+
+ $schema['properties']['status']['default'] = PENDING_STATUS;
+ $schema['properties']['status']['enum'] = array( PENDING_STATUS, RESOLVED_STATUS );
+
+ $schema['properties']['parent'] = array(
+ 'description' => __( 'The ID for the parent of the object.', 'wporg-patterns' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'required' => true,
+ );
+
+ $schema['properties']['wporg-pattern-flag-reason']['required'] = true;
+
+ return $schema;
+ }
+
+ /**
+ * Retrieves the query params for the posts collection.
+ *
+ * @return array Collection parameters.
+ */
+ public function get_collection_params() {
+ $query_params = parent::get_collection_params();
+
+ $query_params['status']['default'] = PENDING_STATUS;
+ $query_params['status']['items']['enum'] = array( PENDING_STATUS, RESOLVED_STATUS, 'any' );
+
+ $query_params['parent'] = array(
+ 'description' => __( 'Limit result set to items with particular parent IDs.', 'wporg-patterns' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+ $query_params['parent_exclude'] = array(
+ 'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'wporg-patterns' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ 'default' => array(),
+ );
+
+ return $query_params;
+ }
+
+ /**
+ * Get the parent post, if the ID is valid.
+ *
+ * @param int $parent Supplied ID.
+ *
+ * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise.
+ */
+ protected function get_parent( $parent ) {
+ $error = new WP_Error(
+ 'rest_post_invalid_parent',
+ __( 'Invalid post parent ID.', 'wporg-patterns' ),
+ array( 'status' => 404 )
+ );
+ if ( (int) $parent <= 0 ) {
+ return $error;
+ }
+
+ $parent = get_post( (int) $parent );
+ if ( empty( $parent ) || empty( $parent->ID ) || $this->parent_post_type !== $parent->post_type ) {
+ return $error;
+ }
+
+ return $parent;
+ }
+}
diff --git a/content/plugins/pattern-directory/includes/favorite.php b/content/plugins/pattern-directory/includes/favorite.php
new file mode 100644
index 0000000..d5faa6a
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/favorite.php
@@ -0,0 +1,165 @@
+exists() ) {
+ return false;
+ }
+
+ $users_favorites = get_favorites( $user );
+ $already_favorited = in_array( $post->ID, $users_favorites, true );
+ if ( $already_favorited ) {
+ return true;
+ }
+
+ $success = add_user_meta( $user->ID, META_KEY, $post->ID );
+ return (bool) $success;
+}
+
+/**
+ * Remove a pattern from a users's favorites list.
+ *
+ * @param int|WP_Post|null $post The block pattern to unfavorite.
+ * @param int|WP_User|null $user The user favoriting. Optional. Default current user.
+ * @return boolean
+ */
+function remove_favorite( $post, $user = 0 ) {
+ $post = get_block_pattern( $post );
+ $user = new \WP_User( $user ?: get_current_user_id() );
+ if ( ! $post || ! $user->exists() ) {
+ return false;
+ }
+
+ $users_favorites = get_favorites( $user );
+ $already_favorited = in_array( $post->ID, $users_favorites, true );
+ if ( ! $already_favorited ) {
+ return true;
+ }
+
+ return delete_user_meta( $user->ID, META_KEY, $post->ID );
+}
+
+/**
+ * Check if a pattern is in a user's favorites.
+ *
+ * @param int|WP_Post|null $post The block pattern to look up.
+ * @param int|WP_User|null $user The user to check. Optional. Default current user.
+ * @return boolean
+ */
+function is_favorite( $post, $user = 0 ) {
+ $post = get_block_pattern( $post );
+ if ( ! $post ) {
+ return false;
+ }
+
+ $users_favorites = get_favorites( $user );
+ return in_array( $post->ID, $users_favorites, true );
+}
+
+/**
+ * Get a list of the user's favorite patterns
+ *
+ * @param int|WP_User|null $user The user to check. Optional. Default current user.
+ * @return integer[]
+ */
+function get_favorites( $user = 0 ) {
+ $user = new \WP_User( $user ?: get_current_user_id() );
+ if ( ! $user->exists() ) {
+ return array();
+ }
+ $favorites = get_user_meta( $user->ID, META_KEY ) ?: array();
+
+ return array_map( 'absint', $favorites );
+}
+
+/**
+ * Get the cached count of how many times this pattern has been favorited.
+ *
+ * @param int|WP_Post $post The pattern to check.
+ * @return integer
+ */
+function get_favorite_count( $post = 0 ) {
+ $post = get_block_pattern( $post );
+ if ( ! $post ) {
+ return false;
+ }
+
+ return absint( get_post_meta( $post->ID, META_KEY, true ) );
+}
+
+/**
+ * Get a count of how many times this pattern has been favorited, directly from the users table.
+ *
+ * @param int|WP_Post $post The pattern to check.
+ * @return integer
+ */
+function get_raw_favorite_count( $post = 0 ) {
+ global $wpdb;
+ $post = get_block_pattern( $post );
+ if ( ! $post ) {
+ return false;
+ }
+ $count = $wpdb->get_var( $wpdb->prepare(
+ "SELECT COUNT(*)
+ FROM {$wpdb->usermeta}
+ WHERE meta_key=%s
+ AND meta_value=%d",
+ META_KEY,
+ $post->ID
+ ) );
+
+ return absint( $count );
+}
+
+/**
+ * Update a given post's favorite count cache.
+ *
+ * @param mixed $post_id The post ID.
+ */
+function update_favorite_cache( $post_id ) {
+ $count = get_raw_favorite_count( $post_id );
+ if ( ! is_int( $count ) ) {
+ return;
+ }
+
+ update_post_meta( $post_id, META_KEY, $count );
+}
+
+/**
+ * Trigger the update of favorite count when a user favorites or unfavorites a pattern.
+ *
+ * @param int $mid The meta ID.
+ * @param int $user_id User ID for this metadata.
+ * @param string $meta_key Metadata key.
+ * @param mixed $_meta_value Metadata value. Serialized if non-scalar. Post ID(s).
+ */
+function trigger_favorite_cache_update( $mid, $user_id, $meta_key, $_meta_value ) {
+ if ( META_KEY !== $meta_key ) {
+ return;
+ }
+
+ // This value can be an array in the delete action, so walk through each unique value and refresh the cache.
+ if ( is_array( $_meta_value ) ) {
+ $_meta_value = array_unique( $_meta_value );
+ array_walk( $_meta_value, __NAMESPACE__ . '\update_favorite_cache' );
+ return;
+ }
+
+ update_favorite_cache( $_meta_value );
+}
+add_action( 'added_user_meta', __NAMESPACE__ . '\trigger_favorite_cache_update', 10, 4 );
+add_action( 'deleted_user_meta', __NAMESPACE__ . '\trigger_favorite_cache_update', 10, 4 );
diff --git a/content/plugins/pattern-directory/includes/logging.php b/content/plugins/pattern-directory/includes/logging.php
new file mode 100644
index 0000000..6ea583e
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/logging.php
@@ -0,0 +1,101 @@
+post_parent;
+
+ if ( ! $pattern_id ) {
+ return;
+ }
+
+ $new = get_post_status_object( $new_status );
+ $user = get_user_by( 'id', $post->post_author );
+ $user_handle = sprintf(
+ '@%s',
+ $user->user_login
+ );
+
+ $msg = '';
+ if ( $new_status === $old_status ) {
+ return;
+ } elseif ( 'new' === $old_status && PENDING_STATUS === $new_status ) {
+ $msg = sprintf(
+ // translators: User name;
+ __( 'New flag submitted by %s', 'wporg-patterns' ),
+ esc_html( $user_handle ),
+ );
+ } elseif ( PENDING_STATUS === $new_status ) {
+ $msg = sprintf(
+ // translators: 1. User name; 2. Post status;
+ __( 'Flag submitted by %1$s set to %2$s', 'wporg-patterns' ),
+ esc_html( $user_handle ),
+ esc_html( $new->label )
+ );
+ } elseif ( RESOLVED_STATUS === $new_status ) {
+ $msg = sprintf(
+ // translators: 1. User name; 2. Post status;
+ __( 'Flag submitted by %1$s marked as %2$s', 'wporg-patterns' ),
+ esc_html( $user_handle ),
+ esc_html( $new->label )
+ );
+ } elseif ( 'trash' === $new_status ) {
+ $msg = sprintf(
+ // translators: User name;
+ __( 'Flag submitted by %s moved to trash.', 'wporg-patterns' ),
+ esc_html( $user_handle )
+ );
+ }
+
+ if ( $msg ) {
+ $data = array(
+ 'post_excerpt' => $msg,
+ 'post_type' => InternalNotes\LOG_POST_TYPE,
+ );
+
+ InternalNotes\create_note( $pattern_id, $data );
+ }
+}
diff --git a/content/plugins/pattern-directory/includes/notifications.php b/content/plugins/pattern-directory/includes/notifications.php
new file mode 100644
index 0000000..e61fac0
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/notifications.php
@@ -0,0 +1,260 @@
+post_status;
+ $old_status = $post_before->post_status;
+ if ( $new_status === $old_status ) {
+ return;
+ }
+
+ if ( 'publish' === $new_status && in_array( $old_status, array( 'pending', SPAM_STATUS, UNLISTED_STATUS ) ) ) {
+ notify_pattern_approved( $post );
+ } elseif ( SPAM_STATUS === $new_status ) {
+ notify_pattern_flagged( $post );
+ } elseif ( UNLISTED_STATUS === $new_status ) {
+ notify_pattern_unlisted( $post );
+ }
+}
+
+/**
+ * Notify when a pattern has been approved.
+ *
+ * @param \WP_Post $post
+ *
+ * @return void
+ */
+function notify_pattern_approved( $post ) {
+ $author = get_user_by( 'id', $post->post_author );
+ if ( ! $author ) {
+ return;
+ }
+
+ $email = $author->user_email;
+ $locale = get_user_locale( $author );
+
+ $pattern_title = get_the_title( $post );
+ $pattern_url = get_permalink( $post );
+
+ if ( $locale ) {
+ switch_to_locale( $locale );
+ }
+
+ $subject = esc_html__( 'Pattern published', 'wporg-patterns' );
+
+ $message = sprintf(
+ // translators: Plaintext email message. Note the line breaks. 1. Pattern title; 2. Pattern URL;
+ esc_html__( 'Hello!
+
+Thank you for submitting your pattern, %1$s. It is now live in the Block Pattern Directory!
+
+%2$s', 'wporg-patterns' ),
+ esc_html( $pattern_title ),
+ esc_url_raw( $pattern_url )
+ );
+
+ if ( $locale ) {
+ restore_current_locale();
+ }
+
+ send_email( $email, $subject, $message );
+}
+
+/**
+ * Notify when a pattern has been unpublished for review.
+ *
+ * This is called either when the status transitions into "spam", or when a post
+ * crosses the flag threshold.
+ *
+ * @param \WP_Post $post
+ *
+ * @return void
+ */
+function notify_pattern_flagged( $post ) {
+ $author = get_user_by( 'id', $post->post_author );
+ if ( ! $author ) {
+ return;
+ }
+
+ $email = $author->user_email;
+ $locale = get_user_locale( $author );
+
+ $pattern_title = get_the_title( $post );
+
+ if ( $locale ) {
+ switch_to_locale( $locale );
+ }
+
+ $reason = '';
+
+ if ( SPAM_STATUS === $post->post_status ) {
+ $spam_term = get_term_by( 'slug', '4-spam', REASON );
+ $reason = wp_strip_all_tags( $spam_term->description );
+ } else {
+ $flags = get_posts( array(
+ 'post_type' => FLAG,
+ 'post_parent' => $post->ID,
+ 'post_status' => PENDING_STATUS,
+ ) );
+ if ( ! empty( $flags ) ) {
+ $reasons = array();
+ foreach ( $flags as $flag ) {
+ $terms = get_the_terms( $flag, REASON );
+ if ( is_array( $terms ) ) {
+ $reasons = array_merge( $reasons, $terms );
+ }
+ }
+ $reasons = array_map(
+ function( \WP_Term $reason ) {
+ return wp_strip_all_tags( $reason->description );
+ },
+ $reasons
+ );
+ $reasons = array_unique( $reasons );
+ $reason = trim( implode( "\n", $reasons ) );
+ }
+ }
+
+ if ( ! $reason ) {
+ $reason = get_default_reason_description();
+ }
+
+ $subject = esc_html__( 'Pattern being reviewed', 'wporg-patterns' );
+
+ $message = sprintf(
+ // translators: Plaintext email message. Note the line breaks. 1. Pattern title; 2. Pattern URL;
+ esc_html__( 'Hi there!
+
+Thanks for submitting your pattern. Unfortunately, your pattern, %1$s, has been flagged for review due to the following reason(s):
+
+%2$s
+
+Your pattern has been unpublished from the Block Pattern Directory at this time, and will receive further review. If the pattern meets the guidelines, we will re-publish it to the Block Pattern Directory. Thanks for your patience with us volunteer reviewers!', 'wporg-patterns' ),
+ esc_html( $pattern_title ),
+ esc_html( $reason )
+ );
+
+ if ( $locale ) {
+ restore_current_locale();
+ }
+
+ send_email( $email, $subject, $message );
+}
+
+/**
+ * Notify when a pattern has been unlisted.
+ *
+ * @param \WP_Post $post
+ *
+ * @return void
+ */
+function notify_pattern_unlisted( $post ) {
+ $author = get_user_by( 'id', $post->post_author );
+ if ( ! $author ) {
+ return;
+ }
+
+ $email = $author->user_email;
+ $locale = get_user_locale( $author );
+
+ $pattern_title = get_the_title( $post );
+
+ if ( $locale ) {
+ switch_to_locale( $locale );
+ }
+
+ $reasons = get_the_terms( $post, REASON );
+ $reason = '';
+ if ( ! empty( $reasons ) ) {
+ $reason_term = reset( $reasons );
+ $reason = wp_strip_all_tags( $reason_term->description );
+ }
+
+ if ( ! $reason ) {
+ $reason = get_default_reason_description();
+ }
+
+ $subject = esc_html__( 'Pattern unlisted', 'wporg-patterns' );
+
+ $message = sprintf(
+ // translators: Plaintext email message. Note the line breaks. 1. Pattern title; 2. Pattern URL;
+ esc_html__( 'Hello,
+
+Your pattern, %1$s, has been unlisted from the Block Pattern Directory due to the following reason:
+
+%2$s
+
+If you would like to resubmit your pattern, please make sure it follows the guidelines:
+
+%3$s', 'wporg-patterns' ),
+ esc_html( $pattern_title ),
+ esc_html( $reason ),
+ 'https://wordpress.org/patterns/about/'
+ );
+
+ if ( $locale ) {
+ restore_current_locale();
+ }
+
+ send_email( $email, $subject, $message );
+}
+
+/**
+ * Wrapper for wp_mail.
+ *
+ * @param string $to
+ * @param string $subject
+ * @param string $message
+ *
+ * @return void
+ */
+function send_email( $to, $subject, $message ) {
+ $message = html_entity_decode( $message, ENT_QUOTES );
+
+ wp_mail(
+ $to,
+ $subject,
+ $message,
+ array(
+ 'From: WordPress Pattern Directory ',
+ 'Reply-To: ',
+ )
+ );
+}
diff --git a/content/plugins/pattern-directory/includes/pattern-flag-post-type.php b/content/plugins/pattern-directory/includes/pattern-flag-post-type.php
new file mode 100644
index 0000000..9760490
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/pattern-flag-post-type.php
@@ -0,0 +1,209 @@
+ __( 'Block Pattern Flags', 'wporg-patterns' ),
+ 'singular_name' => __( 'Block Pattern Flag', 'wporg-patterns' ),
+ 'add_new_item' => __( 'Add New Flag', 'wporg-patterns' ),
+ 'edit_item' => __( 'Edit Flag', 'wporg-patterns' ),
+ 'new_item' => __( 'New Flag', 'wporg-patterns' ),
+ 'view_item' => __( 'View Flag', 'wporg-patterns' ),
+ 'view_items' => __( 'View Flags', 'wporg-patterns' ),
+ 'search_items' => __( 'Search Flags', 'wporg-patterns' ),
+ 'not_found' => __( 'No flags found.', 'wporg-patterns' ),
+ 'not_found_in_trash' => __( 'No flags found in Trash.', 'wporg-patterns' ),
+ 'all_items' => __( 'All Flags', 'wporg-patterns' ),
+ 'insert_into_item' => __( 'Insert into flag', 'wporg-patterns' ),
+ 'filter_items_list' => __( 'Filter flags list', 'wporg-patterns' ),
+ 'items_list_navigation' => __( 'Flags list navigation', 'wporg-patterns' ),
+ 'items_list' => __( 'Flags list', 'wporg-patterns' ),
+ );
+
+ register_post_type(
+ POST_TYPE,
+ array(
+ 'labels' => $post_type_labels,
+ 'description' => 'Flags are added to patterns by users when the pattern needs to be reviewed by a moderator.',
+ 'show_ui' => true,
+ 'show_in_menu' => 'edit.php?post_type=wporg-pattern',
+ 'show_in_admin_bar' => false,
+ 'show_in_rest' => true,
+ 'rest_controller_class' => '\\WordPressdotorg\\Pattern_Directory\\REST_Flags_Controller',
+ 'supports' => array( 'author', 'excerpt' ),
+ 'can_export' => false,
+ 'delete_with_user' => false,
+ )
+ );
+
+ $taxonomy_labels = array(
+ 'name' => __( 'Flag Reasons', 'wporg-patterns' ),
+ 'singular_name' => __( 'Flag Reason', 'wporg-patterns' ),
+ 'search_items' => __( 'Search Reasons', 'wporg-patterns' ),
+ 'all_items' => __( 'All Reasons', 'wporg-patterns' ),
+ 'parent_item' => __( 'Parent Reason', 'wporg-patterns' ),
+ 'parent_item_colon' => __( 'Parent Reason:', 'wporg-patterns' ),
+ 'edit_item' => __( 'Edit Reason', 'wporg-patterns' ),
+ 'view_item' => __( 'View Reason', 'wporg-patterns' ),
+ 'update_item' => __( 'Update Reason', 'wporg-patterns' ),
+ 'add_new_item' => __( 'Add New Reason', 'wporg-patterns' ),
+ 'new_item_name' => __( 'New Reason', 'wporg-patterns' ),
+ 'separate_items_with_commas' => __( 'Separate reasons with commas', 'wporg-patterns' ),
+ 'add_or_remove_items' => __( 'Add or remove reasons', 'wporg-patterns' ),
+ 'not_found' => __( 'No reasons found.', 'wporg-patterns' ),
+ 'no_terms' => __( 'No reasons', 'wporg-patterns' ),
+ 'filter_by_item' => __( 'Filter by reason', 'wporg-patterns' ),
+ 'items_list_navigation' => __( 'Reasons list navigation', 'wporg-patterns' ),
+ 'items_list' => __( 'Reasons list', 'wporg-patterns' ),
+ 'back_to_items' => __( '← Go to Reasons', 'wporg-patterns' ),
+ );
+
+ register_taxonomy(
+ TAX_TYPE,
+ array( POST_TYPE, PATTERN ), // The taxonomy will also get applied to patterns when they get unlisted.
+ array(
+ 'labels' => $taxonomy_labels,
+ 'description' => 'Flag reason indicates why a flag was added to a pattern.',
+ 'public' => false,
+ 'hierarchical' => true,
+ 'show_ui' => true,
+ 'show_in_menu' => 'edit.php?post_type=' . PATTERN,
+ 'show_in_rest' => true,
+ 'show_tagcloud' => false,
+ 'show_in_quick_edit' => false,
+ 'show_admin_column' => true,
+ )
+ );
+
+ register_post_status(
+ RESOLVED_STATUS,
+ array(
+ 'label' => __( 'Resolved', 'wporg-patterns' ),
+ 'label_count' => _n_noop(
+ 'Resolved (%s)',
+ 'Resolved (%s)',
+ 'wporg-patterns'
+ ),
+ 'protected' => true,
+ )
+ );
+}
+
+/**
+ * If a pattern or flag doesn't have a reason term added, but needs to show a reason description.
+ *
+ * @return string
+ */
+function get_default_reason_description() {
+ return __( "This pattern doesn't meet the guidelines for the pattern directory.", 'wporg-patterns' );
+}
+
+/**
+ * Automatically unpublish a pattern if it receives a certain number of flags.
+ *
+ * @param int $post_ID
+ * @param WP_Post $post
+ * @param bool $update
+ *
+ * @return void
+ */
+function check_flag_threshold( $post_ID, $post, $update ) {
+ if ( $update || POST_TYPE !== get_post_type( $post ) ) {
+ return;
+ }
+
+ $pattern = get_post( $post->post_parent );
+ if ( ! $pattern ) {
+ return;
+ }
+
+ $flag_check = new WP_Query( array(
+ 'post_type' => POST_TYPE,
+ 'post_parent' => $pattern->ID,
+ 'post_status' => PENDING_STATUS,
+ ) );
+
+ $threshold = absint( get_option( 'wporg-pattern-flag_threshold', 5 ) );
+
+ if ( $flag_check->found_posts >= $threshold ) {
+ wp_update_post( array(
+ 'ID' => $pattern->ID,
+ 'post_status' => PENDING_STATUS,
+ ) );
+
+ /**
+ * Fires after a pattern is automatically unlisted.
+ *
+ * @param WP_Post $pattern The just-unlisted pattern.
+ */
+ do_action( 'wporg_unlist_pattern', $pattern );
+ }
+}
+
+/**
+ * Get a list of post IDs for patterns that have pending flags.
+ *
+ * TODO this isn't used anywhere on the front end, but maybe it should be cached?
+ *
+ * @param array $args Optional. Query args. 'orderby' and/or 'order'.
+ *
+ * @return int[]
+ */
+function get_pattern_ids_with_pending_flags( $args = array() ) {
+ global $wpdb;
+
+ $args = wp_parse_args(
+ $args,
+ array(
+ 'orderby' => 'date',
+ 'order' => 'desc',
+ )
+ );
+
+ // For string interpolation.
+ $pattern = PATTERN;
+ $flag = POST_TYPE;
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $pattern_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "
+ SELECT DISTINCT patterns.ID
+ FROM {$wpdb->posts} patterns
+ JOIN {$wpdb->posts} flags ON patterns.ID = flags.post_parent
+ AND flags.post_type = '{$flag}'
+ AND flags.post_status = 'pending'
+ WHERE patterns.post_type = '{$pattern}'
+ ORDER BY %s %s
+ ",
+ $args['orderby'],
+ $args['order']
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ return $pattern_ids;
+}
diff --git a/content/plugins/pattern-directory/includes/pattern-post-type.php b/content/plugins/pattern-directory/includes/pattern-post-type.php
new file mode 100644
index 0000000..f47b79b
--- /dev/null
+++ b/content/plugins/pattern-directory/includes/pattern-post-type.php
@@ -0,0 +1,1046 @@
+ array(
+ 'name' => _x( 'Block Pattern', 'post type general name', 'wporg-patterns' ),
+ 'singular_name' => _x( 'Block Pattern', 'post type singular name', 'wporg-patterns' ),
+ 'add_new' => _x( 'Add New', 'block pattern', 'wporg-patterns' ),
+ 'add_new_item' => __( 'Add New Pattern', 'wporg-patterns' ),
+ 'edit_item' => __( 'Edit Pattern', 'wporg-patterns' ),
+ 'new_item' => __( 'New Pattern', 'wporg-patterns' ),
+ 'view_item' => __( 'View Pattern', 'wporg-patterns' ),
+ 'view_items' => __( 'View Patterns', 'wporg-patterns' ),
+ 'search_items' => __( 'Search Patterns', 'wporg-patterns' ),
+ 'not_found' => __( 'No patterns found.', 'wporg-patterns' ),
+ 'not_found_in_trash' => __( 'No patterns found in Trash.', 'wporg-patterns' ),
+ 'all_items' => __( 'All Block Patterns', 'wporg-patterns' ),
+ 'archives' => __( 'Pattern Archives', 'wporg-patterns' ),
+ 'attributes' => __( 'Pattern Attributes', 'wporg-patterns' ),
+ 'insert_into_item' => __( 'Insert into block pattern', 'wporg-patterns' ),
+ 'uploaded_to_this_item' => __( 'Uploaded to this block pattern', 'wporg-patterns' ),
+ 'filter_items_list' => __( 'Filter patterns list', 'wporg-patterns' ),
+ 'items_list_navigation' => __( 'Block patterns list navigation', 'wporg-patterns' ),
+ 'items_list' => __( 'Block patterns list', 'wporg-patterns' ),
+ 'item_published' => __( 'Block pattern published.', 'wporg-patterns' ),
+ 'item_published_privately' => __( 'Block pattern published privately.', 'wporg-patterns' ),
+ 'item_reverted_to_draft' => __( 'Block pattern reverted to draft.', 'wporg-patterns' ),
+ 'item_scheduled' => __( 'Block pattern scheduled.', 'wporg-patterns' ),
+ 'item_updated' => __( 'Block pattern updated.', 'wporg-patterns' ),
+ ),
+ 'description' => 'Stores publicly shared Block Patterns (predefined block layouts, ready to insert and tweak).',
+ 'public' => true,
+ 'show_in_rest' => true,
+ 'rewrite' => array( 'slug' => 'pattern' ),
+ 'supports' => array( 'title', 'editor', 'author', 'custom-fields', 'revisions', 'wporg-internal-notes', 'wporg-log-notes' ),
+ 'capability_type' => array( 'pattern', 'patterns' ),
+ 'map_meta_cap' => true,
+ )
+ );
+
+ register_taxonomy(
+ 'wporg-pattern-category',
+ POST_TYPE,
+ array(
+ 'public' => true,
+ 'hierarchical' => true,
+ 'show_in_rest' => true,
+ 'rest_base' => 'pattern-categories',
+ 'show_admin_column' => true,
+ 'rewrite' => array(
+ 'slug' => 'categories',
+ ),
+ 'query_var' => 'pattern-categories',
+ 'capabilities' => array(
+ 'assign_terms' => 'edit_patterns',
+ 'edit_terms' => 'edit_patterns',
+ ),
+ )
+ );
+
+ register_taxonomy(
+ 'wporg-pattern-keyword',
+ POST_TYPE,
+ array(
+ 'public' => true,
+ 'hierarchical' => false,
+ 'show_in_rest' => true,
+ 'rest_base' => 'pattern-keywords',
+ 'show_admin_column' => true,
+ 'rewrite' => array(
+ 'slug' => 'pattern-keywords',
+ ),
+ 'capabilities' => array(
+ 'assign_terms' => 'edit_patterns',
+ 'edit_terms' => 'edit_patterns',
+ ),
+
+ 'labels' => array(
+ 'name' => _x( 'Keywords (Internal)', 'taxonomy general name', 'wporg-patterns' ),
+ 'singular_name' => _x( 'Keyword', 'taxonomy singular name', 'wporg-patterns' ),
+ 'search_items' => __( 'Search Keywords', 'wporg-patterns' ),
+ 'popular_items' => __( 'Popular Keywords', 'wporg-patterns' ),
+ 'all_items' => __( 'All Keywords', 'wporg-patterns' ),
+ 'edit_item' => __( 'Edit Keyword', 'wporg-patterns' ),
+ 'view_item' => __( 'View Keyword', 'wporg-patterns' ),
+ 'update_item' => __( 'Update Keyword', 'wporg-patterns' ),
+ 'add_new_item' => __( 'Add New Keyword', 'wporg-patterns' ),
+ 'new_item_name' => __( 'New Keyword Name', 'wporg-patterns' ),
+ 'separate_items_with_commas' => __( 'Separate keywords with commas', 'wporg-patterns' ),
+ 'add_or_remove_items' => __( 'Add or remove keywords', 'wporg-patterns' ),
+ 'choose_from_most_used' => __( 'Choose from the most used keywords', 'wporg-patterns' ),
+ 'not_found' => __( 'No keywords found.', 'wporg-patterns' ),
+ 'no_terms' => __( 'No keywords', 'wporg-patterns' ),
+ 'items_list_navigation' => __( 'Keywords list navigation', 'wporg-patterns' ),
+ 'items_list' => __( 'Keywords list', 'wporg-patterns' ),
+ /* translators: Tab heading when selecting from the most used terms. */
+ 'most_used' => _x( 'Most Used', 'keywords', 'wporg-patterns' ),
+ 'back_to_items' => __( '← Go to Keywords', 'wporg-patterns' ),
+ ),
+ )
+ );
+
+ register_post_meta(
+ POST_TYPE,
+ 'wpop_keywords',
+ array(
+ 'type' => 'string',
+ 'description' => 'A comma-separated list of keywords for this pattern',
+ 'single' => true,
+ 'default' => '',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'auth_callback' => __NAMESPACE__ . '\can_edit_this_pattern',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'string',
+ 'maxLength' => 360,
+ ),
+ ),
+ )
+ );
+
+ register_post_meta(
+ POST_TYPE,
+ 'wpop_description',
+ array(
+ 'type' => 'string',
+ 'description' => 'A description of the pattern',
+ 'single' => true,
+ 'default' => '',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'auth_callback' => __NAMESPACE__ . '\can_edit_this_pattern',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'maxLength' => 360,
+ 'required' => true,
+ ),
+ ),
+ )
+ );
+
+ register_post_meta(
+ POST_TYPE,
+ 'wpop_viewport_width',
+ array(
+ 'type' => 'number',
+ 'description' => 'The width of the pattern in the block inserter.',
+ 'single' => true,
+ 'default' => 1200,
+ 'sanitize_callback' => 'absint',
+ 'auth_callback' => __NAMESPACE__ . '\can_edit_this_pattern',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'minimum' => 200,
+ 'maximum' => 2000,
+ ),
+ ),
+ )
+ );
+
+ register_post_meta(
+ POST_TYPE,
+ 'wpop_block_types',
+ array(
+ 'type' => 'string',
+ 'description' => 'A list of block types this pattern supports for transforms.',
+ 'single' => false,
+ 'sanitize_callback' => function( $value, $key, $type ) {
+ return preg_replace( '/[^a-z0-9-\/]/', '', $value );
+ },
+ 'auth_callback' => __NAMESPACE__ . '\can_edit_this_pattern',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'string',
+ ),
+ ),
+ )
+ );
+
+ register_post_meta(
+ POST_TYPE,
+ 'wpop_locale',
+ array(
+ 'type' => 'string',
+ 'description' => 'The language used when creating this pattern.',
+ 'single' => true,
+ 'sanitize_callback' => function( $value ) {
+ if ( ! in_array( $value, array_keys( get_locales() ), true ) ) {
+ return 'en_US';
+ }
+
+ return $value;
+ },
+ 'auth_callback' => __NAMESPACE__ . '\can_edit_this_pattern',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( get_locales() ),
+ 'required' => true,
+ 'default' => 'en_US',
+ ),
+ ),
+ )
+ );
+
+ register_post_meta(
+ POST_TYPE,
+ 'wpop_wp_version',
+ array(
+ 'type' => 'string',
+ 'description' => 'The earliest WordPress version compatible with this pattern.',
+ 'single' => true,
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'auth_callback' => __NAMESPACE__ . '\can_edit_this_pattern',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'string',
+ ),
+ ),
+ )
+ );
+
+ register_post_meta(
+ POST_TYPE,
+ 'wpop_contains_block_types',
+ array(
+ 'type' => 'string',
+ 'description' => 'A list of block types used in this pattern',
+ 'single' => true,
+ 'default' => '',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'auth_callback' => __NAMESPACE__ . '\can_edit_this_pattern',
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'string',
+ ),
+ ),
+ )
+ );
+}
+
+/**
+ * Adds extra fields to REST API responses.
+ */
+function register_rest_fields() {
+ /*
+ * Provide category and keyword slugs without embedding.
+ *
+ * Normally API clients would request these via `_embed` parameters, but that would returning the entire
+ * object, and Core only needs the slugs. We'd also have to include the `_links` field, because of a Core bug.
+ *
+ * @see https://core.trac.wordpress.org/ticket/49538
+ * @see https://core.trac.wordpress.org/ticket/49985
+ *
+ * Adding it here is faster for the server to generate, and for the client to download. It also makes the
+ * output easier for a human to visually parse.
+ */
+ register_rest_field(
+ POST_TYPE,
+ 'category_slugs',
+ array(
+ 'get_callback' => function() {
+ $slugs = wp_list_pluck( wp_get_object_terms( get_the_ID(), 'wporg-pattern-category' ), 'slug' );
+ $slugs = array_map( 'sanitize_title', $slugs );
+ $slugs = array_diff( $slugs, [ 'featured' ] );
+ $slugs = array_values( $slugs );
+
+ return $slugs;
+ },
+
+ 'schema' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ )
+ );
+
+ // See `category_slugs` registration for details.
+ register_rest_field(
+ POST_TYPE,
+ 'keyword_slugs',
+ array(
+ 'get_callback' => function() {
+ $slugs = wp_list_pluck( wp_get_object_terms( get_the_ID(), 'wporg-pattern-keyword' ), 'slug' );
+
+ return array_map( 'sanitize_title', $slugs );
+ },
+
+ 'schema' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ )
+ );
+
+ /*
+ * Provide the raw content without requiring the `edit` context.
+ *
+ * We need the raw content because it contains the source code for blocks (the comment delimiters). The rendered
+ * content is considered a "classic block", since it lacks these. The `edit` context would return both raw and
+ * rendered, but it requires more permissions and potentially exposes more content than we need.
+ */
+ register_rest_field(
+ POST_TYPE,
+ 'pattern_content',
+ array(
+ 'get_callback' => function( $response_data ) {
+ $pattern = get_post( $response_data['id'] );
+ return decode_pattern_content( $pattern->post_content );
+ },
+
+ 'schema' => array(
+ 'type' => 'string',
+ ),
+ )
+ );
+
+ /*
+ * Get the author's avatar.
+ */
+ register_rest_field(
+ POST_TYPE,
+ 'favorite_count',
+ array(
+ 'get_callback' => function() {
+ return get_favorite_count( get_the_ID() );
+ },
+
+ 'schema' => array(
+ 'type' => 'integer',
+ 'default' => 0,
+ ),
+ )
+ );
+
+ /*
+ * Get the author's avatar.
+ */
+ register_rest_field(
+ POST_TYPE,
+ 'author_meta',
+ array(
+ 'get_callback' => function( $post ) {
+ return array(
+ 'name' => esc_html( get_the_author_meta( 'display_name', $post['author'] ) ),
+ 'url' => esc_url( home_url( '/author/' . get_the_author_meta( 'user_nicename', $post['author'] ) ) ),
+ 'avatar' => get_avatar_url( $post['author'], array( 'size' => 64 ) ),
+ );
+ },
+
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'type' => 'string',
+ ),
+ 'url' => array(
+ 'type' => 'string',
+ ),
+ 'avatar' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ )
+ );
+
+ // Add the parent pattern (English original) to the endpoint.
+ // We only need to set the schema. `WP_REST_Posts_Controller` will output the parent ID if the
+ // schema contains the parent property. It also checks that the ID referenced is a valid post.
+ register_rest_field(
+ POST_TYPE,
+ 'parent',
+ array(
+ 'schema' => array(
+ 'description' => __( 'The ID for the original English pattern.', 'wporg-patterns' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ ),
+ )
+ );
+
+ register_rest_field(
+ POST_TYPE,
+ 'unlisted_reason',
+ array(
+ 'get_callback' => function() {
+ $reasons = wp_get_object_terms( get_the_ID(), FLAG_REASON );
+ if ( count( $reasons ) > 0 ) {
+ $reason = array_shift( $reasons );
+ return array(
+ 'term_id' => absint( $reason->term_id ),
+ 'name' => esc_attr( $reason->name ),
+ 'slug' => esc_attr( $reason->slug ),
+ 'description' => wp_kses_post( $reason->description ),
+ );
+ }
+
+ return array();
+ },
+ 'schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'term_id' => array(
+ 'type' => 'number',
+ ),
+ 'name' => array(
+ 'type' => 'string',
+ ),
+ 'slug' => array(
+ 'type' => 'string',
+ ),
+ 'description' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ )
+ );
+}
+
+/**
+ * Register custom statuses for patterns.
+ *
+ * @return void
+ */
+function register_post_statuses() {
+ register_post_status(
+ UNLISTED_STATUS,
+ array(
+ 'label' => _x( 'Unlisted', 'post status', 'wporg-patterns' ),
+ 'label_count' => _nx_noop(
+ 'Unlisted (%s)',
+ 'Unlisted (%s)',
+ 'post status',
+ 'wporg-patterns'
+ ),
+ 'public' => false,
+ 'protected' => true,
+ 'show_in_admin_all_list' => true,
+ )
+ );
+
+ register_post_status(
+ SPAM_STATUS,
+ array(
+ 'label' => _x( 'Possible Spam', 'post status', 'wporg-patterns' ),
+ 'label_count' => _nx_noop(
+ 'Possible Spam (%s)',
+ 'Possible Spam (%s)',
+ 'post status',
+ 'wporg-patterns'
+ ),
+ 'public' => false,
+ 'protected' => true,
+ 'show_in_admin_all_list' => true,
+ )
+ );
+}
+
+/**
+ * Do things when certain status transitions happen.
+ *
+ * @param string $new_status
+ * @param string $old_status
+ * @param \WP_Post $post
+ *
+ * @return void
+ */
+function status_transitions( $new_status, $old_status, $post ) {
+ if ( POST_TYPE !== get_post_type( $post ) ) {
+ return;
+ }
+
+ // If a pattern gets relisted, remove the reason that it was originally unlisted.
+ if ( UNLISTED_STATUS === $old_status && UNLISTED_STATUS !== $new_status ) {
+ wp_delete_object_term_relationships( $post->ID, array( FLAG_REASON ) );
+ }
+}
+
+/**
+ * Given a post ID, parse out the block types and update the `wpop_contains_block_types` meta field.
+ *
+ * @param int $pattern_id Pattern ID.
+ */
+function update_contains_block_types_meta( $pattern_id ) {
+ $pattern = get_post( $pattern_id );
+ $blocks = parse_blocks( $pattern->post_content );
+ $all_blocks = _flatten_blocks( $blocks );
+
+ // Get the list of block names and convert it to a single string.
+ $block_names = wp_list_pluck( $all_blocks, 'blockName' );
+ $block_names = array_filter( $block_names ); // Filter out null values (extra line breaks).
+ $block_names = array_unique( $block_names );
+ sort( $block_names );
+ $used_blocks = implode( ',', $block_names );
+
+ update_post_meta( $pattern_id, 'wpop_contains_block_types', $used_blocks );
+}
+
+/**
+ * Determines if the current user can edit the given pattern post.
+ *
+ * This is a callback for the `auth_{$object_type}_meta_{$meta_key}` filter, and it's used to authorize access to
+ * modifying post meta keys via the REST API.
+ *
+ * @param bool $allowed
+ * @param string $meta_key
+ * @param int $pattern_id
+ *
+ * @return bool
+ */
+function can_edit_this_pattern( $allowed, $meta_key, $pattern_id ) {
+ return current_user_can( 'edit_post', $pattern_id );
+}
+
+/**
+ * Enqueue scripts for the block editor.
+ *
+ * @throws Error If the build files don't exist.
+ */
+function enqueue_editor_assets() {
+ if ( function_exists( 'get_current_screen' ) && POST_TYPE !== get_current_screen()->id ) {
+ return;
+ }
+
+ $dir = dirname( dirname( __FILE__ ) );
+
+ $script_asset_path = "$dir/build/pattern-post-type.asset.php";
+ if ( ! file_exists( $script_asset_path ) ) {
+ throw new Error( 'You need to run `npm run start:directory` or `npm run build:directory` for the Pattern Directory.' );
+ }
+
+ $script_asset = require $script_asset_path;
+ wp_enqueue_script(
+ 'wporg-pattern-post-type',
+ plugins_url( 'build/pattern-post-type.js', dirname( __FILE__ ) ),
+ $script_asset['dependencies'],
+ $script_asset['version'],
+ true
+ );
+
+ wp_set_script_translations( 'wporg-pattern-post-type', 'wporg-patterns' );
+
+ $locales = ( is_admin() ) ? get_locales_with_english_names() : get_locales_with_native_names();
+
+ wp_add_inline_script(
+ 'wporg-pattern-post-type',
+ 'var wporgLocaleData = ' . wp_json_encode( $locales ) . ';',
+ 'before'
+ );
+
+ wp_enqueue_style(
+ 'wporg-pattern-post-type',
+ plugins_url( 'build/pattern-post-type.css', dirname( __FILE__ ) ),
+ array(),
+ $script_asset['version'],
+ );
+}
+
+/**
+ * Restrict the set of blocks allowed in block patterns.
+ *
+ * @param bool|array $allowed_block_types Array of block type slugs, or boolean to enable/disable all.
+ * @param WP_Block_Editor_Context $block_editor_context The post resource data.
+ *
+ * @return bool|array A (possibly) filtered list of block types.
+ */
+function remove_disallowed_blocks( $allowed_block_types, $block_editor_context ) {
+ $disallowed_block_types = array(
+ // Remove blocks that don't make sense in Block Patterns
+ 'core/freeform', // Classic block
+ 'core/legacy-widget',
+ 'core/more',
+ 'core/nextpage',
+ 'core/block', // Reusable blocks
+ 'core/shortcode',
+ 'core/template-part',
+ );
+
+ if ( isset( $block_editor_context->post ) && POST_TYPE === $block_editor_context->post->post_type ) {
+ // This can be true if all block types are allowed, so to filter them we
+ // need to get the list of all registered blocks first.
+ if ( true === $allowed_block_types ) {
+ $allowed_block_types = array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() );
+ }
+ $allowed_block_types = array_diff( $allowed_block_types, $disallowed_block_types );
+
+ // Remove the "WordPress.org" blocks, like Global Header & Global Footer.
+ $allowed_block_types = array_filter(
+ $allowed_block_types,
+ function ( $block_type ) {
+ return 'wporg/' !== substr( $block_type, 0, 6 );
+ }
+ );
+ }
+
+ return is_array( $allowed_block_types ) ? array_values( $allowed_block_types ) : $allowed_block_types;
+}
+
+/**
+ * Disable the block directory in wp-admin for patterns.
+ *
+ * The block directory file isn't loaded on the frontend, so this is only needed for site admins who can open
+ * the pattern in the "real" wp-admin editor.
+ */
+function disable_block_directory() {
+ if ( is_admin() && POST_TYPE === get_post_type() ) {
+ remove_action( 'enqueue_block_editor_assets', 'wp_enqueue_editor_block_directory_assets' );
+ remove_action( 'enqueue_block_editor_assets', 'gutenberg_enqueue_block_editor_assets_block_directory' );
+ }
+}
+
+/**
+ * Filter the collection parameters:
+ * - set a new default for per_page.
+ * - add a new parameter, `author_name`, for a user's nicename slug.
+ * - add a new parameter, `curation`, to filter between curated, community, and all patterns.
+ *
+ * @param array $query_params JSON Schema-formatted collection parameters.
+ * @return array Filtered parameters.
+ */
+function filter_patterns_collection_params( $query_params ) {
+ if ( isset( $query_params['per_page'] ) ) {
+ // Number of patterns per page, should be multiple of 2 and 3 (for 2- and 3-column layouts).
+ $query_params['per_page']['default'] = 18;
+ }
+
+ $query_params['author_name'] = array(
+ 'description' => __( 'Limit result set to patterns by a single author.', 'wporg-patterns' ),
+ 'type' => 'string',
+ 'validate_callback' => function( $value ) {
+ $user = get_user_by( 'slug', $value );
+ return (bool) $user;
+ },
+ );
+
+ $query_params['curation'] = array(
+ 'description' => __( 'Limit result to either curated core, community, or all patterns.', 'wporg-patterns' ),
+ 'type' => 'string',
+ 'default' => 'all',
+ 'enum' => array(
+ 'all',
+ 'core',
+ 'community',
+ ),
+ );
+
+ if ( isset( $query_params['orderby'] ) ) {
+ $query_params['orderby']['enum'][] = 'favorite_count';
+ }
+
+ $query_params['wp-version'] = array(
+ 'description' => __( 'The version of the requesting site, used to filter out newer patterns.', 'wporg-patterns' ),
+ 'type' => 'string',
+ );
+
+ $query_params['allowed_blocks'] = array(
+ 'description' => __( 'Filter the request to only return patterns with blocks on this list.', 'wporg-patterns' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ );
+
+ return $query_params;
+}
+
+/**
+ * Filter the arguments passed to the pattern query in the API.
+ *
+ * @param array $args Array of arguments to be passed to WP_Query.
+ * @param WP_REST_Request $request The REST API request.
+ */
+function filter_patterns_rest_query( $args, $request ) {
+ $locale = $request->get_param( 'locale' );
+
+ // Prioritise results in the requested locale.
+ // Does not limit to only the requested locale, so as to provide results when no translations
+ // exist for the locale, or we do not recognise the locale.
+ if ( $locale && is_string( $locale ) ) {
+ $args['meta_query']['orderby_locale'] = array(
+ 'key' => 'wpop_locale',
+ 'compare' => 'IN',
+ // Order in value determines result order
+ 'value' => array( $locale, 'en_US' ),
+ );
+ }
+
+ // Use the `author_name` passed in to the API to request patterns by an author slug, not just an ID.
+ if ( isset( $request['author_name'] ) ) {
+ $user = get_user_by( 'slug', $request['author_name'] );
+ if ( $user ) {
+ $args['author'] = $user->ID;
+ } else {
+ $args['post__in'] = array( -1 );
+ }
+ }
+
+ // If `curation` is passed and either `core` or `community`, we should
+ // filter the result. If `curation=all`, no filtering is needed.
+ if ( isset( $request['curation'] ) ) {
+ if ( 'core' === $request['curation'] ) {
+ // Patterns with the core keyword.
+ $args['tax_query']['core_keyword'] = array(
+ 'taxonomy' => 'wporg-pattern-keyword',
+ 'field' => 'slug',
+ 'terms' => 'core',
+ 'operator' => 'IN',
+ );
+ } else if ( 'community' === $request['curation'] ) {
+ // Patterns without the core keyword.
+ $args['tax_query']['core_keyword'] = array(
+ 'taxonomy' => 'wporg-pattern-keyword',
+ 'field' => 'slug',
+ 'terms' => 'core',
+ 'operator' => 'NOT IN',
+ );
+ }
+ }
+
+ $orderby = $request->get_param( 'orderby' );
+ if ( 'favorite_count' === $orderby ) {
+ $args['orderby'] = 'meta_value_num';
+ $args['meta_key'] = 'wporg-pattern-favorites';
+ }
+
+ // Use the passed-in version information to skip over any patterns that
+ // require newer block features.
+ // See https://github.com/WordPress/gutenberg/issues/45179.
+ $version = $request->get_param( 'wp-version' );
+ if ( $version && preg_match( '/^\d+\.\d+/', $version, $matches ) ) {
+ // $version is the full WP version, for example `6.0.2` or `6.2-alpha-54642-src`.
+ // Parse out just the major version section, `6.0` or `6.2`, respectively,
+ // so that the math comparison works.
+ $major_version = $matches[0];
+ $args['meta_query']['version'] = array(
+ // Fetch patterns with no version info, or only those with a lower
+ // or equal version.
+ 'relation' => 'OR',
+ array(
+ 'key' => 'wpop_wp_version',
+ 'compare' => '<=',
+ 'value' => $major_version,
+ ),
+ array(
+ 'key' => 'wpop_wp_version',
+ 'compare' => 'NOT EXISTS',
+ ),
+ );
+ }
+
+ $allowed_blocks = $request->get_param( 'allowed_blocks' );
+ if ( $allowed_blocks ) {
+ // Only return a pattern if all contained blocks are in the allowed blocks list.
+ $args['meta_query']['allowed_blocks'] = array(
+ 'key' => 'wpop_contains_block_types',
+ 'compare' => 'REGEXP',
+ 'value' => '^((' . implode( '|', $allowed_blocks ) . '),?)+$',
+ );
+ }
+
+ return $args;
+}
+
+/**
+ * Filters the WP_Query orderby to prioritse the locale when required.
+ *
+ * @param string $orderby The SQL orderby clause.
+ * @param \WP_Query $query The WP_Query object.
+ * @return string The SQL orderby clause altered to prioritise locales if required.
+ */
+function filter_orderby_locale( $orderby, $query ) {
+ global $wpdb;
+
+ // If this query has the orderby_locale meta_query, sort by it.
+ if ( ! empty( $query->meta_query->queries['orderby_locale']['value'] ) ) {
+ $values = array_reverse( $query->meta_query->queries['orderby_locale']['value'] );
+ $table_alias = $query->meta_query->get_clauses()['orderby_locale']['alias'];
+
+ $field_placeholders = implode( ', ', array_pad( array(), count( $values ), '%s' ) );
+ $locale_orderby = $wpdb->prepare( "FIELD( {$table_alias}.meta_value, {$field_placeholders} ) DESC", $values ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ // Order by matching the locale first, and then the queries order.
+ $orderby = "{$locale_orderby}, {$orderby}";
+ }
+
+ return $orderby;
+}
+
+/**
+ * Get the post object of a block pattern, or false if it's not a pattern or not found.
+ *
+ * @param int|WP_Post $post
+ *
+ * @return WP_Post|false
+ */
+function get_block_pattern( $post ) {
+ $pattern = get_post( $post );
+ if ( ! $pattern || POST_TYPE !== $pattern->post_type ) {
+ return false;
+ }
+ return $pattern;
+}
+
+/**
+ * Give all logged in users caps for creating patterns and related taxonomies.
+ *
+ * This allows any user in the wp.org network to have these capabilities, without having to have an actual
+ * role on the pattern directory site. These caps are only given on the front end, though, because in WP Admin
+ * these same caps could allow unintended access.
+ *
+ * @param array $user_caps A list of primitive caps (keys) and whether user has them (boolean values).
+ *
+ * @return array
+ */
+function set_pattern_caps( $user_caps ) {
+ // Set corresponding caps for all roles.
+ $cap_args = array(
+ 'capability_type' => array( 'pattern', 'patterns' ),
+ 'capabilities' => array(),
+ 'map_meta_cap' => true,
+ );
+ $cap_map = (array) get_post_type_capabilities( (object) $cap_args );
+
+ // Users should have the same permissions for patterns as posts, for example,
+ // if they have `edit_posts`, they should be granted `edit_patterns`, and so on.
+ foreach ( $user_caps as $cap => $bool ) {
+ if ( $bool && isset( $cap_map[ $cap ] ) ) {
+ $user_caps[ $cap_map[ $cap ] ] = true;
+ }
+ }
+
+ // Set caps to allow for front end pattern creation.
+ if ( is_user_logged_in() && ! is_admin() ) {
+ $user_caps['read'] = true;
+ $user_caps['publish_patterns'] = true;
+ $user_caps['edit_patterns'] = true;
+ $user_caps['edit_published_patterns'] = true;
+ $user_caps['delete_patterns'] = true;
+ $user_caps['delete_published_patterns'] = true;
+ // Note that `edit_others_patterns` & `delete_others_patterns` are separate capabilities.
+ }
+
+ return $user_caps;
+}
+
+/**
+ * Set up the `view` endpoint.
+ *
+ * Technically this applies to posts too, but this is easier than a custom EP mask.
+ */
+function add_preview_endpoint() {
+ add_rewrite_endpoint( 'view', EP_PERMALINK );
+}
+
+/**
+ * When viewing a `view` page, set up the preview theme.
+ *
+ * This should switch the theme to twentytwentyone, with a white background,
+ * and inject the image placeholder workaround.
+ */
+function setup_preview_theme() {
+ // query_vars are not set yet, so just check the URL.
+ $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
+
+ // Match pretty & non-pretty permalinks for unpublished patterns.
+ if ( preg_match( '#/view/$#', $request_uri ) || preg_match( '#[?&]view=[1|true]#', $request_uri ) ) {
+ add_filter( 'show_admin_bar', '__return_false', 2000 );
+
+ add_filter( 'template', function() {
+ if ( 'local' === wp_get_environment_type() ) {
+ return 'twentytwentythree';
+ } else {
+ return 'core/twentytwentythree';
+ }
+ } );
+
+ add_filter( 'stylesheet', function() {
+ if ( 'local' === wp_get_environment_type() ) {
+ return 'twentytwentythree';
+ } else {
+ return 'core/twentytwentythree';
+ }
+ } );
+
+ add_filter( 'wp_enqueue_scripts', function() {
+ wp_deregister_style( 'wp4-styles' );
+ wp_deregister_style( 'wporg-global-header-footer' );
+ }, 201 );
+
+ add_filter( 'render_block_core/gallery', __NAMESPACE__ . '\inject_placeholder_svg', 10, 2 );
+ add_filter( 'render_block_core/image', __NAMESPACE__ . '\inject_placeholder_svg', 10, 2 );
+ add_filter( 'render_block_core/media-text', __NAMESPACE__ . '\inject_placeholder_svg', 10, 2 );
+ add_filter( 'render_block_core/video', __NAMESPACE__ . '\inject_placeholder_svg', 10, 2 );
+ add_filter( 'render_block_core/site-logo', __NAMESPACE__ . '\inject_placeholder_svg', 10, 2 );
+ }
+}
+
+/**
+ * Inject the placehodler SVG if we find an empty media block.
+ *
+ * @param string $block_content The block content.
+ * @param array $block The full block, including name and attributes.
+ * @return string The updated block content.
+ */
+function inject_placeholder_svg( $block_content, $block ) {
+ $svg = '';
+
+ // Image block, find img without `src` or with wmark.png (logo), replace with svg.
+ if ( preg_match( '/]*)\/?>/', $block_content, $match ) ) {
+ if ( ! str_contains( $match[1], 'src=' ) || str_contains( $match[1], 'wmark.png' ) ) {
+ $new_content = str_replace( '