diff --git a/api.wordpress.org/public_html/dotorg/freescout/common.php b/api.wordpress.org/public_html/dotorg/freescout/common.php
index f847004c28..b4d5fd651a 100644
--- a/api.wordpress.org/public_html/dotorg/freescout/common.php
+++ b/api.wordpress.org/public_html/dotorg/freescout/common.php
@@ -173,6 +173,82 @@ function send_panel( array $blocks, array $extra = array() ): never {
exit;
}
+/**
+ * A plugin's or theme's security scans; %1$s is plugin or theme, and %2$s its slug.
+ *
+ * @var string
+ */
+const SCANS_URL = 'https://gandalf.wordpress.org/admin/subjects/%1$s/%2$s?tab=runs';
+
+/**
+ * Whether a plugin has been released: its code is in SVN, so it can be checked out and scanned.
+ *
+ * An approved plugin isn't in SVN until its author commits it.
+ *
+ * @param \WP_Post $post Plugin.
+ * @return bool
+ */
+function is_plugin_released( \WP_Post $post ): bool {
+ return ! in_array( $post->post_status, array( 'new', 'pending', 'approved', 'rejected' ), true );
+}
+
+/**
+ * The ZIP of a plugin: the latest uploaded for review until it's released, and the latest release otherwise.
+ *
+ * A plugin in review, or approved but not yet committed to SVN, hasn't been released, so its uploads are only for
+ * reviewers; for them, the URL carries the plugin's review info, which the review tools read. Rejected plugins have
+ * none.
+ *
+ * @param \WP_Post $post Plugin.
+ * @param bool $review Whether it's for a reviewer.
+ * @return string Empty if there's no ZIP to download.
+ */
+function get_plugin_download_url( \WP_Post $post, bool $review ): string {
+ if ( 'rejected' === $post->post_status ) {
+ return '';
+ }
+
+ $url = "https://downloads.wordpress.org/plugin/{$post->post_name}.latest-stable.zip";
+
+ if ( in_array( $post->post_status, array( 'new', 'pending', 'approved' ), true ) ) {
+ $attachments = $review ? get_posts(
+ array(
+ 'post_parent' => $post->ID,
+ 'post_type' => 'attachment',
+ 'orderby' => 'post_date',
+ 'order' => 'DESC',
+ 'posts_per_page' => 1,
+ )
+ ) : array();
+ $url = $attachments ? (string) wp_get_attachment_url( $attachments[0]->ID ) : '';
+ }
+
+ if ( $url && $review && class_exists( '\WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review' ) ) {
+ $url = \WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review::append_plugin_review_info_url( $url, $post );
+ }
+
+ return $url;
+}
+
+/**
+ * Who's reviewing a plugin: only a review in progress has someone on it, as the assignment stays after it's done.
+ *
+ * @param \WP_Post $post Plugin.
+ * @return string Their name, empty if no one is.
+ */
+function get_assigned_reviewer( \WP_Post $post ): string {
+ if ( ! $post->assigned_reviewer || ! in_array( $post->post_status, array( 'new', 'pending' ), true ) ) {
+ return '';
+ }
+
+ $reviewer = get_user_by( 'id', (int) $post->assigned_reviewer );
+ if ( ! $reviewer ) {
+ return '';
+ }
+
+ return $reviewer->display_name ? $reviewer->display_name : $reviewer->user_login;
+}
+
/**
* A status badge; WPOrgSidebar's stylesheet colors it by tone.
*
@@ -201,6 +277,152 @@ function panel_link( string $text, string $url ): array {
);
}
+/**
+ * Gets the time between two timestamps as a number and a unit letter, e.g. 3d, for the narrow sidebar.
+ *
+ * Rounded in the largest unit that fits, like human_time_diff(), or up to the next one when it rounds to a whole one.
+ *
+ * @param int $from Unix timestamp.
+ * @param int $to Unix timestamp.
+ * @return string
+ */
+function short_time_diff( int $from, int $to ): string {
+ $diff = abs( $to - $from );
+ $units = array(
+ 'y' => YEAR_IN_SECONDS,
+ 'mo' => MONTH_IN_SECONDS,
+ 'w' => WEEK_IN_SECONDS,
+ 'd' => DAY_IN_SECONDS,
+ 'h' => HOUR_IN_SECONDS,
+ 'm' => MINUTE_IN_SECONDS,
+ );
+
+ $larger = null;
+ foreach ( $units as $unit => $seconds ) {
+ if ( $diff >= $seconds ) {
+ $count = (int) round( $diff / $seconds );
+ if ( $larger && $count >= round( $units[ $larger ] / $seconds ) ) {
+ return '1' . $larger;
+ }
+
+ return $count . $unit;
+ }
+
+ $larger = $unit;
+ }
+
+ return max( 1, $diff ) . 's';
+}
+
+/**
+ * Icons of the reasons plugins are closed or disabled for, by reason; the plugin directory names them.
+ *
+ * @var string[]
+ */
+const CLOSE_REASON_ICONS = array(
+ 'security-issue' => 'π‘οΈ',
+ 'author-request' => 'π€',
+ 'guideline-violation' => 'π',
+ 'licensing-trademark-violation' => 'β’οΈ',
+ 'merged-into-core' => 'π§©',
+ 'unused' => 'π€',
+);
+
+/**
+ * Icons, text, and tones of the statuses of plugins and themes in the sidebar, by status; published ones have none.
+ *
+ * @var array[]
+ */
+const ITEM_STATUSES = array(
+ // Plugins.
+ 'new' => array( 'β³', 'Pending initial review', 'warning' ),
+ 'pending' => array( 'π', 'In review', 'warning' ),
+ 'approved' => array( 'β
', 'Approved', 'warning' ),
+ 'closed' => array( 'π', 'Closed', 'error' ),
+ 'disabled' => array( 'π', 'Disabled', 'error' ),
+ 'rejected' => array( 'π«', 'Rejected', 'muted' ),
+
+ // Themes.
+ 'draft' => array( 'β³', 'In review or rejected', 'warning' ),
+ 'suspend' => array( 'β', 'Suspended', 'error' ),
+ 'suspended' => array( 'β', 'Suspended', 'error' ),
+ 'delist' => array( '', 'Delisted', 'muted' ),
+ 'delisted' => array( '', 'Delisted', 'muted' ),
+);
+
+/**
+ * Gets a reason a plugin was closed, disabled, or rejected for, in the plugin directory's words.
+ *
+ * @param string $reasons_method The plugin directory's method that lists them: get_close_reasons or get_rejection_reasons.
+ * @param string $reason Reason, e.g. security-issue.
+ * @return string
+ */
+function get_plugin_reason( string $reasons_method, string $reason ): string {
+ $template = '\WordPressdotorg\Plugin_Directory\Template';
+ $reasons = class_exists( $template ) ? $template::$reasons_method() : array();
+
+ return $reasons[ $reason ] ?? ucfirst( str_replace( '-', ' ', $reason ) );
+}
+
+/**
+ * Gets the status of a plugin or theme in words.
+ *
+ * @param string $post_status Post status.
+ * @param string $close_reason Why a plugin was closed or disabled, e.g. security-issue.
+ * @return string
+ */
+function get_item_status_label( string $post_status, string $close_reason = '' ): string {
+ if ( ! isset( ITEM_STATUSES[ $post_status ] ) ) {
+ return 'publish' === $post_status ? 'Published' : ucfirst( $post_status );
+ }
+
+ $label = ITEM_STATUSES[ $post_status ][1];
+ if ( $close_reason && in_array( $post_status, array( 'closed', 'disabled' ), true ) ) {
+ $label .= ': ' . get_plugin_reason( 'get_close_reasons', $close_reason );
+ }
+
+ return $label;
+}
+
+/**
+ * Gets the status line of a plugin or theme in the sidebar, which published ones don't have.
+ *
+ * Reasons and reviewers are the plugins team's, so only reviews pass them.
+ *
+ * @param string $post_status Post status.
+ * @param string $reviewer Reviewer assigned to a plugin in review, see get_assigned_reviewer().
+ * @param string $close_reason Why a plugin was closed or disabled, e.g. security-issue.
+ * @param string $rejection Why a plugin was rejected, in words.
+ * @return array The item's tone, and its status: icon, text, and tooltip. Empty if published.
+ */
+function get_item_status( string $post_status, string $reviewer = '', string $close_reason = '', string $rejection = '' ): array {
+ if ( ! isset( ITEM_STATUSES[ $post_status ] ) ) {
+ return array();
+ }
+
+ list( $icon, , $tone ) = ITEM_STATUSES[ $post_status ];
+ if ( in_array( $post_status, array( 'closed', 'disabled' ), true ) ) {
+ $icon = CLOSE_REASON_ICONS[ $close_reason ] ?? $icon;
+ }
+
+ $text = get_item_status_label( $post_status, $close_reason );
+ if ( $reviewer ) {
+ $text .= ' Β· Assigned to ' . $reviewer;
+ }
+
+ return array(
+ 'tone' => $tone,
+ // Without an icon or tooltip where there's none.
+ 'status' => array_filter(
+ array(
+ 'icon' => $icon,
+ 'text' => $text,
+ 'tooltip' => 'rejected' === $post_status ? $rejection : '',
+ )
+ ),
+ );
+}
+
/**
* Determines the mailbox slug, e.g. "Plugins" => plugins.
*
diff --git a/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php
new file mode 100644
index 0000000000..792d725972
--- /dev/null
+++ b/api.wordpress.org/public_html/dotorg/freescout/plugin-review.php
@@ -0,0 +1,88 @@
+mailbox->email ?? '' ) ), 'plugins' ) ) {
+ return array( 'plugin' => null );
+ }
+
+ $post = find_plugin( (int) ( $request->plugin_id ?? 0 ), (string) ( $request->slug ?? '' ) );
+ if ( ! $post ) {
+ return array( 'plugin' => null );
+ }
+
+ $released = is_plugin_released( $post );
+ $submitter = get_user_by( 'id', (int) $post->post_author );
+
+ return array(
+ 'plugin' => array(
+ 'id' => (int) $post->ID,
+ // Stored escaped, and shown as text.
+ 'name' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
+ 'slug' => $post->post_name,
+ 'status' => $post->post_status,
+ 'status_label' => get_item_status_label( $post->post_status, (string) $post->_close_reason ),
+ // Its code is in SVN, so it can be checked out and scanned.
+ 'released' => $released,
+ // Edit and permalinks are built by hand, as the post types aren't registered on this site.
+ 'edit_url' => add_query_arg(
+ array(
+ 'action' => 'edit',
+ 'post' => $post->ID,
+ ),
+ admin_url( 'post.php' )
+ ),
+ 'page_url' => home_url( "/{$post->post_name}/" ),
+ 'download_url' => get_plugin_download_url( $post, true ),
+ // Only releases are scanned.
+ 'scans_url' => $released ? sprintf( SCANS_URL, 'plugin', rawurlencode( $post->post_name ) ) : '',
+ 'submitter' => $submitter ? array(
+ 'username' => $submitter->user_login,
+ 'email' => $submitter->user_email,
+ ) : null,
+ 'reviewer' => get_assigned_reviewer( $post ),
+ ),
+ );
+}
+
+/**
+ * Finds a plugin by its ID or, without one, by its slug, whatever its status.
+ *
+ * @param int $plugin_id Plugin ID, 0 if unknown.
+ * @param string $slug Slug, for when the ID is unknown.
+ * @return \WP_Post|null
+ */
+function find_plugin( int $plugin_id, string $slug ): ?\WP_Post {
+ if ( $plugin_id ) {
+ $post = get_post( $plugin_id );
+ } elseif ( '' !== $slug && class_exists( '\WordPressdotorg\Plugin_Directory\Plugin_Directory' ) ) {
+ $post = \WordPressdotorg\Plugin_Directory\Plugin_Directory::get_plugin_post( $slug );
+ } else {
+ $post = null;
+ }
+
+ return $post instanceof \WP_Post && 'plugin' === $post->post_type ? $post : null;
+}
+
+wp_send_json( render_plugin_review( get_request( basename( __FILE__ ) ) ) );
diff --git a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php
index 02434625cd..449533a0c0 100644
--- a/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php
+++ b/api.wordpress.org/public_html/dotorg/freescout/plugins-themes.php
@@ -13,6 +13,19 @@
$wp_init_host = 'https://wordpress.org/plugins/';
require __DIR__ . '/common.php';
+/**
+ * Orders plugins and themes by status, then by name: in review or approved first, with theme drafts, which can also be
+ * rejected; then published, then the others, and rejected plugins last.
+ *
+ * @var string
+ */
+const ITEM_ORDER = "CASE
+ WHEN post_status IN( 'new', 'pending', 'approved', 'draft' ) THEN 1
+ WHEN post_status = 'publish' THEN 2
+ WHEN post_status = 'rejected' THEN 4
+ ELSE 3
+END, post_title";
+
/**
* Gets the plugins and themes panel.
*
@@ -33,7 +46,7 @@ function render_plugins_themes( object $request ): array {
);
// Display plugins first in the plugins inbox.
- if ( str_starts_with( $mailbox_email, 'plugins' ) ) {
+ if ( str_starts_with( strtolower( $mailbox_email ), 'plugins' ) ) {
$sites = array_reverse( $sites );
$repo_post_types = array_reverse( $repo_post_types );
}
@@ -124,8 +137,8 @@ function get_user_items( \WP_User $user ): array {
$wpdb->prepare(
"SELECT ID
FROM %i
- WHERE post_type IN( 'plugin', 'repopackage' ) AND ( post_author = %d" . ( $slugs ? ' OR post_name IN( ' . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' )' : '' ) . " )
- ORDER BY FIELD( post_status, 'new', 'pending', 'publish', 'disabled', 'delisted', 'delist', 'closed', 'approved', 'suspended', 'suspend', 'rejected', 'draft' ), post_title",
+ WHERE post_type IN( 'plugin', 'repopackage' ) AND ( post_author = %d" . ( $slugs ? ' OR post_name IN( ' . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' )' : '' ) . ' )
+ ORDER BY ' . ITEM_ORDER,
array_merge( array( $wpdb->posts, $user->ID ), $slugs )
)
);
@@ -140,7 +153,7 @@ function get_user_items( \WP_User $user ): array {
* Queried directly, as WP_Query's 'any' skips statuses this request doesn't register, like suspended themes.
*
* @param array $slugs Plugin or theme slugs.
- * @return int[] Post IDs, ordered by title.
+ * @return int[] Post IDs, ordered by status, then title.
*/
function get_items_by_slug( array $slugs ): array {
global $wpdb;
@@ -155,7 +168,7 @@ function get_items_by_slug( array $slugs ): array {
FROM %i
WHERE post_type IN( 'plugin', 'repopackage' ) AND post_status NOT IN( 'trash', 'auto-draft' )
AND post_name IN( " . implode( ', ', array_fill( 0, count( $slugs ), '%s' ) ) . ' )
- ORDER BY post_title',
+ ORDER BY ' . ITEM_ORDER, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- A constant, with no input.
array_merge( array( $wpdb->posts ), array_values( $slugs ) )
)
);
@@ -174,117 +187,51 @@ function render_items( array $post_ids, string $mailbox_email ): array {
$items = array();
// Reviews are the plugins team's; other mailboxes' conversations can name any plugin.
- $show_review = str_starts_with( $mailbox_email, 'plugins' );
+ $show_review = str_starts_with( strtolower( $mailbox_email ), 'plugins' );
foreach ( $post_ids as $post_id ) {
$post = get_post( (int) $post_id );
$type = ( 'plugin' === $post->post_type ) ? 'plugin' : 'theme';
- $status = '';
- $tone = 'neutral';
- $reviewer = false;
+ $reviewer = '';
+ $close_reason = '';
+ $rejection = '';
$last_modified = $post->post_modified_gmt;
- $download_link = "https://downloads.wordpress.org/{$type}/{$post->post_name}.latest-stable.zip";
+ $download_link = 'plugin' === $type ? get_plugin_download_url( $post, $show_review ) : "https://downloads.wordpress.org/theme/{$post->post_name}.latest-stable.zip";
if ( 'plugin' === $type ) {
- // Only a review in progress has someone on it; the assignment stays after it's done.
- if ( $show_review && $post->assigned_reviewer && in_array( $post->post_status, array( 'new', 'pending' ), true ) ) {
- $reviewer_user = get_user_by( 'id', (int) $post->assigned_reviewer );
- if ( $reviewer_user ) {
- $reviewer = $reviewer_user->display_name ? $reviewer_user->display_name : $reviewer_user->user_login;
- }
- }
+ $reviewer = $show_review ? get_assigned_reviewer( $post ) : '';
// Prefer the last_updated post meta.
$last_modified = $post->last_updated ? $post->last_updated : $last_modified;
- // Get the ZIPs attached, link to the latest for pending/new. Unreleased, so only for reviews.
- if ( in_array( $post->post_status, array( 'new', 'pending' ), true ) ) {
- $attachments = $show_review ? get_posts(
- array(
- 'post_parent' => $post->ID,
- 'post_type' => 'attachment',
- 'orderby' => 'post_date',
- 'order' => 'DESC',
- 'posts_per_page' => 1,
- )
- ) : array();
- $download_link = $attachments ? (string) wp_get_attachment_url( $attachments[0]->ID ) : '';
- }
-
- // Append Info URL.
- if (
- $download_link &&
- $show_review &&
- class_exists( '\WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review' )
- ) {
- $download_link = \WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review::append_plugin_review_info_url( $download_link, $post );
+ // Why a plugin was closed or rejected is the plugins team's, like reviews.
+ if ( $show_review ) {
+ $close_reason = (string) $post->_close_reason;
+ $rejection = $post->_rejection_reason ? get_plugin_reason( 'get_rejection_reasons', (string) $post->_rejection_reason ) : '';
}
+ } elseif ( 'draft' === $post->post_status ) {
+ $download_link = ''; // No zips exist for themes in review.
}
$last_updated = (int) strtotime( $last_modified );
+ $released = 'plugin' === $type && is_plugin_released( $post );
- switch ( $post->post_status ) {
- // Plugins.
- case 'rejected':
- $status = 'Rejected';
- $tone = 'error';
- $download_link = ''; // No zips exist for rejected plugins.
- break;
- case 'closed':
- case 'disabled':
- $status = ucwords( $post->post_status );
- // This is not perfect, but close enough.
- if ( $show_review && $post->_close_reason ) {
- $status .= ': ' . ucwords( str_replace( '-', ' ', $post->_close_reason ) );
- }
- $tone = 'error';
- break;
- case 'pending':
- case 'new':
- $status = 'In Review';
- $tone = 'warning';
- break;
- case 'approved':
- $status = 'Approved';
- $tone = 'success';
- break;
-
- // Themes.
- case 'draft':
- $status = 'In Review or Rejected';
- $tone = 'warning';
- $download_link = ''; // No zips exist for drafts.
- break;
- case 'suspend':
- $status = 'Suspended';
- $tone = 'error';
- break;
- case 'delist':
- $status = 'Delisted';
- $tone = 'error';
- break;
- }
-
- $meta = array(
- array( 'text' => $post->post_name ),
- array(
- 'text' => 'Updated ' . human_time_diff( $last_updated, time() ) . ' ago',
- 'tooltip' => gmdate( 'Y-m-d', $last_updated ),
- ),
- );
-
- if ( $reviewer ) {
- $meta[] = array( 'text' => 'Assigned to ' . $reviewer );
+ // Built by hand, as the post types aren't registered on this site; rarest first, so Download lines up at the end.
+ $links = array();
+ if ( $released || ( 'theme' === $type && 'publish' === $post->post_status ) ) {
+ $links[] = array(
+ 'text' => 'Scans in Gandalf',
+ 'url' => sprintf( SCANS_URL, $type, rawurlencode( $post->post_name ) ),
+ 'icon' => 'shield',
+ );
}
-
- // Edit and permalinks are built by hand, as the post types aren't registered on this site.
- $links = array(
- array(
+ if ( $released || ( 'theme' === $type && in_array( $post->post_status, array( 'publish', 'delist' ), true ) ) ) {
+ $links[] = array(
'text' => 'View on WordPress.org',
'url' => home_url( "/{$post->post_name}/" ),
'icon' => 'link',
- ),
- );
+ );
+ }
if ( $download_link ) {
$links[] = array(
'text' => 'Download',
@@ -293,19 +240,25 @@ class_exists( '\WordPressdotorg\Plugin_Directory\API\Routes\Plugin_Review' )
);
}
- $items[] = array(
- // Stored escaped, and shown as text.
- 'title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
- 'url' => add_query_arg(
- array(
- 'action' => 'edit',
- 'post' => $post->ID,
+ $items[] = array_merge(
+ array(
+ // Stored escaped, and shown as text.
+ 'title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
+ 'url' => add_query_arg(
+ array(
+ 'action' => 'edit',
+ 'post' => $post->ID,
+ ),
+ admin_url( 'post.php' )
+ ),
+ 'meta' => array( array( 'text' => $post->post_name ) ),
+ 'age' => array(
+ 'text' => short_time_diff( $last_updated, time() ),
+ 'tooltip' => 'Updated ' . gmdate( 'Y-m-d', $last_updated ),
),
- admin_url( 'post.php' )
+ 'links' => $links,
),
- 'badges' => $status ? array( badge( $status, $tone ) ) : array(),
- 'meta' => $meta,
- 'links' => $links,
+ get_item_status( $post->post_status, $reviewer, $close_reason, $rejection )
);
}
diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php
index ad27668857..2bfac8fe13 100644
--- a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php
+++ b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php
@@ -16,6 +16,11 @@
define( 'ABSPATH', __DIR__ . '/' );
define( 'KB_IN_BYTES', 1024 );
define( 'MINUTE_IN_SECONDS', 60 );
+define( 'HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS );
+define( 'DAY_IN_SECONDS', 24 * HOUR_IN_SECONDS );
+define( 'WEEK_IN_SECONDS', 7 * DAY_IN_SECONDS );
+define( 'MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS );
+define( 'YEAR_IN_SECONDS', 365 * DAY_IN_SECONDS );
define( 'FREESCOUT_SECRET', 'test-secret' );
require_once __DIR__ . '/stubs/class-wp-user.php';
diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php
new file mode 100644
index 0000000000..34cea5b5c0
--- /dev/null
+++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-item-status-test.php
@@ -0,0 +1,129 @@
+assertSame( array(), get_item_status( 'publish' ) );
+ }
+
+ /**
+ * Each status has words, a tone, and but for delisted themes, an icon.
+ *
+ * @dataProvider data_statuses
+ *
+ * @param string $post_status Post status.
+ * @param string $icon Icon.
+ * @param string $text Text.
+ * @param string $tone Tone.
+ * @return void
+ */
+ public function test_status( string $post_status, string $icon, string $text, string $tone ): void {
+ $this->assertSame(
+ array(
+ 'tone' => $tone,
+ 'status' => array_filter(
+ array(
+ 'icon' => $icon,
+ 'text' => $text,
+ )
+ ),
+ ),
+ get_item_status( $post_status )
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_statuses(): array {
+ return array(
+ 'new' => array( 'new', 'β³', 'Pending initial review', 'warning' ),
+ 'pending' => array( 'pending', 'π', 'In review', 'warning' ),
+ 'approved' => array( 'approved', 'β
', 'Approved', 'warning' ),
+ 'closed' => array( 'closed', 'π', 'Closed', 'error' ),
+ 'disabled' => array( 'disabled', 'π', 'Disabled', 'error' ),
+ 'rejected' => array( 'rejected', 'π«', 'Rejected', 'muted' ),
+ 'draft' => array( 'draft', 'β³', 'In review or rejected', 'warning' ),
+ 'suspended' => array( 'suspend', 'β', 'Suspended', 'error' ),
+ 'delisted' => array( 'delist', '', 'Delisted', 'muted' ),
+ );
+ }
+
+ /**
+ * A closed or disabled plugin's reason picks its icon.
+ *
+ * @return void
+ */
+ public function test_close_reason(): void {
+ $this->assertSame(
+ array(
+ 'icon' => 'π‘οΈ',
+ 'text' => 'Closed: Security issue',
+ ),
+ get_item_status( 'closed', '', 'security-issue' )['status']
+ );
+
+ $this->assertSame(
+ array(
+ 'icon' => 'β’οΈ',
+ 'text' => 'Disabled: Licensing trademark violation',
+ ),
+ get_item_status( 'disabled', '', 'licensing-trademark-violation' )['status']
+ );
+ }
+
+ /**
+ * A reason without an icon is still named.
+ *
+ * @return void
+ */
+ public function test_unknown_close_reason(): void {
+ $this->assertSame(
+ array(
+ 'icon' => 'π',
+ 'text' => 'Closed: Some new reason',
+ ),
+ get_item_status( 'closed', '', 'some-new-reason' )['status']
+ );
+ }
+
+ /**
+ * The reviewer of a plugin in review follows its status.
+ *
+ * @return void
+ */
+ public function test_reviewer(): void {
+ $this->assertSame( 'In review Β· Assigned to Priya Shah', get_item_status( 'pending', 'Priya Shah' )['status']['text'] );
+ $this->assertSame( 'Pending initial review Β· Assigned to Priya Shah', get_item_status( 'new', 'Priya Shah' )['status']['text'] );
+ }
+
+ /**
+ * Why a plugin was rejected is in the tooltip.
+ *
+ * @return void
+ */
+ public function test_rejection_reason(): void {
+ $this->assertSame( 'Duplicate (copy) of another Plugin', get_item_status( 'rejected', '', '', 'Duplicate (copy) of another Plugin' )['status']['tooltip'] );
+ }
+}
diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php
new file mode 100644
index 0000000000..3431052a46
--- /dev/null
+++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-short-time-diff-test.php
@@ -0,0 +1,55 @@
+assertSame( $expected, short_time_diff( $now - $seconds, $now ) );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_short_time_diff(): array {
+ return array(
+ 'no time' => array( 0, '1s' ),
+ 'seconds' => array( 45, '45s' ),
+ 'a minute' => array( MINUTE_IN_SECONDS, '1m' ),
+ 'hours' => array( 3 * HOUR_IN_SECONDS, '3h' ),
+ 'a day' => array( DAY_IN_SECONDS, '1d' ),
+ 'rounded up' => array( DAY_IN_SECONDS + 13 * HOUR_IN_SECONDS, '2d' ),
+ 'weeks' => array( 2 * WEEK_IN_SECONDS, '2w' ),
+ 'months' => array( 2 * MONTH_IN_SECONDS + DAY_IN_SECONDS, '2mo' ),
+ 'years' => array( 12 * YEAR_IN_SECONDS, '12y' ),
+ 'up to a week' => array( 6 * DAY_IN_SECONDS + 15 * HOUR_IN_SECONDS, '1w' ),
+ 'up to a year' => array( 350 * DAY_IN_SECONDS, '1y' ),
+ 'in the future' => array( -2 * DAY_IN_SECONDS, '2d' ),
+ );
+ }
+}
diff --git a/environments/README.md b/environments/README.md
index f7fc42053b..5e542faf13 100644
--- a/environments/README.md
+++ b/environments/README.md
@@ -301,7 +301,7 @@ What's running:
`freescout.wordpress.net/Modules/` is mounted as FreeScout's `Modules/` folder, like production's checkout. Every start switches all modules on and runs `freescout:after-app-update`, as production does after a deploy. After switching branches, run `npm run freescout:setup`: FreeScout errors on every page while it still has a removed module cached.
-Setup also adds sample data, set up like the HelpScout helpdesk but with made-up people and conversations. It adds the **Learn WordPress** mailbox, without incoming email. It also adds four made-up agents and 34 made-up conversations in Plugins, Themes and Learn WordPress from the last eight weeks, with replies, notes, and closed, pending, and spam conversations. It only adds what's missing, and only to a FreeScout on 127.0.0.1 or localhost.
+Setup also adds sample data, set up like the HelpScout helpdesk but with made-up people and conversations. It adds the **Learn WordPress** mailbox, without incoming email. It also adds four made-up agents and 39 made-up conversations in Plugins, Themes and Learn WordPress from the last eight weeks, with replies, notes, and closed, pending, and spam conversations. Five of the Plugins ones are plugin reviews, one for each state of the Plugin Review panel: issues waiting for an update, a rename, an ownership check, an update uploaded, and an approval. It only adds what's missing, and only to a FreeScout on 127.0.0.1 or localhost.
**Premium modules:** to try them, copy them in from a clone of the private [freescout-paid-modules](https://github.com/WordPress/freescout-paid-modules) repository. They must never be committed here, so exclude each one first. From `environments/`:
@@ -321,7 +321,7 @@ Setup switches them on, and adds sample data for each one that's there, with Hel
- **Tags:** some of HelpScout's tags and colors, on conversations.
- **Custom Fields:** the Learn WordPress fields, with values. Plugins and Themes have none.
- **Saved Replies:** some of HelpScout's saved replies in Plugins and Themes, with short made-up texts.
-- **Teams:** the Plugin, Themes and Learn teams, with conversations assigned to some. Names longer than FreeScout's 20 characters are shortened.
+- **Teams:** the Plugin, Themes and Learn teams, with conversations assigned to some. The admin is in Plugin Reviews, so they get the Plugin Review panel. Names longer than FreeScout's 20 characters are shortened.
- **Custom Folders:** "New WordPress" in Plugins, for replies to new WordPress version emails.
- **Mentions:** a note in which one agent mentions another.
- **Workflows:** some of HelpScout's workflows, with made-up rules. Automatic ones only run for new email with matching subjects, or a tag an agent adds; none run for the sample conversations.
diff --git a/environments/freescout/bin/sample-data.php b/environments/freescout/bin/sample-data.php
index f25c7de034..a9dd5a31bf 100644
--- a/environments/freescout/bin/sample-data.php
+++ b/environments/freescout/bin/sample-data.php
@@ -33,6 +33,7 @@
use Modules\Tags\Entities\Tag;
use Modules\Teams\Providers\TeamsServiceProvider;
use Modules\Workflows\Entities\Workflow;
+use Modules\WPOrgPluginReview\Services\Review;
require '/var/www/html/vendor/autoload.php';
@@ -340,6 +341,71 @@ function conversation_data(): array {
array( 'customer', 'Buy 10,000 backlinks today and rank first on every search engine.', 0 ),
),
),
+ array(
+ 'mailbox' => 'Plugins',
+ 'subject' => '[WordPress Plugin Directory] Review in Progress: Simple Booking Forms',
+ 'customer' => 'lucas@example.com',
+ 'agent' => 'priya@wordpress.test',
+ 'days_ago' => 12,
+ 'status' => Conversation::STATUS_ACTIVE,
+ 'tags' => array( 'plugin-review', 'review' ),
+ 'threads' => array(
+ array( 'agent', '
Hi,
We found some issues in Simple Booking Forms.
π΄ Data Must be Sanitized, Escaped, and Validated
The booking form saves $_POST[\'date\'] as it comes.
π΄ Trialware and license checks are not permitted
Recurring bookings stay locked until a license key is entered.
π‘ Use wp_enqueue commands
The calendarβs script is printed in the footer.
π‘ Allowing Direct File Access to plugin files
includes/ajax.php runs without checking ABSPATH.
Review ID: R simple-booking-forms/lucasmoreau/26Sep26/T1 26Sep26/1.0.2 (P0TDX310001HGN)
', 0 ),
+ array( 'customer', 'Thanks, Iβm working on these and will upload a new version this week.', 20 ),
+ ),
+ ),
+ array(
+ 'mailbox' => 'Plugins',
+ 'subject' => '[WordPress Plugin Directory] Review in Progress: WooCommerce Turbo Shipping',
+ 'customer' => 'kenji@example.com',
+ 'agent' => 'lena@wordpress.test',
+ 'days_ago' => 9,
+ 'status' => Conversation::STATUS_PENDING,
+ 'tags' => array( 'plugin-review', 'review' ),
+ 'threads' => array(
+ array( 'agent', 'Hi,
The name of your plugin starts with someone elseβs trademark, so it needs a new one.
- Plugin Name: WooCommerce Turbo Shipping
- Our suggested alternative name:
Turbo Shipping for WooCommerce - Our suggested alternative slug: turbo-shipping-for-woocommerce
Reply to let us know which name youβd like.
Review ID: TRM βTRM woocommerce-turbo-shipping/kenjiwatanabe/29Sep26/T1 29Sep26/2.1 (P0TDX310002HGN)
', 0 ),
+ ),
+ ),
+ array(
+ 'mailbox' => 'Plugins',
+ 'subject' => '[WordPress Plugin Directory] Review in Progress: Gallery Grid Pro',
+ 'customer' => 'mei@example.com',
+ 'agent' => 'marco@wordpress.test',
+ 'days_ago' => 6,
+ 'status' => Conversation::STATUS_ACTIVE,
+ 'tags' => array( 'plugin-review', 'review' ),
+ 'threads' => array(
+ array( 'agent', 'Hi,
Your plugin links to a site we canβt connect to your account, so we need to confirm you own it.
Add a TXT record wordpressorg-meichen-verification to the domain, or write to us from an address at it.
Review ID: OWN βOWN gallery-grid-pro/meichen/2Oct26/T1 2Oct26/3.0 (P0TDX310003HGN)
', 0 ),
+ array( 'customer', 'Iβve added the record. Can you check again?', 30 ),
+ ),
+ ),
+ array(
+ 'mailbox' => 'Plugins',
+ 'subject' => '[WordPress Plugin Directory] Review in Progress: Recipe Cards',
+ 'customer' => 'hannah@example.com',
+ 'agent' => 'priya@wordpress.test',
+ 'days_ago' => 16,
+ 'status' => Conversation::STATUS_ACTIVE,
+ 'tags' => array( 'plugin-review', 'review' ),
+ 'threads' => array(
+ array( 'agent', 'Hi,
We found an issue in Recipe Cards.
π΄ Use Prefixes for declarations, globals and stored data
Functions like save_recipe() can clash with other plugins.
Review ID: R recipe-cards/hannahbecker/22Sep26/T1 22Sep26/1.4 (P0TDX310004HGN)
', 0 ),
+ array( 'agent', 'This is an automated message to confirm that we have received your updated plugin file.
File updated by hannahbecker, version 1.5.
', 50 ),
+ ),
+ ),
+ array(
+ 'mailbox' => 'Plugins',
+ 'subject' => '[WordPress Plugin Directory] Review in Progress: Simple Changelog',
+ 'customer' => 'diego@example.com',
+ 'agent' => 'lena@wordpress.test',
+ 'days_ago' => 20,
+ 'status' => Conversation::STATUS_CLOSED,
+ 'tags' => array( 'plugin-review', 'review', 'approved' ),
+ 'threads' => array(
+ array( 'agent', 'Hi,
We found an issue in Simple Changelog.
π‘ Internationalization: Text domain does not match plugin slug.
Strings use the text domain "changelog".
Review ID: R simple-changelog/diegofernandez/18Sep26/T1 18Sep26/0.9 (P0TDX310005HGN)
', 0 ),
+ array( 'agent', 'This is an automated message to confirm that we have received your updated plugin file.
File updated by diegofernandez, version 1.0.
', 26 ),
+ array( 'agent', 'Hi,
Your plugin is approved. Youβll get an email with SVN access shortly.
Review ID: APPROVED simple-changelog/diegofernandez/18Sep26/T2 20Sep26/1.0 (P0TDX310005HGN)
', 40 ),
+ ),
+ ),
array(
'mailbox' => 'Themes',
'subject' => 'Reported theme: Coastline loads fonts from a CDN',
@@ -1024,7 +1090,8 @@ function seed_saved_replies(): void {
/**
* Teams: HelpScout's teams, with sample agents and their mailbox, and a conversation assigned to some.
*
- * Team names have at most 20 characters, so the longer HelpScout names are shortened.
+ * The admin is in Plugin Reviews, so they get the Plugin Review panel. Team names have at most 20 characters, so the
+ * longer HelpScout names are shortened.
*
* @return void
*/
@@ -1034,9 +1101,9 @@ function seed_teams(): void {
}
$teams = array(
- 'Plugin Reviews' => array( 'Plugins', 'eye-open', array( 'priya@wordpress.test', 'marco@wordpress.test', 'lena@wordpress.test' ) ),
+ 'Plugin Reviews' => array( 'Plugins', 'eye-open', array( admin()->email, 'priya@wordpress.test', 'marco@wordpress.test', 'lena@wordpress.test' ) ),
'Plugin Security' => array( 'Plugins', 'lock', array( 'marco@wordpress.test' ) ),
- 'Plugin Admin' => array( 'Plugins', 'cog', array( 'priya@wordpress.test' ) ),
+ 'Plugin Admins' => array( 'Plugins', 'cog', array( 'priya@wordpress.test' ) ),
'Plugin Reports' => array( 'Plugins', 'flag', array( 'tomas@wordpress.test' ) ),
'Themes' => array( 'Themes', 'picture', array( 'lena@wordpress.test', 'tomas@wordpress.test' ) ),
'Learn' => array( 'Learn WordPress', 'education', array( 'tomas@wordpress.test' ) ),
@@ -1310,6 +1377,24 @@ function seed_api_webhooks(): void {
}
}
+/**
+ * Plugin Review: indexes the sample review emails for its panel, which they're added without the events of.
+ *
+ * @return void
+ */
+function seed_plugin_reviews(): void {
+ if ( ! class_exists( Review::class ) ) {
+ return;
+ }
+
+ foreach ( conversation_data() as $data ) {
+ $conversation = 'Plugins' === $data['mailbox'] ? find_conversation( $data ) : null;
+ if ( $conversation ) {
+ Review::reindex( $conversation );
+ }
+ }
+}
+
/**
* Adds everything.
*
@@ -1339,6 +1424,7 @@ function main(): void {
seed_workflows();
seed_reports();
seed_api_webhooks();
+ seed_plugin_reviews();
foreach ( $mailboxes as $mailbox ) {
$mailbox->updateFoldersCounters();
diff --git a/environments/freescout/mock-api/index.php b/environments/freescout/mock-api/index.php
index 418b60559e..afea615da5 100644
--- a/environments/freescout/mock-api/index.php
+++ b/environments/freescout/mock-api/index.php
@@ -94,10 +94,42 @@ function respond( int $status, array|object $data ): void {
respond( 200, array( 'replaced' => count( $copies ) ) );
}
+// Like plugin-review.php: any plugin ID is a plugin in review, in the plugins team's mailbox.
+if ( 'plugin-review.php' === $endpoint ) {
+ $plugin_id = (int) ( $request->plugin_id ?? 0 );
+ if ( ! $plugin_id || ! str_starts_with( (string) ( $request->mailbox->email ?? '' ), 'plugins' ) ) {
+ respond( 200, array( 'plugin' => null ) );
+ }
+
+ respond(
+ 200,
+ array(
+ 'plugin' => array(
+ 'id' => $plugin_id,
+ 'name' => 'Mock Plugin Renamed',
+ 'slug' => 'mock-plugin-renamed',
+ 'status' => 'pending',
+ 'status_label' => 'In review',
+ 'released' => false,
+ 'scans_url' => '',
+ 'edit_url' => 'https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=' . $plugin_id,
+ 'page_url' => 'https://wordpress.org/plugins/mock-plugin-renamed/',
+ 'download_url' => 'https://wordpress.org/plugins/wp-content/uploads/mock-plugin.zip#wporgapi:https://wordpress.org/plugins/wp-json/plugins/v1/plugin-review/' . $plugin_id . '-0123456789abcdef0123456789abcdef/',
+ 'submitter' => array(
+ 'username' => 'mockauthor',
+ 'email' => 'author@example.org',
+ ),
+ 'reviewer' => 'Mock Reviewer',
+ ),
+ )
+ );
+}
+
/*
* What the real endpoints sent for obenland's WordPress.org account, whatever the sender, though searches are for the
- * sender's email, as they are there. Download links lose their review info, and the forum note and privacy request
- * are samples, as the account has neither.
+ * sender's email, as they are there. Download links lose their review info, and the forum note, the privacy request,
+ * and the plugins mentioned besides The Events Calendar, one in each review status, are samples, as the account has
+ * none of them.
*/
$panel = __DIR__ . '/panels/' . basename( $endpoint, '.php' ) . '.json';
$email = rawurlencode( (string) ( $request->sender->email ?? '' ) );
diff --git a/environments/freescout/mock-api/panels/plugins-themes.json b/environments/freescout/mock-api/panels/plugins-themes.json
index 48ebd11b1a..bec3cd2c6a 100644
--- a/environments/freescout/mock-api/panels/plugins-themes.json
+++ b/environments/freescout/mock-api/panels/plugins-themes.json
@@ -3,25 +3,109 @@
{
"type": "heading",
"text": "Plugins mentioned",
- "count": 1
+ "count": 8
},
{
"type": "items",
"items": [
+ {
+ "title": "Booking Widget for Salons",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900003",
+ "meta": [
+ {
+ "text": "booking-widget-for-salons"
+ }
+ ],
+ "age": {
+ "text": "1d",
+ "tooltip": "Updated 2026-10-04"
+ },
+ "links": [
+ {
+ "text": "View on WordPress.org",
+ "url": "https://wordpress.org/plugins/booking-widget-for-salons/",
+ "icon": "link"
+ },
+ {
+ "text": "Download",
+ "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/booking-widget-for-salons.zip",
+ "icon": "download-alt"
+ }
+ ],
+ "tone": "warning",
+ "status": {
+ "icon": "β
",
+ "text": "Approved"
+ }
+ },
+ {
+ "title": "Contact Form Builder Pro",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900002",
+ "meta": [
+ {
+ "text": "contact-form-builder-pro"
+ }
+ ],
+ "age": {
+ "text": "5d",
+ "tooltip": "Updated 2026-09-30"
+ },
+ "links": [
+ {
+ "text": "Download",
+ "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/contact-form-builder-pro.zip",
+ "icon": "download-alt"
+ }
+ ],
+ "tone": "warning",
+ "status": {
+ "icon": "π",
+ "text": "In review Β· Assigned to Priya Shah"
+ }
+ },
+ {
+ "title": "Custom Checkout Fields, Order Notes and Delivery Date Picker for WooCommerce",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900001",
+ "meta": [
+ {
+ "text": "custom-checkout-fields-order-notes-delivery-date-picker"
+ }
+ ],
+ "age": {
+ "text": "3d",
+ "tooltip": "Updated 2026-10-02"
+ },
+ "links": [
+ {
+ "text": "Download",
+ "url": "https://wordpress.org/plugins/wp-content/uploads/2026/10/custom-checkout-fields-order-notes-delivery-date-picker.zip",
+ "icon": "download-alt"
+ }
+ ],
+ "tone": "warning",
+ "status": {
+ "icon": "β³",
+ "text": "Pending initial review Β· Assigned to Marco Rossi"
+ }
+ },
{
"title": "The Events Calendar",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=203",
- "badges": [],
"meta": [
{
"text": "the-events-calendar"
- },
- {
- "text": "Updated 5 days ago",
- "tooltip": "2026-09-30"
}
],
+ "age": {
+ "text": "5d",
+ "tooltip": "Updated 2026-09-30"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/the-events-calendar?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/the-events-calendar/",
@@ -33,6 +117,131 @@
"icon": "download-alt"
}
]
+ },
+ {
+ "title": "Easy Gallery Slider",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900004",
+ "meta": [
+ {
+ "text": "easy-gallery-slider"
+ }
+ ],
+ "age": {
+ "text": "3w",
+ "tooltip": "Updated 2026-09-14"
+ },
+ "links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/easy-gallery-slider?tab=runs",
+ "icon": "shield"
+ },
+ {
+ "text": "View on WordPress.org",
+ "url": "https://wordpress.org/plugins/easy-gallery-slider/",
+ "icon": "link"
+ },
+ {
+ "text": "Download",
+ "url": "https://downloads.wordpress.org/plugin/easy-gallery-slider.latest-stable.zip",
+ "icon": "download-alt"
+ }
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "π‘οΈ",
+ "text": "Closed: Security Issue"
+ }
+ },
+ {
+ "title": "SEO Booster Ultimate",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900005",
+ "meta": [
+ {
+ "text": "seo-booster-ultimate"
+ }
+ ],
+ "age": {
+ "text": "6mo",
+ "tooltip": "Updated 2026-04-02"
+ },
+ "links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/seo-booster-ultimate?tab=runs",
+ "icon": "shield"
+ },
+ {
+ "text": "View on WordPress.org",
+ "url": "https://wordpress.org/plugins/seo-booster-ultimate/",
+ "icon": "link"
+ },
+ {
+ "text": "Download",
+ "url": "https://downloads.wordpress.org/plugin/seo-booster-ultimate.latest-stable.zip",
+ "icon": "download-alt"
+ }
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "π",
+ "text": "Closed: Guideline Violation"
+ }
+ },
+ {
+ "title": "Turbo Checkout for WooCommerce",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900006",
+ "meta": [
+ {
+ "text": "turbo-checkout-for-woocommerce"
+ }
+ ],
+ "age": {
+ "text": "1y",
+ "tooltip": "Updated 2025-10-20"
+ },
+ "links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/turbo-checkout-for-woocommerce?tab=runs",
+ "icon": "shield"
+ },
+ {
+ "text": "View on WordPress.org",
+ "url": "https://wordpress.org/plugins/turbo-checkout-for-woocommerce/",
+ "icon": "link"
+ },
+ {
+ "text": "Download",
+ "url": "https://downloads.wordpress.org/plugin/turbo-checkout-for-woocommerce.latest-stable.zip",
+ "icon": "download-alt"
+ }
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "β’οΈ",
+ "text": "Disabled: Licensing/Trademark Violation"
+ }
+ },
+ {
+ "title": "Smart AI Page Builder",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=900007",
+ "meta": [
+ {
+ "text": "rejected-smart-ai-page-builder-rejected"
+ }
+ ],
+ "age": {
+ "text": "2d",
+ "tooltip": "Updated 2026-10-03"
+ },
+ "links": [],
+ "tone": "muted",
+ "status": {
+ "icon": "π«",
+ "text": "Rejected",
+ "tooltip": "Duplicate (copy) of another Plugin"
+ }
}
]
},
@@ -48,17 +257,21 @@
{
"title": "Block for Strava",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=308916",
- "badges": [],
"meta": [
{
"text": "block-for-strava"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/block-for-strava?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/block-for-strava/",
@@ -74,17 +287,21 @@
{
"title": "Change From Address",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=183755",
- "badges": [],
"meta": [
{
"text": "change-from-address"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/change-from-address?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/change-from-address/",
@@ -100,17 +317,21 @@
{
"title": "Clicky Popular Posts Widget",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=15101",
- "badges": [],
"meta": [
{
"text": "clicky-popular-posts-widget"
- },
- {
- "text": "Updated 12 years ago",
- "tooltip": "2014-09-04"
}
],
+ "age": {
+ "text": "12y",
+ "tooltip": "Updated 2014-09-04"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/clicky-popular-posts-widget?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/clicky-popular-posts-widget/",
@@ -126,17 +347,21 @@
{
"title": "Jetpack Twitter Via",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=20510",
- "badges": [],
"meta": [
{
"text": "jetpack-twitter-via"
- },
- {
- "text": "Updated 14 years ago",
- "tooltip": "2012-11-26"
}
],
+ "age": {
+ "text": "14y",
+ "tooltip": "Updated 2012-11-26"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/jetpack-twitter-via?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/jetpack-twitter-via/",
@@ -152,17 +377,21 @@
{
"title": "Shiny Updates",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=40",
- "badges": [],
"meta": [
{
"text": "shiny-updates"
- },
- {
- "text": "Updated 10 years ago",
- "tooltip": "2016-09-27"
}
],
+ "age": {
+ "text": "10y",
+ "tooltip": "Updated 2016-09-27"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/shiny-updates?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/shiny-updates/",
@@ -178,17 +407,21 @@
{
"title": "Twenty Eleven Showcase Slider",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=18855",
- "badges": [],
"meta": [
{
"text": "twenty-eleven-showcase-slider"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/twenty-eleven-showcase-slider?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/twenty-eleven-showcase-slider/",
@@ -204,17 +437,21 @@
{
"title": "Uploads Unleashed",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=278625",
- "badges": [],
"meta": [
{
"text": "uploads-unleashed"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/uploads-unleashed?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/uploads-unleashed/",
@@ -230,17 +467,21 @@
{
"title": "WP Approve User",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=16677",
- "badges": [],
"meta": [
{
"text": "wp-approve-user"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-approve-user?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wp-approve-user/",
@@ -256,17 +497,21 @@
{
"title": "WP Author Slug",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=149",
- "badges": [],
"meta": [
{
"text": "wp-author-slug"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-author-slug?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wp-author-slug/",
@@ -282,17 +527,21 @@
{
"title": "WP Display Header",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=12688",
- "badges": [],
"meta": [
{
"text": "wp-display-header"
- },
- {
- "text": "Updated 1 month ago",
- "tooltip": "2026-09-02"
}
],
+ "age": {
+ "text": "1mo",
+ "tooltip": "Updated 2026-09-02"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-display-header?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wp-display-header/",
@@ -308,17 +557,21 @@
{
"title": "WP Last Login",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13408",
- "badges": [],
"meta": [
{
"text": "wp-last-login"
- },
- {
- "text": "Updated 3 weeks ago",
- "tooltip": "2026-09-16"
}
],
+ "age": {
+ "text": "3w",
+ "tooltip": "Updated 2026-09-16"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-last-login?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wp-last-login/",
@@ -334,17 +587,21 @@
{
"title": "WP Search Suggest",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13050",
- "badges": [],
"meta": [
{
"text": "wp-search-suggest"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-search-suggest?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wp-search-suggest/",
@@ -360,17 +617,21 @@
{
"title": "WPMU Author Description",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=17940",
- "badges": [],
"meta": [
{
"text": "wpmu-author-description"
- },
- {
- "text": "Updated 14 years ago",
- "tooltip": "2012-07-12"
}
],
+ "age": {
+ "text": "14y",
+ "tooltip": "Updated 2012-07-12"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wpmu-author-description?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wpmu-author-description/",
@@ -386,17 +647,21 @@
{
"title": "WPMU Network Site Users Dropdown",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=12552",
- "badges": [],
"meta": [
{
"text": "wpmu-network-site-users-dropdown"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-08-21"
}
],
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-08-21"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wpmu-network-site-users-dropdown?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wpmu-network-site-users-dropdown/",
@@ -412,22 +677,21 @@
{
"title": "Arya Stark",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=48935",
- "badges": [
- {
- "label": "Closed",
- "tone": "error"
- }
- ],
"meta": [
{
"text": "arya-stark"
- },
- {
- "text": "Updated 10 years ago",
- "tooltip": "2016-04-27"
}
],
+ "age": {
+ "text": "10y",
+ "tooltip": "Updated 2016-04-27"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/arya-stark?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/arya-stark/",
@@ -438,27 +702,31 @@
"url": "https://downloads.wordpress.org/plugin/arya-stark.latest-stable.zip",
"icon": "download-alt"
}
- ]
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "π",
+ "text": "Closed"
+ }
},
{
"title": "WP Google Suggest",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=13214",
- "badges": [
- {
- "label": "Closed: Author Request",
- "tone": "error"
- }
- ],
"meta": [
{
"text": "wp-google-suggest"
- },
- {
- "text": "Updated 6 years ago",
- "tooltip": "2020-11-23"
}
],
+ "age": {
+ "text": "6y",
+ "tooltip": "Updated 2020-11-23"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-google-suggest?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wp-google-suggest/",
@@ -469,27 +737,31 @@
"url": "https://downloads.wordpress.org/plugin/wp-google-suggest.latest-stable.zip",
"icon": "download-alt"
}
- ]
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "π€",
+ "text": "Closed: Author Request"
+ }
},
{
"title": "WP Save Custom Header",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=48065",
- "badges": [
- {
- "label": "Closed",
- "tone": "error"
- }
- ],
"meta": [
{
"text": "wp-save-custom-header"
- },
- {
- "text": "Updated 15 years ago",
- "tooltip": "2011-05-11"
}
],
+ "age": {
+ "text": "15y",
+ "tooltip": "Updated 2011-05-11"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-save-custom-header?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/plugins/wp-save-custom-header/",
@@ -500,121 +772,120 @@
"url": "https://downloads.wordpress.org/plugin/wp-save-custom-header.latest-stable.zip",
"icon": "download-alt"
}
- ]
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "π",
+ "text": "Closed"
+ }
},
{
- "title": "wp-time-background-color",
- "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=64541",
- "badges": [
- {
- "label": "Closed: Unused",
- "tone": "error"
- }
- ],
+ "title": "WP.org Site Icon",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=37008",
"meta": [
{
- "text": "wp-time-background-color"
- },
- {
- "text": "Updated 12 years ago",
- "tooltip": "2014-12-15"
+ "text": "wporg-site-icon"
}
],
+ "age": {
+ "text": "11y",
+ "tooltip": "Updated 2015-06-29"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wporg-site-icon?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
- "url": "https://wordpress.org/plugins/wp-time-background-color/",
+ "url": "https://wordpress.org/plugins/wporg-site-icon/",
"icon": "link"
},
{
"text": "Download",
- "url": "https://downloads.wordpress.org/plugin/wp-time-background-color.latest-stable.zip",
+ "url": "https://downloads.wordpress.org/plugin/wporg-site-icon.latest-stable.zip",
"icon": "download-alt"
}
- ]
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "π",
+ "text": "Closed"
+ }
},
{
- "title": "WP.org Site Icon",
- "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=37008",
- "badges": [
- {
- "label": "Closed",
- "tone": "error"
- }
- ],
+ "title": "wp-time-background-color",
+ "url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=64541",
"meta": [
{
- "text": "wporg-site-icon"
- },
- {
- "text": "Updated 11 years ago",
- "tooltip": "2015-06-29"
+ "text": "wp-time-background-color"
}
],
+ "age": {
+ "text": "12y",
+ "tooltip": "Updated 2014-12-15"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/plugin/wp-time-background-color?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
- "url": "https://wordpress.org/plugins/wporg-site-icon/",
+ "url": "https://wordpress.org/plugins/wp-time-background-color/",
"icon": "link"
},
{
"text": "Download",
- "url": "https://downloads.wordpress.org/plugin/wporg-site-icon.latest-stable.zip",
+ "url": "https://downloads.wordpress.org/plugin/wp-time-background-color.latest-stable.zip",
"icon": "download-alt"
}
- ]
+ ],
+ "tone": "error",
+ "status": {
+ "icon": "π€",
+ "text": "Closed: Unused"
+ }
},
{
"title": "Hello World Greeting",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=342576",
- "badges": [
- {
- "label": "Rejected",
- "tone": "error"
- }
- ],
"meta": [
{
"text": "rejected-hello-world-greeting-rejected-2"
- },
- {
- "text": "Updated 2 months ago",
- "tooltip": "2026-07-30"
}
],
- "links": [
- {
- "text": "View on WordPress.org",
- "url": "https://wordpress.org/plugins/rejected-hello-world-greeting-rejected-2/",
- "icon": "link"
- }
- ]
+ "age": {
+ "text": "2mo",
+ "tooltip": "Updated 2026-07-30"
+ },
+ "links": [],
+ "tone": "muted",
+ "status": {
+ "icon": "π«",
+ "text": "Rejected"
+ }
},
{
"title": "Hello World Greeting",
"url": "https://wordpress.org/plugins/wp-admin/post.php?action=edit&post=321620",
- "badges": [
- {
- "label": "Rejected",
- "tone": "error"
- }
- ],
"meta": [
{
"text": "rejected-hello-world-greeting-rejected"
- },
- {
- "text": "Updated 4 months ago",
- "tooltip": "2026-06-01"
}
],
- "links": [
- {
- "text": "View on WordPress.org",
- "url": "https://wordpress.org/plugins/rejected-hello-world-greeting-rejected/",
- "icon": "link"
- }
- ]
+ "age": {
+ "text": "4mo",
+ "tooltip": "Updated 2026-06-01"
+ },
+ "links": [],
+ "tone": "muted",
+ "status": {
+ "icon": "π«",
+ "text": "Rejected"
+ }
}
]
},
@@ -627,20 +898,62 @@
{
"type": "items",
"items": [
+ {
+ "title": "Arya Stark Testing",
+ "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=111841",
+ "meta": [
+ {
+ "text": "arya-stark-testing"
+ }
+ ],
+ "age": {
+ "text": "6mo",
+ "tooltip": "Updated 2026-03-28"
+ },
+ "links": [],
+ "tone": "warning",
+ "status": {
+ "icon": "β³",
+ "text": "In review or rejected"
+ }
+ },
+ {
+ "title": "Canary Build",
+ "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=132216",
+ "meta": [
+ {
+ "text": "canary-build"
+ }
+ ],
+ "age": {
+ "text": "3mo",
+ "tooltip": "Updated 2026-07-02"
+ },
+ "links": [],
+ "tone": "warning",
+ "status": {
+ "icon": "β³",
+ "text": "In review or rejected"
+ }
+ },
{
"title": "Test theme",
"url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=10234",
- "badges": [],
"meta": [
{
"text": "testr-2__trashed"
- },
- {
- "text": "Updated 6 years ago",
- "tooltip": "2020-07-07"
}
],
+ "age": {
+ "text": "6y",
+ "tooltip": "Updated 2020-07-07"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/theme/testr-2__trashed?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/themes/testr-2__trashed/",
@@ -656,17 +969,21 @@
{
"title": "Test theme",
"url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=10235",
- "badges": [],
"meta": [
{
"text": "testr-3__trashed"
- },
- {
- "text": "Updated 6 years ago",
- "tooltip": "2020-07-07"
}
],
+ "age": {
+ "text": "6y",
+ "tooltip": "Updated 2020-07-07"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/theme/testr-3__trashed?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/themes/testr-3__trashed/",
@@ -682,17 +999,21 @@
{
"title": "The Bootstrap",
"url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=6007",
- "badges": [],
"meta": [
{
"text": "the-bootstrap"
- },
- {
- "text": "Updated 1 year ago",
- "tooltip": "2025-07-24"
}
],
+ "age": {
+ "text": "1y",
+ "tooltip": "Updated 2025-07-24"
+ },
"links": [
+ {
+ "text": "Scans in Gandalf",
+ "url": "https://gandalf.wordpress.org/admin/subjects/theme/the-bootstrap?tab=runs",
+ "icon": "shield"
+ },
{
"text": "View on WordPress.org",
"url": "https://wordpress.org/themes/the-bootstrap/",
@@ -708,21 +1029,15 @@
{
"title": "Testr",
"url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=12500",
- "badges": [
- {
- "label": "Suspended",
- "tone": "error"
- }
- ],
"meta": [
{
"text": "testr"
- },
- {
- "text": "Updated 6 years ago",
- "tooltip": "2020-07-07"
}
],
+ "age": {
+ "text": "6y",
+ "tooltip": "Updated 2020-07-07"
+ },
"links": [
{
"text": "View on WordPress.org",
@@ -734,59 +1049,12 @@
"url": "https://downloads.wordpress.org/theme/testr.latest-stable.zip",
"icon": "download-alt"
}
- ]
- },
- {
- "title": "Arya Stark Testing",
- "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=111841",
- "badges": [
- {
- "label": "In Review or Rejected",
- "tone": "warning"
- }
- ],
- "meta": [
- {
- "text": "arya-stark-testing"
- },
- {
- "text": "Updated 6 months ago",
- "tooltip": "2026-03-28"
- }
- ],
- "links": [
- {
- "text": "View on WordPress.org",
- "url": "https://wordpress.org/themes/arya-stark-testing/",
- "icon": "link"
- }
- ]
- },
- {
- "title": "Canary Build",
- "url": "https://wordpress.org/themes/wp-admin/post.php?action=edit&post=132216",
- "badges": [
- {
- "label": "In Review or Rejected",
- "tone": "warning"
- }
- ],
- "meta": [
- {
- "text": "canary-build"
- },
- {
- "text": "Updated 3 months ago",
- "tooltip": "2026-07-02"
- }
],
- "links": [
- {
- "text": "View on WordPress.org",
- "url": "https://wordpress.org/themes/canary-build/",
- "icon": "link"
- }
- ]
+ "tone": "error",
+ "status": {
+ "icon": "β",
+ "text": "Suspended"
+ }
}
]
}
diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md
index 24eebc6b38..269363033c 100644
--- a/freescout.wordpress.net/AGENTS.md
+++ b/freescout.wordpress.net/AGENTS.md
@@ -11,6 +11,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco
- `Modules//` β one directory per module (`module.json`, `Providers/`, `Http/`, `Resources/`, `Database/Migrations/`, `Public/`, `tests/`).
- `WPOrgAkismet` β checks new conversations from senders with Akismet (`rest.akismet.com`, its own key), and reports agents' corrections.
- `WPOrgHelpScoutImport` β imports HelpScout mailboxes' conversations, with their tags and custom fields, and their saved replies, from HelpScout's API, page by page on the queue, without the events live email fires, and points WordPress.org's copy of conversations at the imported ones as each mailbox switches. Administrators run it under Manage Β» HelpScout Import. Remove it once every mailbox has moved.
+ - `WPOrgPluginReview` β the latest plugin review of a conversation in its sidebar, for the plugins team: the details, flags, and issues of its review emails' Review ID lines. It indexes which replies are review emails, and of what type; `php artisan wporgpluginreview:index` indexes them again. Only in the plugins team's mailbox, and, with the Teams module, only for members of the teams whose names start with "Plugin". Issue names and replies to copy are in its `Services/IssueNames.php` and `Services/FlagReplies.php`. The index keeps each review email's plugin ID too, which the API's conversation list takes as `reviewPluginId`.
- `WPOrgSearch` β gives searches without other filters core's After filter, a year ago unless the agent picks a date, and runs core's conversation search through its `search.conversations.perform` hook in one query with `COUNT(*) OVER()`, stopped after 20 seconds on MariaDB.
- `WPOrgSidebar` β WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`.
- `WPOrgSite` β tweaks for how WordPress.org runs FreeScout, rather than features; add new ones here instead of starting a module. So far: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`).
diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md
index 85afa79b3e..8b421e41fd 100644
--- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md
+++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md
@@ -60,8 +60,8 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's
1. In HelpScout, stop the mailbox's events going to `api.wordpress.org/dotorg/helpscout/webhook.php`, its webhook under **Manage Β» Apps**: scope it to the mailboxes still in HelpScout, or turn it off once none is left. While it runs for the mailbox, anything done there writes HelpScout's copy of a conversation back, and counts in HelpScout's stats. It leaves alone the HelpScout conversations whose copies FreeScout's replaced, as each copy names the one it replaced, but not the others.
2. Run the last import of the mailbox.
3. Together with moving the mailbox's email to FreeScout, press **Point WordPress.org at FreeScout** for it, under **WordPress.org** on the import page. That sends WordPress.org which FreeScout conversation each HelpScout one was imported as, 250 at a time, on the queue (`api.wordpress.org/dotorg/freescout/replace-copies.php`), without importing anything again. The copies of conversations in FreeScout's Deleted folder, or marked spam, are deleted instead. The import page shows how many were sent, and when it's done. If WordPress.org doesn't take a batch, it says why, and **Send again** sends them all again; what was sent already doesn't change.
- 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID` and `FREESCOUT_API_KEY`, FreeScout's global API key, on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. The plugin directory writes as FreeScout user 81, connected to wordpressdotorg, which needs access to the Plugins mailbox. The queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed.
- 5. After the cutover, import the mailbox again, for what HelpScout got in between. Every import that finishes after the switch sends all of the mailbox's imported conversations again.
+ 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID` and `FREESCOUT_API_KEY`, FreeScout's global API key, on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. The plugin directory writes as FreeScout user 81, connected to wordpressdotorg, which needs access to the Plugins mailbox. The queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed. After the last import, run `php artisan wporgpluginreview:index`, so `WPOrgPluginReview` has the imported review emails indexed before they're opened.
+ 5. After the cutover, import the mailbox again, for what HelpScout got in between, and for the Plugins mailbox, run `php artisan wporgpluginreview:index` again. Every import that finishes after the switch sends all of the mailbox's imported conversations again.
Until a mailbox switches, `WPOrgWebhooks` sends imported conversations' events for stats, but not the conversations, so WordPress.org goes on pointing at HelpScout; after, it sends them, naming the HelpScout conversation each replaces.
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Config/config.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Config/config.php
new file mode 100644
index 0000000000..62326841be
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Config/config.php
@@ -0,0 +1,18 @@
+ 'WPOrgPluginReview',
+
+ // Base URL of the WordPress.org helpdesk endpoints, with a trailing slash.
+ 'api_url' => env( 'WPORG_API_URL', 'https://api.wordpress.org/dotorg/freescout/' ),
+
+ // Shared secret used to sign requests; must match FREESCOUT_SECRET on api.wordpress.org.
+ 'secret' => env( 'WPORG_API_SECRET', '' ),
+);
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Console/IndexReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Console/IndexReviews.php
new file mode 100644
index 0000000000..5501494674
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Console/IndexReviews.php
@@ -0,0 +1,57 @@
+option( 'now' ) ) {
+ IndexReviewsJob::dispatch();
+ $this->line( 'Indexing the review emails on the queue.' );
+
+ return 0;
+ }
+
+ $last = 0;
+ do {
+ $last = ( new IndexReviewsJob( $last ) )->index_batch();
+ } while ( null !== $last );
+
+ $this->line( 'Indexed the review emails.' );
+
+ return 0;
+ }
+}
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php
new file mode 100644
index 0000000000..931d7fd3f6
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Database/Migrations/2026_10_03_000000_create_wporgpluginreview_tables.php
@@ -0,0 +1,62 @@
+increments( 'id' );
+ $table->unsignedInteger( 'thread_id' )->unique();
+ $table->unsignedInteger( 'conversation_id' )->index();
+ $table->string( 'type', 50 );
+ $table->timestamp( 'reviewed_at' )->nullable();
+ $table->unsignedInteger( 'plugin_id' )->default( 0 );
+ $table->index( array( 'plugin_id', 'conversation_id' ) );
+ }
+ );
+
+ /*
+ * The table is created as the module is first switched on, so the review emails FreeScout already has are indexed
+ * on the queue; `wporgpluginreview:index` indexes them again, like after the module was off for a while.
+ */
+ try {
+ IndexReviews::dispatch();
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not queue indexing the review emails: ' . $e->getMessage() );
+ }
+ }
+
+ /**
+ * Reverts the migration.
+ *
+ * @return void
+ */
+ public function down(): void {
+ Schema::dropIfExists( 'wporgpluginreview_reviews' );
+ }
+}
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php
new file mode 100644
index 0000000000..3d893b6322
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/PluginController.php
@@ -0,0 +1,121 @@
+id();
+ if ( $limiter->tooManyAttempts( $key, self::MAX_PER_MINUTE ) ) {
+ abort( 429 );
+ }
+ $limiter->hit( $key );
+
+ $conversation = Conversation::findOrFail( $conversation_id );
+ if ( ! auth()->user()->can( 'view', $conversation ) || ! app( Reviewers::class )->includes( auth()->user() ) ) {
+ abort( 403 );
+ }
+
+ $review = Review::latest( $conversation );
+ if ( ! $review ) {
+ abort( 404 );
+ }
+
+ $plugin = self::plugin( (int) $review['review_id']['plugin_id'], (string) $review['review_id']['slug'], $conversation );
+
+ return response()->json(
+ array(
+ 'plugin' => $plugin,
+ 'owner' => $review['owner'] ? self::owner( $review['owner'], $plugin ) : null,
+ )
+ );
+ }
+
+ /**
+ * The plugin as it is now; never throws, as the panel works without it.
+ *
+ * @param int $plugin_id Plugin ID, 0 if the review doesn't say.
+ * @param string $slug The plugin's slug, as the review knew it; empty if it doesn't say.
+ * @param Conversation $conversation Conversation.
+ * @return array|null
+ */
+ private static function plugin( int $plugin_id, string $slug, Conversation $conversation ): ?array {
+ if ( ( ! $plugin_id && '' === $slug ) || ! Plugins::is_configured() ) {
+ return null;
+ }
+
+ try {
+ return Plugins::get( $plugin_id, $slug, $conversation );
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not get plugin ' . ( $plugin_id ? $plugin_id : $slug ) . ': ' . $e->getMessage() );
+
+ return null;
+ }
+ }
+
+ /**
+ * Whether the domains the plugin declares carry the author's record, and whether the submitter's email address is at
+ * one of them: at the domain itself, not at a subdomain of it.
+ *
+ * @param array $owner What the review says about the owner, see Review::latest().
+ * @param array|null $plugin The plugin as it is now.
+ * @return array Author, plugin, and email: whether each matches; null when it can't be checked, the domains without a
+ * username, and email without a submitter.
+ */
+ private static function owner( array $owner, ?array $plugin ): array {
+ $submitter = is_array( $plugin['submitter'] ?? null ) ? $plugin['submitter'] : null;
+
+ // Review IDs only name the account when the review tools knew it; the submitter is the same person.
+ $username = '' !== $owner['username'] ? $owner['username'] : (string) ( $submitter['username'] ?? '' );
+ $dns = app( Dns::class );
+ $email = null;
+
+ if ( $submitter ) {
+ $host = strtolower( substr( (string) strrchr( (string) $submitter['email'], '@' ), 1 ) );
+ $email = '' !== $host && in_array( $host, array( $owner['author_host'], $owner['plugin_host'] ), true );
+ }
+
+ return array(
+ 'author' => '' !== $username ? $dns->verifies( $owner['author_host'], $username ) : null,
+ 'plugin' => '' !== $username ? $dns->verifies( $owner['plugin_host'], $username ) : null,
+ 'email' => $email,
+ );
+ }
+}
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php
new file mode 100644
index 0000000000..c3fe0066ce
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/Controllers/WorklistController.php
@@ -0,0 +1,111 @@
+user();
+
+ // With the agent's settings for the mailbox, which its folder list shows.
+ $mailbox = Mailbox::findOrFailWithSettings( $mailbox_id, $viewer->id );
+ if ( ! Review::is_plugins_mailbox( $mailbox ) ) {
+ abort( 404 );
+ }
+
+ if ( ! $viewer->can( 'viewCached', $mailbox ) || ! app( Reviewers::class )->includes( $viewer ) ) {
+ abort( 403 );
+ }
+
+ $reviewers = Worklist::reviewers( $mailbox );
+ $user = $reviewers->firstWhere( 'id', (int) $request->query( 'user', $viewer->id ) ) ?? $viewer;
+ $ids = $list->section_ids( $mailbox, $user, $viewer, $reviewers );
+
+ $tags = array();
+ $tag = 0;
+ if ( $list->tag_filter ) {
+ $requested = (int) $request->query( 'tag', 0 );
+ $tags = Worklist::tags( $ids, $requested );
+ if ( isset( $tags[ $requested ] ) ) {
+ $tag = $requested;
+ $ids = Worklist::filter_by_tag( $ids, $tag );
+ }
+ }
+
+ $sections = Worklist::load( $ids );
+ $limit = OpenOrder::limit( $viewer );
+ $open = array(
+ 'oldest' => OpenOrder::oldest_first( $sections['participating'], $limit ),
+ 'progress' => OpenOrder::progress_based( $sections['participating'], $limit ),
+ );
+ $folders = $mailbox->getAssesibleFolders();
+ $folder = $folders->first( array( $list, 'is_folder' ) ) ?? $list->folder( $mailbox );
+ $is_viewer = (int) $user->id === (int) $viewer->id;
+
+ // The folder list counts the agent's own active conversations, which may have changed since they were counted.
+ if ( $is_viewer && ! $tag ) {
+ $folder->active_count = count(
+ array_filter(
+ $sections['participating'],
+ static function ( Conversation $conversation ): bool {
+ return $conversation->isActive();
+ }
+ )
+ );
+ }
+
+ return view(
+ 'wporgpluginreview::worklist',
+ array(
+ 'list' => $list,
+ 'mailbox' => $mailbox,
+ 'folders' => $folders,
+ 'folder' => $folder,
+ 'user' => $user,
+ 'is_viewer' => $is_viewer,
+ 'reviewers' => $reviewers->contains( 'id', $viewer->id ) ? $reviewers : $reviewers->prepend( $viewer ),
+ 'sections' => $sections,
+ 'tags' => $tags,
+ 'tag' => $tag,
+ 'open' => $open,
+ 'limit' => $limit,
+ )
+ );
+ }
+}
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php
new file mode 100644
index 0000000000..743687a874
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Http/routes.php
@@ -0,0 +1,30 @@
+ array( 'web', 'auth' ),
+ 'prefix' => \Helper::getSubdirectory(),
+ 'namespace' => 'Modules\WPOrgPluginReview\Http\Controllers',
+ ),
+ static function (): void {
+ Route::get( '/wporgpluginreview/{conversation_id}/plugin', 'PluginController@show' )
+ ->where( 'conversation_id', '[0-9]+' )
+ ->name( 'wporgpluginreview.plugin' );
+
+ Route::get( '/wporgpluginreview/mailbox/{mailbox_id}/{worklist}', 'WorklistController@show' )
+ ->where(
+ array(
+ 'mailbox_id' => '[0-9]+',
+ 'worklist' => implode( '|', \Modules\WPOrgPluginReview\Services\Worklist::slugs() ),
+ )
+ )
+ ->name( 'wporgpluginreview.worklist' );
+ }
+);
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Jobs/IndexReviews.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Jobs/IndexReviews.php
new file mode 100644
index 0000000000..f838db9bf2
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Jobs/IndexReviews.php
@@ -0,0 +1,117 @@
+after_id = $after_id;
+ }
+
+ /**
+ * Indexes the next batch of threads, and queues the one after.
+ *
+ * @return void
+ */
+ public function handle(): void {
+ $last = $this->index_batch();
+
+ if ( null !== $last ) {
+ self::dispatch( $last );
+ }
+ }
+
+ /**
+ * Indexes the next batch of threads.
+ *
+ * @return int|null ID of the last thread read, to start the next batch after; null once there are no more.
+ */
+ public function index_batch(): ?int {
+ $mailbox_ids = Review::plugins_mailbox_ids();
+ if ( ! $mailbox_ids ) {
+ return null;
+ }
+
+ $last = $this->after_id;
+
+ for ( $chunk = 0; $chunk < self::CHUNKS_PER_JOB; $chunk++ ) {
+ $threads = Thread::query()
+ ->where( 'id', '>', $last )
+ ->where( 'type', Thread::TYPE_MESSAGE )
+ ->where( 'state', Thread::STATE_PUBLISHED )
+ ->whereIn(
+ 'conversation_id',
+ static function ( QueryBuilder $conversations ) use ( $mailbox_ids ): void {
+ $conversations->select( 'id' )->from( 'conversations' )->whereIn( 'mailbox_id', $mailbox_ids );
+ }
+ )
+ ->where( 'body', 'like', '%Review%' )
+ ->orderBy( 'id' )
+ ->limit( self::CHUNK )
+ ->get();
+
+ foreach ( $threads as $thread ) {
+ Review::index_thread( $thread );
+ $last = (int) $thread->id;
+ }
+
+ if ( $threads->count() < self::CHUNK ) {
+ return null;
+ }
+ }
+
+ return $last;
+ }
+}
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php
new file mode 100644
index 0000000000..1a1ec13013
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Providers/WPOrgPluginReviewServiceProvider.php
@@ -0,0 +1,532 @@
+mergeConfigFrom( __DIR__ . '/../Config/config.php', self::ALIAS );
+ $this->loadViewsFrom( __DIR__ . '/../Resources/views', self::ALIAS );
+ $this->loadRoutesFrom( __DIR__ . '/../Http/routes.php' );
+ $this->loadMigrationsFrom( __DIR__ . '/../Database/Migrations' );
+ $this->commands( array( IndexReviews::class ) );
+
+ $this->register_hooks();
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not boot: ' . $e->getMessage() );
+ }
+ }
+
+ /**
+ * Registers the Eventy hooks.
+ *
+ * @return void
+ */
+ private function register_hooks(): void {
+ \Eventy::addFilter(
+ 'stylesheets',
+ static function ( mixed $styles = array() ): mixed {
+ if ( is_array( $styles ) && app( Reviewers::class )->includes( auth()->user() ) ) {
+ $styles[] = \Module::getPublicPath( self::ALIAS ) . '/css/review.css';
+ }
+
+ return $styles;
+ }
+ );
+
+ \Eventy::addFilter(
+ 'javascripts',
+ static function ( mixed $javascripts = array() ): mixed {
+ if ( is_array( $javascripts ) && app( Reviewers::class )->includes( auth()->user() ) ) {
+ $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/review.js';
+ $javascripts[] = \Module::getPublicPath( self::ALIAS ) . '/js/downloads.js';
+ }
+
+ return $javascripts;
+ }
+ );
+
+ /*
+ * Callbacks take what core passes untyped, and check it: a TypeError in a hook isn't caught, and would fail the
+ * request.
+ */
+ \Eventy::addAction(
+ 'thread.created',
+ static function ( mixed $thread = null ): void {
+ if ( $thread instanceof Thread ) {
+ Review::index_thread( $thread );
+ }
+ }
+ );
+
+ /*
+ * Agents' replies are saved as drafts first, and published by an update; merging moves threads into the other
+ * conversation by updating them too. Other updates, like being opened, change nothing.
+ */
+ \Eventy::addAction(
+ 'thread.updated',
+ static function ( mixed $thread = null ): void {
+ // Core fires it before the model takes its changes in, while they're still dirty.
+ if ( $thread instanceof Thread && $thread->isDirty( Review::INDEXED_FIELDS ) ) {
+ Review::index_thread( $thread );
+ }
+ }
+ );
+
+ \Eventy::addAction(
+ 'thread.deleting',
+ static function ( mixed $thread = null ): void {
+ if ( $thread instanceof Thread ) {
+ Review::index_thread( $thread, true );
+ }
+ }
+ );
+
+ // Moving a conversation to another mailbox makes its reviews count, or not, without changing them.
+ \Eventy::addAction(
+ 'conversation.moved',
+ static function ( mixed $conversation = null ): void {
+ if ( $conversation instanceof Conversation ) {
+ Review::reindex( $conversation );
+ }
+ }
+ );
+
+ // Core deletes conversations' threads with a query, which their hooks don't see.
+ \Eventy::addAction(
+ 'conversation.deleting',
+ static function ( mixed $conversation = null ): void {
+ if ( $conversation instanceof Conversation ) {
+ Review::forget( array( (int) $conversation->id ) );
+ }
+ }
+ );
+
+ \Eventy::addAction(
+ 'conversations.before_delete_forever',
+ static function ( mixed $conversation_ids = null ): void {
+ if ( is_array( $conversation_ids ) ) {
+ Review::forget( $conversation_ids );
+ }
+ }
+ );
+
+ \Eventy::addAction(
+ 'conversation.after_customer_sidebar',
+ static function ( mixed $conversation = null ): void {
+ if ( $conversation instanceof Conversation ) {
+ self::render_panel( $conversation );
+ }
+ },
+ self::PANEL_PRIORITY
+ );
+
+ \Eventy::addFilter( 'api.conversations.query', array( PluginConversations::class, 'filter' ), 20, 2 );
+
+ self::register_subject_hooks();
+ self::register_folder_hooks();
+ self::register_profile_hooks();
+
+ // The page's script shows it once there are URLs.
+ \Eventy::addAction(
+ 'menu.append',
+ static function (): void {
+ try {
+ if ( app( Reviewers::class )->includes( auth()->user() ) ) {
+ echo \View::make( self::ALIAS . '::downloads' )->render();
+ }
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not add the download URLs button to the header: ' . $e->getMessage() );
+ }
+ }
+ );
+ }
+
+ /**
+ * Shows the plugin directory's subjects marked, like "π My Plugin", for the same agents as the panel: in the
+ * conversation, its tab's title, the sender's previous conversations, and lists of conversations. The subject itself
+ * shows on hover, in the subject's editor, and when copied.
+ *
+ * @return void
+ */
+ private static function register_subject_hooks(): void {
+ // Lists ask for each row, and the answer doesn't change within a request.
+ $mailbox_ids = null;
+ $marks = static function ( mixed $conversation ) use ( &$mailbox_ids ): bool {
+ if ( ! $conversation instanceof Conversation ) {
+ return false;
+ }
+
+ if ( null === $mailbox_ids ) {
+ $mailbox_ids = app( Reviewers::class )->includes( auth()->user() ) ? Review::plugins_mailbox_ids() : array();
+ }
+
+ return in_array( (int) $conversation->mailbox_id, $mailbox_ids, true );
+ };
+
+ \Eventy::addAction(
+ 'conversation.after_subject',
+ static function ( mixed $conversation = null ) use ( $marks ): void {
+ try {
+ if ( $marks( $conversation ) ) {
+ echo \View::make(
+ self::ALIAS . '::subjects',
+ array(
+ 'conversation_id' => (int) $conversation->id,
+ 'number' => (int) $conversation->number,
+ 'subject' => (string) $conversation->getSubject(),
+ 'short_subject' => Subject::short( (string) $conversation->subject ),
+ 'subjects' => self::marked_subjects( $conversation ),
+ )
+ )->render();
+ }
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not mark the subjects of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() );
+ }
+ }
+ );
+
+ // The subject goes in between, hidden, for the row and FreeScout's scripts; last, so other modules' tags stay out of it.
+ $hidden = false;
+ \Eventy::addAction(
+ 'conversations_table.before_subject',
+ static function ( mixed $conversation = null ) use ( $marks, &$hidden ): void {
+ try {
+ $marked = $marks( $conversation ) ? Subject::marked( (string) $conversation->subject ) : '';
+ if ( '' !== $marked ) {
+ $subject = e( $conversation->getSubject() );
+ echo '' . e( $marked ) . '';
+ $hidden = true;
+ }
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not mark the subject of conversation ' . ( $conversation->id ?? '' ) . ': ' . $e->getMessage() );
+ }
+ },
+ PHP_INT_MAX
+ );
+
+ \Eventy::addAction(
+ 'conversations_table.after_subject',
+ static function () use ( &$hidden ): void {
+ if ( $hidden ) {
+ echo '';
+ $hidden = false;
+ }
+ },
+ PHP_INT_MIN
+ );
+ }
+
+ /**
+ * The marked subjects of a conversation and of its sender's latest others in its mailbox, which its sidebar lists.
+ *
+ * @param Conversation $conversation Conversation.
+ * @return array[] Subject, and marked subject, by conversation ID; only those with a mark.
+ */
+ private static function marked_subjects( Conversation $conversation ): array {
+ $conversations = array( $conversation );
+ if ( $conversation->customer_id ) {
+ $previous = Conversation::where( 'mailbox_id', $conversation->mailbox_id )
+ ->where( 'customer_id', $conversation->customer_id )
+ ->where( 'id', '<>', $conversation->id )
+ ->where( 'status', '!=', Conversation::STATUS_SPAM )
+ ->where( 'state', Conversation::STATE_PUBLISHED )
+ ->orderBy( 'created_at', 'desc' )
+ ->limit( ConversationsController::PREV_CONVERSATIONS_LIMIT )
+ ->get();
+ $conversations = array_merge( $conversations, $previous->all() );
+ }
+
+ $subjects = array();
+ foreach ( $conversations as $item ) {
+ $marked = Subject::marked( (string) $item->subject );
+ if ( '' !== $marked ) {
+ $subjects[ (int) $item->id ] = array(
+ 'subject' => (string) $item->getSubject(),
+ 'marked' => $marked,
+ );
+ }
+ }
+
+ return $subjects;
+ }
+
+ /**
+ * Lets members of the plugins team's teams choose in their profile how many conversations the worklists' Open
+ * buttons open at a time.
+ *
+ * @return void
+ */
+ private static function register_profile_hooks(): void {
+ \Eventy::addAction(
+ 'user.edit.before_photo',
+ static function ( mixed $user = null ): void {
+ try {
+ if ( $user instanceof User && app( Reviewers::class )->includes( $user ) ) {
+ echo \View::make(
+ self::ALIAS . '::profile',
+ array(
+ 'limit' => OpenOrder::limit( $user ),
+ 'max' => OpenOrder::MAX_LIMIT,
+ )
+ )->render();
+ }
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not show how many conversations to open in a profile: ' . $e->getMessage() );
+ }
+ }
+ );
+
+ \Eventy::addFilter(
+ 'user.save_profile',
+ static function ( mixed $user = null, mixed $request = null ): mixed {
+ try {
+ if ( $user instanceof User && $request instanceof Request && $request->has( 'wporgpluginreview_open_limit' ) && app( Reviewers::class )->includes( $user ) ) {
+ OpenOrder::set_limit( $user, $request->input( 'wporgpluginreview_open_limit' ) );
+ }
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not save how many conversations to open in a profile: ' . $e->getMessage() );
+ }
+
+ return $user;
+ },
+ 20,
+ 2
+ );
+
+ \Eventy::addAction(
+ 'user.deleted',
+ static function ( mixed $user = null ): void {
+ try {
+ if ( $user instanceof User ) {
+ OpenOrder::forget_limit( (int) $user->id );
+ }
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not forget how many conversations a deleted user opens: ' . $e->getMessage() );
+ }
+ }
+ );
+ }
+
+ /**
+ * Adds the My Reviews and My Cases folders to the plugins team's mailbox, for its members.
+ *
+ * They aren't stored: they're added to the mailbox's folders as they're listed, after the Teams and Custom Folders
+ * modules have sorted theirs, and link to their own pages. Their counts are kept in the cache until a change may
+ * affect them.
+ *
+ * @return void
+ */
+ private static function register_folder_hooks(): void {
+ \Eventy::addFilter(
+ 'mailbox.folders',
+ static function ( mixed $folders = null, mixed $mailbox = null ): mixed {
+ if ( ! $folders instanceof Collection || ! $mailbox instanceof Mailbox ) {
+ return $folders;
+ }
+
+ try {
+ $user = auth()->user();
+ if ( ! Review::is_plugins_mailbox( $mailbox ) || ! app( Reviewers::class )->includes( $user ) ) {
+ return $folders;
+ }
+
+ return Worklist::add_folders( $folders, $mailbox, $user );
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not add the worklist folders to mailbox ' . $mailbox->id . ': ' . $e->getMessage() );
+
+ return $folders;
+ }
+ },
+ self::FOLDERS_PRIORITY,
+ 2
+ );
+
+ \Eventy::addFilter(
+ 'folder.type_name',
+ static function ( mixed $name = '', mixed $folder = null ): mixed {
+ $worklist = Worklist::of_folder( $folder );
+
+ return $worklist ? $worklist->name() : $name;
+ },
+ self::FOLDERS_PRIORITY,
+ 2
+ );
+
+ \Eventy::addFilter(
+ 'folder.type_icon',
+ static function ( mixed $icon = '', mixed $folder = null ): mixed {
+ $worklist = Worklist::of_folder( $folder );
+
+ return $worklist ? $worklist->icon : $icon;
+ },
+ self::FOLDERS_PRIORITY,
+ 2
+ );
+
+ \Eventy::addFilter(
+ 'folder.url',
+ static function ( mixed $url = '', mixed $mailbox_id = null, mixed $folder = null ): mixed {
+ $worklist = Worklist::of_folder( $folder );
+
+ return $worklist && is_numeric( $mailbox_id ) ? $worklist->url( (int) $mailbox_id ) : $url;
+ },
+ self::FOLDERS_PRIORITY,
+ 3
+ );
+
+ // The folders' counts change with agents' replies; only those published, removed, or moved.
+ foreach ( array( 'thread.created', 'thread.updated', 'thread.deleting' ) as $hook ) {
+ \Eventy::addAction(
+ $hook,
+ static function ( mixed $thread = null ) use ( $hook ): void {
+ if ( ! $thread instanceof Thread || Thread::TYPE_MESSAGE !== (int) $thread->type || ! $thread->created_by_user_id ) {
+ return;
+ }
+
+ // Core fires it before the model takes its changes in, while they're still dirty.
+ if ( 'thread.updated' === $hook && ! $thread->isDirty( array( 'state', 'type', 'conversation_id' ) ) ) {
+ return;
+ }
+
+ self::forget_counts( $thread->conversation );
+ }
+ );
+ }
+
+ /*
+ * And with conversations that open, close, change hands, or are deleted, merged, or imported. An author's reply
+ * makes a conversation active without core's status_changed.
+ */
+ $hooks = array(
+ 'conversation.customer_replied',
+ 'conversation.status_changed',
+ 'conversation.user_changed',
+ 'conversation.state_changed',
+ 'conversation.merged',
+ 'conversation.deleting',
+ 'wporghelpscoutimport.conversation_imported',
+ );
+ foreach ( $hooks as $hook ) {
+ \Eventy::addAction(
+ $hook,
+ static function ( mixed $conversation = null ): void {
+ self::forget_counts( $conversation );
+ }
+ );
+ }
+
+ \Eventy::addAction(
+ 'conversation.moved',
+ static function ( mixed $conversation = null, mixed $user = null, mixed $prev_mailbox = null ): void {
+ self::forget_counts( $conversation );
+
+ if ( $prev_mailbox instanceof Mailbox ) {
+ Worklist::forget_counts( (int) $prev_mailbox->id );
+ }
+ },
+ 20,
+ 3
+ );
+ }
+
+ /**
+ * Forgets the worklists' counts of a conversation's mailbox.
+ *
+ * @param mixed $conversation Conversation.
+ * @return void
+ */
+ private static function forget_counts( mixed $conversation ): void {
+ if ( $conversation instanceof Conversation && $conversation->mailbox_id ) {
+ Worklist::forget_counts( (int) $conversation->mailbox_id );
+ }
+ }
+
+ /**
+ * Renders the latest review of a conversation that has one, for the plugins team.
+ *
+ * @param Conversation $conversation Conversation being viewed.
+ * @return void
+ */
+ private static function render_panel( Conversation $conversation ): void {
+ try {
+ // The mailbox is known already; the teams may take a query.
+ if ( ! Review::is_plugins_mailbox( $conversation->mailbox ) || ! app( Reviewers::class )->includes( auth()->user() ) ) {
+ return;
+ }
+
+ $review = Review::latest( $conversation );
+ if ( ! $review ) {
+ return;
+ }
+
+ echo \View::make(
+ self::ALIAS . '::panel',
+ array(
+ 'review' => $review,
+ 'replies' => FlagReplies::REPLIES,
+ 'conversation_id' => (int) $conversation->id,
+ )
+ )->render();
+ } catch ( \Throwable $e ) {
+ \Log::error( '[WPOrgPluginReview] Could not render the review of conversation ' . $conversation->id . ': ' . $e->getMessage() );
+ }
+ }
+}
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css
new file mode 100644
index 0000000000..94d6a70d38
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/css/review.css
@@ -0,0 +1,506 @@
+/**
+ * Styles the Plugin Review panel in the conversation sidebar, the buttons it adds to the Plugins & Themes panel, and the
+ * My Reviews and My Cases folders.
+ *
+ * Text colors meet WCAG AAA (7:1) on the panel background.
+ */
+
+.wporg-review .panel-body {
+ font-size: 13px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.wporg-review-heading {
+ margin: 12px 0 4px;
+ padding-top: 12px;
+ border-top: 1px solid #d6dde3;
+ color: #2a3b47;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.wporg-review-details {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 2px 8px;
+ margin: 4px 0 0;
+}
+
+.wporg-review-details dt {
+ color: #4a5561;
+ font-weight: 400;
+}
+
+.wporg-review-details dd {
+ margin: 0;
+}
+
+.wporg-review-copy {
+ margin-left: 2px;
+ padding: 0 2px;
+ border: 0;
+ background: none;
+ color: #4a5561;
+ font-size: 11px;
+}
+
+.wporg-review-copy:hover,
+.wporg-review-copy:focus {
+ color: #0078d7;
+}
+
+.wporg-review-copied {
+ margin-left: 4px;
+ color: #175a28;
+ font-size: 11px;
+}
+
+.wporg-review-flags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+/* Flags without a color of their own are orange. */
+.wporg-review-flag {
+ padding: 0 8px;
+ border-radius: 9px;
+ background-color: #ff8918;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 20px;
+}
+
+.wporg-review-flag[data-flag="UPD"] {
+ background-color: #dc2626;
+}
+
+.wporg-review-flag[data-flag="TRM"] {
+ background-color: #f59e0b;
+}
+
+.wporg-review-flag[data-flag="OWN"] {
+ background-color: #8c6e1e;
+}
+
+.wporg-review-flag[data-flag="DESC"] {
+ background-color: #4f3d0c;
+}
+
+.wporg-review-flag[data-flag="LIC"] {
+ background-color: #a72e9e;
+}
+
+.wporg-review-flag[data-flag="COM"] {
+ background-color: #0c4f4d;
+}
+
+.wporg-review-flag[data-flag="FUN"],
+.wporg-review-flag[data-flag="ACT"] {
+ background-color: #1d4ed8;
+}
+
+.wporg-review-flag[data-flag="RMT"] {
+ background-color: #b91c1c;
+}
+
+.wporg-review-flag[data-flag="SOCK"] {
+ background-color: #ffcafe;
+ color: #444;
+}
+
+.wporg-review-flag .wporg-review-copy {
+ color: inherit;
+}
+
+.wporg-review-badge {
+ margin-left: 4px;
+ padding: 0 6px;
+ border-radius: 9px;
+ background-color: #e1f4e5;
+ color: #175a28;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.wporg-review-issues {
+ margin: 0;
+ padding-left: 18px;
+}
+
+.wporg-review-issue {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: #4a5561;
+ font-weight: 400;
+ text-align: left;
+}
+
+.wporg-review-issue:hover,
+.wporg-review-issue:focus {
+ text-decoration: underline;
+}
+
+.wporg-review-issue.is-priority-2 {
+ font-weight: 700;
+}
+
+.wporg-review-issue.is-priority-0 {
+ opacity: 0.6;
+}
+
+/* Where an issue the panel jumped to starts, for a moment. */
+.wporg-review-highlight {
+ background-color: #fff6bf;
+ transition: background-color 0.6s ease;
+}
+
+.wporg-review-action {
+ margin-left: 4px;
+ color: #4a5561;
+}
+
+/* Names and Owner: a section of their own, colored by whether the author followed the review. */
+.wporg-review-check {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid #d6dde3;
+}
+
+.wporg-review-check .wporg-review-heading {
+ margin-top: 0;
+ padding-top: 0;
+ border-top: 0;
+}
+
+.wporg-review-check.is-followed,
+.wporg-review-check.is-ignored,
+.wporg-review-check.is-to-review {
+ padding: 8px 10px;
+ border: 1px solid;
+ border-left-width: 4px;
+ border-radius: 4px;
+}
+
+.wporg-review-check.is-followed {
+ border-color: #00a32a;
+ background-color: #edfaef;
+}
+
+.wporg-review-check.is-ignored {
+ border-color: #d63638;
+ background-color: #fcf0f1;
+}
+
+.wporg-review-check.is-to-review {
+ border-color: #dba617;
+ background-color: #fcf9e8;
+}
+
+.wporg-review-email {
+ color: #8a3a00;
+}
+
+.wporg-review-error {
+ color: #8c1616;
+}
+
+/* The warning above the reply editor, for a Review ID of another author. */
+.wporg-review-mismatch .btn {
+ margin-left: 4px;
+}
+
+.wporg-review-email.is-match {
+ color: #175a28;
+}
+
+/*
+ * The My Reviews and My Cases folders: their own table, like the conversation lists, rather than theirs, which core's
+ * scripts reload with a folder's conversations.
+ */
+
+/* In the folder list, they're with Unassigned and Mine, apart from the rest: Mine comes right after them. */
+#folders > li[data-folder_id="-229"] + li:not([data-folder_id="-230"]),
+#folders > li[data-folder_id="-230"] + li {
+ margin-bottom: 6px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid #e3e8eb;
+}
+
+.wporg-worklist-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 14px;
+ border-bottom: 1px solid #e3e8eb;
+}
+
+.wporg-worklist-toolbar label {
+ margin: 0;
+}
+
+.wporg-worklist-toolbar .wporg-worklist-filter {
+ width: auto;
+ max-width: 100%;
+}
+
+.wporg-worklist-scope {
+ font-size: 13px;
+}
+
+.wporg-worklist-section + .wporg-worklist-section {
+ border-top: 1px solid #d6dde3;
+}
+
+.wporg-worklist-section-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px 12px;
+ padding: 18px 14px 2px;
+}
+
+.wporg-worklist-heading {
+ margin: 0;
+ color: #2a3b47;
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.wporg-worklist-count {
+ margin-left: 4px;
+ padding: 1px 7px;
+ border-radius: 9999px;
+ background-color: #eef1f4;
+ color: #3e4a55;
+ font-size: 12px;
+ vertical-align: 2px;
+}
+
+.wporg-worklist-description {
+ margin: 0;
+ padding: 0 14px 10px;
+ font-size: 13px;
+}
+
+.wporg-worklist-empty {
+ margin: 0;
+ padding: 0 14px 18px;
+ color: #4a5561;
+}
+
+.table.wporg-worklist-table {
+ margin: 0;
+ table-layout: fixed;
+}
+
+.wporg-worklist-table thead {
+ background-color: #deecf9;
+}
+
+.table.wporg-worklist-table th {
+ padding: 4px 14px 1px;
+ border-bottom: 1px solid #e3e8eb;
+ color: #2a3b47;
+ white-space: nowrap;
+}
+
+.table.wporg-worklist-table td {
+ padding: 0;
+ border-top: 0;
+ border-bottom: 1px solid #e3e8eb;
+ vertical-align: middle;
+}
+
+.wporg-worklist-table td a {
+ display: block;
+ padding: 12px 14px;
+ color: #2a3b47;
+ font-size: 14px;
+}
+
+.wporg-worklist-table td a:hover,
+.wporg-worklist-table td a:focus {
+ text-decoration: none;
+}
+
+.wporg-worklist-table tr:hover td {
+ background-color: #fbfcfd;
+}
+
+/* conv-subject is for the Tags module's scripts; core's lists move it around on phones. */
+.wporg-worklist .wporg-worklist-table .conv-subject {
+ position: static !important;
+ padding-right: 0 !important;
+ padding-left: 0 !important;
+}
+
+/* Active conversations stand out, like in core's lists. */
+.wporg-worklist-table tr.is-active td {
+ background-color: #f4f8fd;
+ font-weight: 700;
+}
+
+/* The Open buttons, and the conversations one would open while it's hovered or focused, numbered in order. */
+.wporg-worklist-open {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+
+.wporg-worklist-open-count,
+.wporg-worklist-status {
+ padding: 0 6px;
+ border-radius: 9px;
+ background-color: #eef1f4;
+ color: #3e4a55;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.wporg-worklist-open-count {
+ margin-left: 2px;
+}
+
+.wporg-worklist-open-limit {
+ color: #4a5561;
+ font-size: 12px;
+}
+
+.wporg-worklist-table tr.is-to-open td {
+ background-color: #fff6bf;
+}
+
+.wporg-worklist-table tr.is-to-open td:first-child {
+ box-shadow: inset 4px 0 0 #b38a00;
+}
+
+.wporg-worklist-open-order {
+ display: inline-block;
+ min-width: 20px;
+ margin-right: 6px;
+ padding: 0 6px;
+ border-radius: 10px;
+ background-color: #2a3b47;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 18px;
+ text-align: center;
+}
+
+.wporg-worklist-sender {
+ width: 190px;
+}
+
+.wporg-worklist-cb {
+ width: 40px;
+ text-align: center;
+}
+
+.table.wporg-worklist-table .wporg-worklist-cb {
+ padding: 4px 0;
+}
+
+/* Core's checkboxes draw their box at the left of their label, which its own lists move into place. */
+.wporg-worklist-table .conv-cb label {
+ left: 10px;
+}
+
+.wporg-worklist-threads {
+ width: 84px;
+ text-align: center;
+}
+
+.wporg-worklist-threads-count {
+ padding: 0.1em 0.5em;
+ border: 1px solid #d6dde3;
+ border-radius: 4px;
+ background: #f8f9f9;
+ color: #4a5561;
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.wporg-worklist-date {
+ width: 138px;
+}
+
+.wporg-worklist-name,
+.wporg-worklist-email,
+.wporg-worklist-title,
+.wporg-worklist-preview {
+ display: block;
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.wporg-worklist-email,
+.wporg-worklist-preview {
+ color: #4a5561;
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.wporg-worklist-preview {
+ margin-top: 2px;
+ font-size: 13px;
+}
+
+.wporg-worklist-status {
+ margin-right: 4px;
+}
+
+@media (max-width: 767px) {
+ .wporg-worklist-table thead,
+ .wporg-worklist-table .wporg-worklist-threads {
+ display: none;
+ }
+
+ /* Core hides the checkboxes of its lists on phones, which select by a long press instead. */
+ .wporg-worklist .wporg-worklist-table .wporg-worklist-cb {
+ display: table-cell !important;
+ }
+
+ .wporg-worklist-sender {
+ width: 35%;
+ }
+
+ .wporg-worklist-date {
+ width: 90px;
+ }
+}
+
+/* In the sidebar's Plugins & Themes panel, after a plugin's download link: adds its URL to the ones the header copies, or removes it. */
+.wporg-downloads-toggle {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: #4a5561;
+ font-size: inherit;
+ line-height: 1;
+}
+
+.wporg-downloads-toggle[aria-pressed="true"],
+.wporg-downloads-toggle:hover,
+.wporg-downloads-toggle:focus {
+ color: #0078d7;
+}
+
+/* In the profile: how many conversations the Open buttons open at a time. */
+.wporg-open-limit {
+ max-width: 100px;
+}
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/img/icon.svg b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/img/icon.svg
new file mode 100644
index 0000000000..8a8d7d0865
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/img/icon.svg
@@ -0,0 +1,9 @@
+
diff --git a/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js
new file mode 100644
index 0000000000..7ae09cfc08
--- /dev/null
+++ b/freescout.wordpress.net/Modules/WPOrgPluginReview/Public/js/downloads.js
@@ -0,0 +1,541 @@
+/**
+ * Collects the download URLs of the plugins in review that the agent has open, in the order they opened them, for the
+ * header's button that copies them all, to review the plugins in bulk with a local tool.
+ *
+ * Each page keeps its plugin's URL in the browser's storage, under a key of its own, so that pages opening at once
+ * don't write over each other's, and removes it as it closes. It also holds a lock of the same name for as long as it's
+ * open, which the browser lets go of however the page ends, even when it crashes, or closes without saying so: URLs
+ * whose page doesn't hold its lock anymore are left out, and removed. Pages the worklists' Open buttons opened go in
+ * the order they were opened, which their URL tells; the others in the order they loaded.
+ *
+ * The sidebar's Plugins & Themes panel gets a button next to each plugin's download link, which adds its URL, or
+ * removes it when it's there. Added URLs stay, after the others, until they're removed: no page holds them.
+ *
+ * @param {jQuery} $ jQuery.
+ */
+( function ( $ ) {
+ /**
+ * What the keys of the pages' URLs start with, in the browser's storage; their locks are named the same.
+ *
+ * @type {string}
+ */
+ const PREFIX = 'wporgpluginreview.download.';
+
+ /**
+ * What the keys of the URLs added from the sidebar start with, followed by the URL, so each is there once.
+ *
+ * @type {string}
+ */
+ const ADDED = PREFIX + 'added.';
+
+ /**
+ * What the keys of the conversations' places in the order start with, in the tab's storage, followed by their path,
+ * so a page keeps its place when it's reloaded.
+ *
+ * @type {string}
+ */
+ const PLACES = 'wporgpluginreview.download-place.';
+
+ /**
+ * The parameters the worklists' Open buttons add to the conversations they open, as review.js's OPEN_PARAMS.
+ *
+ * @type {Object}
+ */
+ const OPEN_PARAMS = {
+ batch: 'wporg_open_batch',
+ order: 'wporg_open_order',
+ };
+
+ /**
+ * How long "Copied" shows, in milliseconds.
+ *
+ * @type {number}
+ */
+ const COPIED_FOR = 1200;
+
+ /**
+ * How often a visible page checks for pages that closed without saying so, in milliseconds.
+ *
+ * @type {number}
+ */
+ const CHECK_EVERY = 5000;
+
+ /**
+ * How long a page waits for other pages' changes to settle before it shows them, in milliseconds, so that many
+ * pages opening at once don't each refresh for every other.
+ *
+ * @type {number}
+ */
+ const SETTLE_FOR = 250;
+
+ /**
+ * This page's key, different for every page that opens, even of the same conversation.
+ *
+ * @type {string}
+ */
+ const PAGE_KEY =
+ PREFIX +
+ Date.now().toString( 36 ) +
+ Math.random().toString( 36 ).slice( 2 );
+
+ /**
+ * This page's URL and place, once its plugin is known.
+ *
+ * @type {Object|null}
+ */
+ let entry = null;
+
+ /**
+ * Whether this page holds its lock.
+ *
+ * @type {boolean}
+ */
+ let locked = false;
+
+ /**
+ * The URLs the header shows, which are the ones it copies.
+ *
+ * @type {string[]}
+ */
+ let shown = [];
+
+ /**
+ * Until when the header says they were copied, as a time in milliseconds.
+ *
+ * @type {number}
+ */
+ let copiedUntil = 0;
+
+ /**
+ * The refresh waiting for other pages' changes to settle.
+ *
+ * @type {number}
+ */
+ let settling = 0;
+
+ /**
+ * One of the browser's storages, if the page may use it.
+ *
+ * @param {string} name localStorage or sessionStorage.
+ * @return {Storage|null} Storage.
+ */
+ function storage( name ) {
+ try {
+ return window[ name ] || null;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * The browser's locks, which only secure pages have.
+ *
+ * @return {LockManager|null} Locks.
+ */
+ function locks() {
+ const manager = window.navigator.locks;
+
+ return manager && manager.request && manager.query ? manager : null;
+ }
+
+ /**
+ * Where this page goes in the order: when the Open button that opened it was clicked, and in which position, or
+ * else when it loaded; first time this conversation loaded in this tab, so a reload keeps its place.
+ *
+ * @return {Object} Batch, a time in milliseconds, and order in it; 0 for pages that weren't opened together.
+ */
+ function place() {
+ const params = new URLSearchParams( window.location.search );
+ const batch = Number( params.get( OPEN_PARAMS.batch ) );
+ const order = Number( params.get( OPEN_PARAMS.order ) );
+ const session = storage( 'sessionStorage' );
+ const key = PLACES + window.location.pathname;
+
+ let found = batch > 0 && order > 0 ? { batch, order } : null;
+ if ( ! found && session ) {
+ try {
+ found = JSON.parse( session.getItem( key ) || 'null' );
+ } catch {
+ found = null;
+ }
+ }
+ if ( ! found || ! ( Number( found.batch ) > 0 ) ) {
+ found = { batch: Date.now(), order: 0 };
+ }
+
+ try {
+ session.setItem( key, JSON.stringify( found ) );
+ } catch {
+ // Then a reload puts it last.
+ }
+
+ return { batch: Number( found.batch ), order: Number( found.order ) };
+ }
+
+ /**
+ * The pages' URLs and places, as kept.
+ *
+ * @return {Object[]} Each with its key.
+ */
+ function entries() {
+ const local = storage( 'localStorage' );
+ const found = [];
+ if ( ! local ) {
+ return found;
+ }
+
+ for ( let i = 0; i < local.length; i++ ) {
+ const key = local.key( i );
+ if ( ! key || ! key.startsWith( PREFIX ) ) {
+ continue;
+ }
+
+ try {
+ const item = JSON.parse( local.getItem( key ) || 'null' );
+ if ( item && 'string' === typeof item.url && item.url ) {
+ found.push( { ...item, key } );
+ }
+ } catch {
+ // Not one of ours.
+ }
+ }
+
+ return found;
+ }
+
+ /**
+ * The ones whose page is still open, removing the others.
+ *
+ * @param {Object[]} found Entries.
+ * @return {Promise