From f4f89fa6851db5d2b25c6bf81d971a7dc3399fb2 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 7 May 2026 17:02:20 +0200 Subject: [PATCH 01/28] Fetch known-plugins.json from wp.org with daily refresh Adds a Known_Plugins loader that caches a remote copy of the mapping and falls back to the bundled JSON. A daily WP-Cron event refreshes the cache from https://ps.w.org/aaa-option-optimizer/assets/known-plugins.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- aaa-option-optimizer.php | 7 +++ src/class-known-plugins.php | 98 +++++++++++++++++++++++++++++ src/class-map-plugin-to-options.php | 6 +- src/class-plugin.php | 15 +++++ 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/class-known-plugins.php diff --git a/aaa-option-optimizer.php b/aaa-option-optimizer.php index 3bacb4d..6d5e740 100644 --- a/aaa-option-optimizer.php +++ b/aaa-option-optimizer.php @@ -37,6 +37,11 @@ function aaa_option_optimizer_activation() { // Create the custom table. Progress_Planner\OptionOptimizer\Database::create_table(); + // Schedule daily refresh of the known-plugins mapping. + if ( ! wp_next_scheduled( Progress_Planner\OptionOptimizer\Known_Plugins::CRON_HOOK ) ) { + wp_schedule_event( time(), 'daily', Progress_Planner\OptionOptimizer\Known_Plugins::CRON_HOOK ); + } + $autoload_values = \wp_autoload_values_to_autoload(); $placeholders = implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) ); @@ -73,6 +78,8 @@ function aaa_option_optimizer_activation() { function aaa_option_optimizer_deactivation() { $aaa_option_value = get_option( 'option_optimizer' ); update_option( 'option_optimizer', $aaa_option_value, false ); + + wp_clear_scheduled_hook( Progress_Planner\OptionOptimizer\Known_Plugins::CRON_HOOK ); } /** diff --git a/src/class-known-plugins.php b/src/class-known-plugins.php new file mode 100644 index 0000000..9c20e70 --- /dev/null +++ b/src/class-known-plugins.php @@ -0,0 +1,98 @@ +>|null + */ + private $list; + + /** + * Get the known-plugins mapping. + * + * @return array> + */ + public function get(): array { + if ( null !== $this->list ) { + return $this->list; + } + + $cached = \get_option( self::CACHE_KEY ); + if ( \is_array( $cached ) && ! empty( $cached ) ) { + $this->list = $cached; + return $this->list; + } + + $this->list = $this->load_bundled(); + return $this->list; + } + + /** + * Refresh the cached mapping from the remote URL. + * + * @return bool True when a fresh copy was stored, false otherwise. + */ + public function refresh(): bool { + $response = \wp_remote_get( + self::REMOTE_URL, + [ + 'timeout' => 10, + ] + ); + + if ( \is_wp_error( $response ) ) { + return false; + } + + if ( 200 !== \wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + $body = \wp_remote_retrieve_body( $response ); + $data = \json_decode( $body, true ); + + if ( ! \is_array( $data ) || empty( $data ) ) { + return false; + } + + \update_option( self::CACHE_KEY, $data, false ); + $this->list = $data; + return true; + } + + /** + * Load the JSON file bundled with the plugin. + * + * @return array> + */ + private function load_bundled(): array { + $path = \plugin_dir_path( AAA_OPTION_OPTIMIZER_FILE ) . 'known-plugins/known-plugins.json'; + if ( ! \file_exists( $path ) ) { + return []; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a file bundled with the plugin. + $data = \json_decode( (string) \file_get_contents( $path ), true ); + return \is_array( $data ) ? $data : []; + } +} diff --git a/src/class-map-plugin-to-options.php b/src/class-map-plugin-to-options.php index 799d8fe..15d781f 100644 --- a/src/class-map-plugin-to-options.php +++ b/src/class-map-plugin-to-options.php @@ -16,7 +16,7 @@ class Map_Plugin_To_Options { /** * List of plugins we can recognize. * - * @var object[] + * @var array> */ private $plugins_list = []; @@ -28,9 +28,9 @@ class Map_Plugin_To_Options { * @return string */ public function get_plugin_name( string $option ): string { - $plugins_list = []; if ( empty( $this->plugins_list ) ) { - $this->plugins_list = json_decode( file_get_contents( plugin_dir_path( AAA_OPTION_OPTIMIZER_FILE ) . 'known-plugins/known-plugins.json' ), true ); + $known = new Known_Plugins(); + $this->plugins_list = $known->get(); } // for each plugin in the list, check if the option starts with the prefix. diff --git a/src/class-plugin.php b/src/class-plugin.php index 7213d73..5d91f48 100644 --- a/src/class-plugin.php +++ b/src/class-plugin.php @@ -79,6 +79,12 @@ public function register_hooks() { // Use the shutdown action to update the option with tracked data. \add_action( 'shutdown', [ $this, 'update_tracked_options' ] ); + // Daily refresh of the known-plugins mapping. + \add_action( Known_Plugins::CRON_HOOK, [ $this, 'refresh_known_plugins' ] ); + if ( ! \wp_next_scheduled( Known_Plugins::CRON_HOOK ) ) { + \wp_schedule_event( \time(), 'daily', Known_Plugins::CRON_HOOK ); + } + // Register the REST routes. $rest = new REST(); $rest->register_hooks(); @@ -160,6 +166,15 @@ protected function add_option_usage( $option_name ) { ++$this->accessed_options[ $option_name ]; } + /** + * Refresh the cached known-plugins mapping from wp.org. + * + * @return void + */ + public function refresh_known_plugins() { + ( new Known_Plugins() )->refresh(); + } + /** * Update the tracked options at the end of the page load. * From 0e9a69f9c899d04ff5a35598458fb81e387df86e Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 7 May 2026 17:02:26 +0200 Subject: [PATCH 02/28] Sync known-plugins.json to wp.org SVN assets daily Daily GitHub Action validates known-plugins.json and publishes it to the plugin's wp.org /assets/ directory so installs can fetch updates without waiting for a release. Validator rejects malformed JSON, missing fields, and dangerously generic prefixes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/sync-known-plugins.yml | 83 +++++++++++++++++++++ bin/validate-known-plugins.php | 94 ++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 .github/workflows/sync-known-plugins.yml create mode 100644 bin/validate-known-plugins.php diff --git a/.github/workflows/sync-known-plugins.yml b/.github/workflows/sync-known-plugins.yml new file mode 100644 index 0000000..5f6b8b1 --- /dev/null +++ b/.github/workflows/sync-known-plugins.yml @@ -0,0 +1,83 @@ +name: Sync known-plugins.json to wp.org SVN + +on: + schedule: + - cron: '0 3 * * *' + push: + branches: + - develop + - main + paths: + - 'known-plugins/known-plugins.json' + workflow_dispatch: + +jobs: + sync: + name: Publish known-plugins.json to wp.org assets + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Validate known-plugins.json + run: php bin/validate-known-plugins.php + + - name: Install Subversion + run: sudo apt-get update && sudo apt-get install -y subversion + + - name: Sparse-checkout SVN assets + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + run: | + svn checkout \ + --depth immediates \ + --username "$SVN_USERNAME" \ + --password "$SVN_PASSWORD" \ + --non-interactive \ + --no-auth-cache \ + https://plugins.svn.wordpress.org/aaa-option-optimizer/ svn-repo + svn update --set-depth infinity svn-repo/assets || svn mkdir --parents svn-repo/assets + + - name: Copy JSON into assets and detect changes + id: diff + run: | + mkdir -p svn-repo/assets + cp known-plugins/known-plugins.json svn-repo/assets/known-plugins.json + cd svn-repo + # Add the file if it's new + if ! svn info assets/known-plugins.json >/dev/null 2>&1; then + svn add assets/known-plugins.json + fi + # Capture status; empty means nothing to commit + STATUS=$(svn status assets/known-plugins.json) + echo "svn-status: '$STATUS'" + if [ -z "$STATUS" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit to SVN + if: steps.diff.outputs.changed == 'true' + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + run: | + cd svn-repo + svn commit assets/known-plugins.json \ + --username "$SVN_USERNAME" \ + --password "$SVN_PASSWORD" \ + --non-interactive \ + --no-auth-cache \ + -m "Update assets/known-plugins.json" + + - name: No changes + if: steps.diff.outputs.changed != 'true' + run: echo "known-plugins.json on SVN is already up to date." diff --git a/bin/validate-known-plugins.php b/bin/validate-known-plugins.php new file mode 100644 index 0000000..52d86f0 --- /dev/null +++ b/bin/validate-known-plugins.php @@ -0,0 +1,94 @@ + $entry ) { + if ( ! is_string( $slug ) || '' === $slug ) { + $errors[] = 'Slug must be a non-empty string.'; + continue; + } + + if ( ! is_array( $entry ) ) { + $errors[] = "Entry for '{$slug}' must be an object."; + continue; + } + + if ( empty( $entry['name'] ) || ! is_string( $entry['name'] ) ) { + $errors[] = "Entry '{$slug}' missing 'name' string."; + } + + if ( empty( $entry['option_prefixes'] ) || ! is_array( $entry['option_prefixes'] ) ) { + $errors[] = "Entry '{$slug}' missing non-empty 'option_prefixes' array."; + continue; + } + + foreach ( $entry['option_prefixes'] as $prefix ) { + if ( ! is_string( $prefix ) || '' === $prefix ) { + $errors[] = "Entry '{$slug}' has empty/non-string prefix."; + continue; + } + + // Reject dangerously generic prefixes that would match thousands of options. + if ( strlen( $prefix ) < 3 ) { + $errors[] = "Entry '{$slug}' prefix '{$prefix}' is too short (<3 chars)."; + } + + if ( in_array( $prefix, [ 'wp_', 'option_', '_transient_' ], true ) ) { + $errors[] = "Entry '{$slug}' prefix '{$prefix}' is reserved/dangerous."; + } + + if ( isset( $seen_prefixes[ $prefix ] ) && $seen_prefixes[ $prefix ] !== $slug ) { + $warnings[] = "Prefix '{$prefix}' claimed by both '{$seen_prefixes[ $prefix ]}' and '{$slug}'."; + } + $seen_prefixes[ $prefix ] = $slug; + } +} + +foreach ( $warnings as $warn ) { + fwrite( STDERR, "WARNING: {$warn}\n" ); +} + +if ( ! empty( $errors ) ) { + fwrite( STDERR, "Validation failed:\n" ); + foreach ( $errors as $err ) { + fwrite( STDERR, " - {$err}\n" ); + } + exit( 1 ); +} + +$count = count( $data ); +echo "OK: {$count} entries validated.\n"; +exit( 0 ); From 85b4d792278cddbc99b613f73f6912c88fd3295d Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 7 May 2026 17:54:10 +0200 Subject: [PATCH 03/28] Exclude bin/ CLI scripts from PHPCS The validator runs in GitHub Actions, not in WordPress, so WordPress-specific sniffs (escaping, $-prefix globals, WP_Filesystem) don't apply. Co-Authored-By: Claude Opus 4.7 (1M context) --- phpcs.xml.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 7c4ebd2..16b2f59 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -22,6 +22,9 @@ /coverage/* /js/vendor/* + + /bin/* + *.js *.css From 267468ed07e539ad877e224a5822e02b425c1294 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 8 May 2026 09:52:08 +0200 Subject: [PATCH 04/28] Drop redundant activation-hook scheduling, clean up on uninstall The runtime guard in Plugin::register_hooks() already self-heals the schedule for every install. Activation-time scheduling was redundant. Uninstall now also removes the cached mapping option and clears the cron event as defense-in-depth. Co-Authored-By: Claude Opus 4.7 (1M context) --- aaa-option-optimizer.php | 5 ----- uninstall.php | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/aaa-option-optimizer.php b/aaa-option-optimizer.php index 6d5e740..0cd997b 100644 --- a/aaa-option-optimizer.php +++ b/aaa-option-optimizer.php @@ -37,11 +37,6 @@ function aaa_option_optimizer_activation() { // Create the custom table. Progress_Planner\OptionOptimizer\Database::create_table(); - // Schedule daily refresh of the known-plugins mapping. - if ( ! wp_next_scheduled( Progress_Planner\OptionOptimizer\Known_Plugins::CRON_HOOK ) ) { - wp_schedule_event( time(), 'daily', Progress_Planner\OptionOptimizer\Known_Plugins::CRON_HOOK ); - } - $autoload_values = \wp_autoload_values_to_autoload(); $placeholders = implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) ); diff --git a/uninstall.php b/uninstall.php index 05bf9fe..630eb9d 100644 --- a/uninstall.php +++ b/uninstall.php @@ -24,3 +24,9 @@ // Delete the plugin option. delete_option( 'option_optimizer' ); + +// Delete the cached known-plugins mapping. +delete_option( 'aaa_option_optimizer_known_plugins' ); + +// Clear the daily refresh cron event (defense-in-depth; deactivation already does this). +wp_clear_scheduled_hook( 'aaa_option_optimizer_refresh_known_plugins' ); From 931786e88dbb066e0715e38d3f932863def55e3f Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 8 May 2026 10:21:03 +0200 Subject: [PATCH 05/28] Add Report origin button + popover for unknown options For rows where no plugin matched, the Source column becomes a button that opens a popover. User pastes a wp.org slug or URL; the plugin verifies it via api.wordpress.org and shows the official plugin name. On submit, the report is POSTed to the configurable submission endpoint (filterable via aaa_option_optimizer_report_url) for the maintainer to triage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + css/style.css | 25 +++ js/admin-script.js | 242 +++++++++++++++++++++++++++- src/class-admin-page.php | 23 +++ src/class-map-plugin-to-options.php | 32 +++- src/class-rest.php | 61 ++++--- 6 files changed, 349 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 92fecf3..7197ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /coverage/ .phpunit.result.cache node_modules +/submission-worker/ diff --git a/css/style.css b/css/style.css index 21e90b6..3b2d1ee 100644 --- a/css/style.css +++ b/css/style.css @@ -91,6 +91,31 @@ div.dt-container .dt-input { .aaa-option-optimizer-popover__close:hover { cursor: pointer; } +.aaa-report-trigger { + background: none; + border: 1px dashed #c3c4c7; + color: #2271b1; + padding: 2px 8px; + font: inherit; + cursor: pointer; + border-radius: 3px; +} +.aaa-report-trigger:hover { + background: #f6f7f7; + border-color: #2271b1; +} +.aaa-report-popover { + min-width: 380px; + min-height: auto; +} +.aaa-report-popover .aaa-report-input { + width: 100%; + margin-top: 4px; +} +.aaa-report-popover .aaa-report-status { + min-height: 1.2em; + margin: 8px 0; +} .aaa-option-optimizer-tabs { clear: both; display: flex; diff --git a/js/admin-script.js b/js/admin-script.js index 3531a73..4c49d0b 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -183,7 +183,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', searchable: false }, { name: 'autoload', @@ -204,7 +208,12 @@ jQuery( document ).ready( function () { if ( selector === '#requested_do_not_exist_table' ) { return [ { name: 'option', data: 'name' }, - { name: 'source', data: 'plugin', searchable: false }, + { + name: 'source', + data: 'plugin', + searchable: false, + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'calls', data: 'count', searchable: false }, { name: 'option_name', @@ -227,7 +236,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', searchable: false }, { name: 'autoload', @@ -257,7 +270,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', @@ -383,6 +400,220 @@ jQuery( document ).ready( function () { `; } + /** + * Escape HTML for safe insertion as text. + * + * @param {string} unsafe - The string to escape. + * @return {string} - The escaped string. + */ + function escapeHtml( unsafe ) { + return String( unsafe ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); + } + + /** + * Renders the Source column. For unknown sources, wraps the label + * in a button that opens a Report Origin popover. + * + * @param {Object} row - The row data. + * + * @return {string} - The HTML for the source column. + */ + function renderSourceColumn( row ) { + const label = escapeHtml( row.plugin ); + if ( row.plugin_known ) { + return label; + } + const popoverId = `report_${ row.name.replace( + /[^a-zA-Z0-9_-]/g, + '_' + ) }`; + return `${ renderReportPopover( row, popoverId ) } + `; + } + + /** + * Renders the Report Origin popover for an unknown row. + * + * @param {Object} row - The row data. + * @param {string} popoverId - The popover element id. + * + * @return {string} - The popover HTML. + */ + function renderReportPopover( row, popoverId ) { + const i18n = aaaOptionOptimizer.i18n; + const optionName = escapeHtml( row.name ); + return `
+ +

${ i18n.reportOriginOf } ${ optionName }

+

+ +

+

+

${ escapeHtml( i18n.reportPrivacyNote ) }

+

+ + +

+
`; + } + + /** + * Extract a wp.org plugin slug from a slug or URL. + * + * @param {string} input - User input. + * @return {string} - Normalized slug, or empty string if invalid. + */ + function normalizeSlug( input ) { + const trimmed = String( input || '' ).trim(); + if ( ! trimmed ) { + return ''; + } + // Try to pull a slug out of a wordpress.org URL. + const urlMatch = trimmed.match( + /wordpress\.org\/plugins\/([a-z0-9-]+)/i + ); + if ( urlMatch ) { + return urlMatch[ 1 ].toLowerCase(); + } + // Otherwise treat input as a slug. + if ( /^[a-z0-9-]+$/i.test( trimmed ) ) { + return trimmed.toLowerCase(); + } + return ''; + } + + // Per-popover state for the wp.org verification step. + const reportState = {}; + + /** + * Verify a slug against the wordpress.org plugin directory and update + * the popover UI accordingly. + * + * @param {jQuery} $popover - The popover jQuery element. + * @param {string} slug - The slug to verify. + */ + function verifyReportSlug( $popover, slug ) { + const optionName = $popover.data( 'option' ); + const i18n = aaaOptionOptimizer.i18n; + const $status = $popover.find( '.aaa-report-status' ); + const $submit = $popover.find( '.aaa-report-submit' ); + + reportState[ optionName ] = { slug: '', verifiedName: '' }; + $submit.prop( 'disabled', true ); + + if ( ! slug ) { + $status.text( '' ); + return; + } + + $status.text( i18n.reportVerifying ); + + jQuery + .ajax( { + url: `https://api.wordpress.org/plugins/info/1.0/${ encodeURIComponent( + slug + ) }.json`, + method: 'GET', + dataType: 'json', + timeout: 8000, + } ) + .done( function ( data ) { + if ( ! data || data.error || ! data.name ) { + $status.text( i18n.reportNotFound ); + return; + } + reportState[ optionName ] = { + slug, + verifiedName: data.name, + }; + $status.html( + `✓ ${ i18n.reportVerified } ${ escapeHtml( + data.name + ) }` + ); + $submit.prop( 'disabled', false ); + } ) + .fail( function () { + $status.text( i18n.reportVerifyError ); + } ); + } + + /** + * Submit a report to the configured endpoint. + * + * @param {jQuery} $popover - The popover jQuery element. + */ + function submitReport( $popover ) { + const optionName = $popover.data( 'option' ); + const i18n = aaaOptionOptimizer.i18n; + const state = reportState[ optionName ]; + if ( ! state || ! state.slug ) { + return; + } + + const $status = $popover.find( '.aaa-report-status' ); + const $submit = $popover.find( '.aaa-report-submit' ); + $submit.prop( 'disabled', true ); + $status.text( i18n.reportSubmitting ); + + jQuery + .ajax( { + url: aaaOptionOptimizer.reportUrl, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify( { + option_name: optionName, + slug: state.slug, + site: window.location.hostname, + } ), + timeout: 10000, + } ) + .done( function () { + $status.text( i18n.reportThanks ); + } ) + .fail( function () { + $status.text( i18n.reportFailed ); + $submit.prop( 'disabled', false ); + } ); + } + + // Debounced wp.org verification on input change. + let reportInputTimer = null; + jQuery( document ).on( 'input', '.aaa-report-input', function () { + const $popover = jQuery( this ).closest( '.aaa-report-popover' ); + const raw = jQuery( this ).val(); + const slug = normalizeSlug( raw ); + clearTimeout( reportInputTimer ); + reportInputTimer = setTimeout( + () => verifyReportSlug( $popover, slug ), + 350 + ); + } ); + + // Submit handler. + jQuery( document ).on( 'click', '.aaa-report-submit', function () { + const $popover = jQuery( this ).closest( '.aaa-report-popover' ); + submitReport( $popover ); + } ); + jQuery( '#aaa-option-reset-data' ).on( 'click', function ( e ) { e.preventDefault(); jQuery.ajax( { @@ -651,8 +882,7 @@ jQuery( document ).ready( function () { function migrateChunk() { jQuery.ajax( { url: - aaaOptionOptimizer.root + - 'aaa-option-optimizer/v1/migrate', + aaaOptionOptimizer.root + 'aaa-option-optimizer/v1/migrate', method: 'POST', beforeSend: ( xhr ) => xhr.setRequestHeader( diff --git a/src/class-admin-page.php b/src/class-admin-page.php index e45a243..28f6c22 100644 --- a/src/class-admin-page.php +++ b/src/class-admin-page.php @@ -185,6 +185,13 @@ public function enqueue_scripts( $hook ) { true // In footer. ); + /** + * Filter the URL the plugin POSTs unknown-option reports to. + * + * @param string $url The submission endpoint URL. + */ + $report_url = \apply_filters( 'aaa_option_optimizer_report_url', 'https://option-optimizer-api.progressplanner.com/submit' ); + \wp_localize_script( 'aaa-option-optimizer-admin-js', 'aaaOptionOptimizer', @@ -192,6 +199,7 @@ public function enqueue_scripts( $hook ) { 'root' => \esc_url_raw( \rest_url() ), 'nonce' => \wp_create_nonce( 'wp_rest' ), 'migration' => Database::get_migration_status(), + 'reportUrl' => \esc_url_raw( $report_url ), 'i18n' => [ 'filterBySource' => \esc_html__( 'Filter by source', 'aaa-option-optimizer' ), 'showValue' => \esc_html__( 'Show', 'aaa-option-optimizer' ), @@ -199,6 +207,21 @@ public function enqueue_scripts( $hook ) { 'removeAutoload' => \esc_html__( 'Remove autoload', 'aaa-option-optimizer' ), 'deleteOption' => \esc_html__( 'Delete', 'aaa-option-optimizer' ), 'createOptionFalse' => \esc_html__( 'Create option with value false', 'aaa-option-optimizer' ), + 'unknownLabel' => \esc_html__( 'Unknown', 'aaa-option-optimizer' ), + 'reportOrigin' => \esc_html__( 'Report origin', 'aaa-option-optimizer' ), + 'reportOriginOf' => \esc_html__( 'Report origin of', 'aaa-option-optimizer' ), + 'reportSlugOrUrlLabel' => \esc_html__( 'wp.org slug or URL', 'aaa-option-optimizer' ), + 'reportSlugPlaceholder' => \esc_html__( 'e.g. wp125 or https://wordpress.org/plugins/wp125/', 'aaa-option-optimizer' ), + 'reportVerifying' => \esc_html__( 'Checking wp.org…', 'aaa-option-optimizer' ), + 'reportNotFound' => \esc_html__( 'Plugin not found on wordpress.org.', 'aaa-option-optimizer' ), + 'reportVerifyError' => \esc_html__( 'Could not verify with wordpress.org.', 'aaa-option-optimizer' ), + 'reportVerified' => \esc_html__( 'Verified:', 'aaa-option-optimizer' ), + 'reportSubmit' => \esc_html__( 'Submit', 'aaa-option-optimizer' ), + 'reportCancel' => \esc_html__( 'Cancel', 'aaa-option-optimizer' ), + 'reportSubmitting' => \esc_html__( 'Submitting…', 'aaa-option-optimizer' ), + 'reportThanks' => \esc_html__( 'Thanks! Your report has been submitted.', 'aaa-option-optimizer' ), + 'reportFailed' => \esc_html__( 'Submission failed. Please try again.', 'aaa-option-optimizer' ), + 'reportPrivacyNote' => \esc_html__( 'We send the option name and the slug you provide. Never the option value.', 'aaa-option-optimizer' ), 'noAutoloadedButNotUsed' => \esc_html__( 'All autoloaded options are in use.', 'aaa-option-optimizer' ), 'noUsedButNotAutoloaded' => \esc_html__( 'All options that are used are autoloaded.', 'aaa-option-optimizer' ), 'noOptionsSelected' => \esc_html__( 'No options selected.', 'aaa-option-optimizer' ), diff --git a/src/class-map-plugin-to-options.php b/src/class-map-plugin-to-options.php index 15d781f..ffeaae1 100644 --- a/src/class-map-plugin-to-options.php +++ b/src/class-map-plugin-to-options.php @@ -28,22 +28,42 @@ class Map_Plugin_To_Options { * @return string */ public function get_plugin_name( string $option ): string { + $match = $this->find_match( $option ); + return null !== $match ? $match : __( 'Unknown', 'aaa-option-optimizer' ); + } + + /** + * Whether the option's source plugin is known. + * + * @param string $option The option name. + * + * @return bool + */ + public function is_known( string $option ): bool { + return null !== $this->find_match( $option ); + } + + /** + * Look up the plugin name for an option, or null if no match. + * + * @param string $option The option name. + * + * @return string|null + */ + private function find_match( string $option ): ?string { if ( empty( $this->plugins_list ) ) { $known = new Known_Plugins(); $this->plugins_list = $known->get(); } - // for each plugin in the list, check if the option starts with the prefix. foreach ( $this->plugins_list as $plugin ) { foreach ( $plugin['option_prefixes'] as $prefix ) { - if ( strpos( $option, $prefix ) === 0 ) { - if ( isset( $plugin['name'] ) ) { - return $plugin['name']; - } + if ( strpos( $option, $prefix ) === 0 && isset( $plugin['name'] ) ) { + return $plugin['name']; } } } - return __( 'Unknown', 'aaa-option-optimizer' ); + return null; } } diff --git a/src/class-rest.php b/src/class-rest.php index 472eea4..26ecc50 100644 --- a/src/class-rest.php +++ b/src/class-rest.php @@ -228,12 +228,13 @@ public function get_all_options() { $options = $wpdb->get_results( "SELECT option_name, option_value, autoload FROM $wpdb->options" ); foreach ( $options as $option ) { $output[] = [ - 'name' => $option->option_name, - 'plugin' => $this->get_plugin_name( $option->option_name ), - 'value' => htmlentities( $option->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), - 'size' => $this->get_length( $option->option_value ), - 'raw_size' => strlen( $option->option_value ), - 'autoload' => $option->autoload, + 'name' => $option->option_name, + 'plugin' => $this->get_plugin_name( $option->option_name ), + 'plugin_known' => $this->is_plugin_known( $option->option_name ), + 'value' => htmlentities( $option->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), + 'size' => $this->get_length( $option->option_value ), + 'raw_size' => strlen( $option->option_value ), + 'autoload' => $option->autoload, ]; } return new \WP_REST_Response( [ 'data' => $output ], 200 ); @@ -320,12 +321,13 @@ public function get_unused_options() { // Format output. foreach ( $results as $row ) { $response_data[] = [ - 'name' => $row->option_name, - 'plugin' => $this->get_plugin_name( $row->option_name ), - 'value' => htmlentities( $row->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), - 'size' => $this->get_length( $row->option_value ), - 'raw_size' => strlen( $row->option_value ), - 'autoload' => 'yes', + 'name' => $row->option_name, + 'plugin' => $this->get_plugin_name( $row->option_name ), + 'plugin_known' => $this->is_plugin_known( $row->option_name ), + 'value' => htmlentities( $row->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), + 'size' => $this->get_length( $row->option_value ), + 'raw_size' => strlen( $row->option_value ), + 'autoload' => 'yes', ]; } @@ -459,13 +461,14 @@ function ( $option_name ) use ( $search ) { foreach ( $results as $row ) { $response_data[] = [ - 'name' => $row->option_name, - 'plugin' => $this->get_plugin_name( $row->option_name ), - 'value' => htmlentities( maybe_serialize( $row->option_value ), ENT_QUOTES | ENT_SUBSTITUTE ), - 'size' => $this->get_length( $row->option_value ), - 'raw_size' => strlen( $row->option_value ), - 'autoload' => 'no', - 'count' => $used_options[ $row->option_name ] ?? 0, + 'name' => $row->option_name, + 'plugin' => $this->get_plugin_name( $row->option_name ), + 'plugin_known' => $this->is_plugin_known( $row->option_name ), + 'value' => htmlentities( maybe_serialize( $row->option_value ), ENT_QUOTES | ENT_SUBSTITUTE ), + 'size' => $this->get_length( $row->option_value ), + 'raw_size' => strlen( $row->option_value ), + 'autoload' => 'no', + 'count' => $used_options[ $row->option_name ] ?? 0, ]; } @@ -560,10 +563,11 @@ public function get_options_that_do_not_exist() { foreach ( $non_autoloaded_keys as $option => $count ) { if ( ! isset( $existing_keys[ $option ] ) ) { $non_existing_options[ $option ] = [ - 'name' => $option, - 'plugin' => $this->get_plugin_name( $option ), - 'count' => $count, - 'option_name' => $option, + 'name' => $option, + 'plugin' => $this->get_plugin_name( $option ), + 'plugin_known' => $this->is_plugin_known( $option ), + 'count' => $count, + 'option_name' => $option, ]; } } @@ -866,6 +870,17 @@ private function get_plugin_name( $option ) { return $this->map_plugin_to_options->get_plugin_name( $option ); } + /** + * Whether the option's source plugin is known. + * + * @param string $option The option name. + * + * @return bool + */ + private function is_plugin_known( $option ) { + return $this->map_plugin_to_options->is_known( $option ); + } + /** * Filter options array by source (plugin) name. * From b1a65e3db5baec14bb6f7804d2fd36be01c26ad1 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 8 May 2026 10:32:06 +0200 Subject: [PATCH 06/28] Tighten Report trigger styling Drop dashed-border button look. Render label and action as plain text with the action subtly underlined. Force single-line so the row doesn't grow when the trigger is shown. Co-Authored-By: Claude Opus 4.7 (1M context) --- css/style.css | 23 ++++++++++++++++------- js/admin-script.js | 6 +++--- src/class-admin-page.php | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/css/style.css b/css/style.css index 3b2d1ee..da0bd42 100644 --- a/css/style.css +++ b/css/style.css @@ -93,16 +93,25 @@ div.dt-container .dt-input { } .aaa-report-trigger { background: none; - border: 1px dashed #c3c4c7; - color: #2271b1; - padding: 2px 8px; + border: 0; + padding: 0; font: inherit; + color: inherit; cursor: pointer; - border-radius: 3px; + white-space: nowrap; + line-height: inherit; } -.aaa-report-trigger:hover { - background: #f6f7f7; - border-color: #2271b1; +.aaa-report-trigger .aaa-report-trigger__action { + color: #2271b1; + margin-left: 4px; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} +.aaa-report-trigger:hover .aaa-report-trigger__action, +.aaa-report-trigger:focus-visible .aaa-report-trigger__action { + color: #135e96; + text-decoration-style: solid; } .aaa-report-popover { min-width: 380px; diff --git a/js/admin-script.js b/js/admin-script.js index 4c49d0b..0b408f4 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -435,9 +435,9 @@ jQuery( document ).ready( function () { return `${ renderReportPopover( row, popoverId ) } `; + ) }">${ label }${ + aaaOptionOptimizer.i18n.reportOrigin + }`; } /** diff --git a/src/class-admin-page.php b/src/class-admin-page.php index 28f6c22..046bb82 100644 --- a/src/class-admin-page.php +++ b/src/class-admin-page.php @@ -208,7 +208,7 @@ public function enqueue_scripts( $hook ) { 'deleteOption' => \esc_html__( 'Delete', 'aaa-option-optimizer' ), 'createOptionFalse' => \esc_html__( 'Create option with value false', 'aaa-option-optimizer' ), 'unknownLabel' => \esc_html__( 'Unknown', 'aaa-option-optimizer' ), - 'reportOrigin' => \esc_html__( 'Report origin', 'aaa-option-optimizer' ), + 'reportOrigin' => \esc_html__( 'Report', 'aaa-option-optimizer' ), 'reportOriginOf' => \esc_html__( 'Report origin of', 'aaa-option-optimizer' ), 'reportSlugOrUrlLabel' => \esc_html__( 'wp.org slug or URL', 'aaa-option-optimizer' ), 'reportSlugPlaceholder' => \esc_html__( 'e.g. wp125 or https://wordpress.org/plugins/wp125/', 'aaa-option-optimizer' ), From a43c89c291c6f437765c8262836d1a47b312bcf2 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 8 May 2026 10:33:30 +0200 Subject: [PATCH 07/28] Stack Report link under the Unknown label Co-Authored-By: Claude Opus 4.7 (1M context) --- css/style.css | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/css/style.css b/css/style.css index da0bd42..39c4442 100644 --- a/css/style.css +++ b/css/style.css @@ -98,20 +98,18 @@ div.dt-container .dt-input { font: inherit; color: inherit; cursor: pointer; - white-space: nowrap; - line-height: inherit; + line-height: 1.4; + text-align: left; } .aaa-report-trigger .aaa-report-trigger__action { + display: block; color: #2271b1; - margin-left: 4px; text-decoration: underline; - text-decoration-style: dotted; text-underline-offset: 2px; } .aaa-report-trigger:hover .aaa-report-trigger__action, .aaa-report-trigger:focus-visible .aaa-report-trigger__action { color: #135e96; - text-decoration-style: solid; } .aaa-report-popover { min-width: 380px; From dc7198f6dc07a00ad37304e9de1aa6a2777fc6ab Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 8 May 2026 11:50:53 +0200 Subject: [PATCH 08/28] Tighten Report popover and SVN sync workflow - Per-popover verify-debounce timer instead of a single shared one, so concurrently-open popovers don't cancel each other's lookup. - Stable popover ids via a counter, eliminating collisions when two unknown option names normalize to the same slug. - Drop dead svn-mkdir fallback and redundant mkdir in the SVN sync workflow; assets/ always exists on wp.org plugin SVN. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/sync-known-plugins.yml | 3 +-- js/admin-script.js | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/sync-known-plugins.yml b/.github/workflows/sync-known-plugins.yml index 5f6b8b1..b1231a4 100644 --- a/.github/workflows/sync-known-plugins.yml +++ b/.github/workflows/sync-known-plugins.yml @@ -43,12 +43,11 @@ jobs: --non-interactive \ --no-auth-cache \ https://plugins.svn.wordpress.org/aaa-option-optimizer/ svn-repo - svn update --set-depth infinity svn-repo/assets || svn mkdir --parents svn-repo/assets + svn update --set-depth infinity svn-repo/assets - name: Copy JSON into assets and detect changes id: diff run: | - mkdir -p svn-repo/assets cp known-plugins/known-plugins.json svn-repo/assets/known-plugins.json cd svn-repo # Add the file if it's new diff --git a/js/admin-script.js b/js/admin-script.js index 0b408f4..b4ef281 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -423,15 +423,13 @@ jQuery( document ).ready( function () { * * @return {string} - The HTML for the source column. */ + let reportPopoverSeq = 0; function renderSourceColumn( row ) { const label = escapeHtml( row.plugin ); if ( row.plugin_known ) { return label; } - const popoverId = `report_${ row.name.replace( - /[^a-zA-Z0-9_-]/g, - '_' - ) }`; + const popoverId = `aaa_report_${ ++reportPopoverSeq }`; return `${ renderReportPopover( row, popoverId ) }

${ i18n.reportOriginOf } ${ optionName }

-

-

+ - ${ renderInstalledPluginOptions( `${ popoverId }_list` ) } -

+ + +

${ escapeHtml( i18n.reportSlugOrUrlHelp ) }

`; } + /** + * Guess which installed plugin an unknown option belongs to. + * + * The known-plugins mapping is maintained per prefix rather than per + * plugin, so a plugin can be half-recognized: `wpseo_titles` resolves to + * Yoast while `indexables_indexation_reason` right beside it does not. + * The site already knows which plugins are installed, so an unrecognized + * option naming one of them is a strong hint about where it came from. + * + * Matches the longest installed slug whose token appears in the option + * name -- longest so that `elementor-pro` is preferred over `elementor` + * when both are installed. Transient wrappers are stripped first, and + * very short slugs are skipped because two or three letters collide with + * ordinary words too easily. + * + * This is a guess offered as a starting point, never a mapping: it only + * ever prefills the Report form, which still verifies the slug against + * wordpress.org before anything can be submitted. + * + * @param {string} optionName - The unrecognized option name. + * @return {string} - An installed plugin slug, or an empty string. + */ + function guessPluginFromOption( optionName ) { + const plugins = aaaOptionOptimizer.installedPlugins; + if ( ! plugins ) { + return ''; + } + + const haystack = String( optionName || '' ) + .toLowerCase() + .replace( /^_(?:site_)?transient_(?:timeout_)?/, '' ) + .replace( /[^a-z0-9]+/g, '' ); + if ( ! haystack ) { + return ''; + } + + let best = ''; + for ( const slug of Object.keys( plugins ) ) { + const token = slug.toLowerCase().replace( /[^a-z0-9]+/g, '' ); + // Short tokens ("seo", "ai") match far too much to be evidence. + if ( token.length < 5 ) { + continue; + } + if ( haystack.includes( token ) && token.length > best.length ) { + best = slug; + } + } + return best; + } + /** * Find installed plugins matching what the user has typed. * @@ -1029,6 +1080,27 @@ jQuery( document ).ready( function () { renderReportList( $popover, raw ); } ); + // Verify a prefilled guess when the Report popover is opened, so a + // half-recognized plugin shows up already confirmed and the user only has + // to agree with it. + // + // Bound to the trigger rather than the popover's own `toggle` event: that + // event does not bubble, so document-level delegation never sees it. + jQuery( document ).on( 'click', '.aaa-report-trigger', function () { + const popoverId = jQuery( this ).attr( 'popovertarget' ); + const $popover = jQuery( document.getElementById( popoverId ) ); + if ( ! $popover.length ) { + return; + } + const value = $popover.find( '.aaa-report-input' ).val(); + // Only once per popover; reopening shouldn't re-query wp.org. + if ( ! value || $popover.data( 'guessVerified' ) ) { + return; + } + $popover.data( 'guessVerified', true ); + verifyReportSlug( $popover, normalizeSlug( value ) ); + } ); + // Open the list on focus so the installed plugins are discoverable // without having to guess that typing reveals them. jQuery( document ).on( 'focus', '.aaa-report-input', function () { diff --git a/readme.txt b/readme.txt index 2d8c270..49faca3 100644 --- a/readme.txt +++ b/readme.txt @@ -180,6 +180,7 @@ The endpoint is operated by the plugin maintainers. The submission URL can be ov * Add "Report origin" feature: for options whose source plugin is unknown, users can submit the matching wp.org slug to help maintainers expand the recognized plugins list. Submissions land as GitHub issues for maintainer review; no auto-merge. * The "Report origin" slug field suggests the plugins installed on your site, so the slug can be picked from a list instead of typed. The field still accepts a typed slug or wp.org URL, so options left behind by a plugin that has since been deleted remain reportable. Suggestions are read locally and never sent anywhere. +* When an unrecognized option names a plugin installed on the site, the Report form opens with that plugin already filled in and verified, so a plugin the recognized-plugins list only partly covers takes one click to report. The guess is made locally from the installed plugin list and is only ever a starting point -- the slug is still verified against wordpress.org, and it can be changed or cleared. * "Report origin" also submits the option prefix the plugin appears to use, alongside the option name, so a single report can cover every option sharing that prefix instead of just the one reported. The prefix is prefilled from the option name and can be corrected, or cleared to report only that option. * Recognized-plugins list can refresh once a day in the background from the maintainers' server, so the list grows for users without requiring plugin updates. This is opt-in: enable it on the settings tab or agree in the Report popover. The refresh sends only your plugin and WordPress version for anonymous statistics; no site identity is sent. From cd5156af4fe8826532195e1a1df6b6df2102409c Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 9 Sep 2026 11:59:49 +0200 Subject: [PATCH 24/28] Show why a report submission was refused The endpoint explains why it turned a submission down ("Invalid option_name", "Rate limit exceeded"), but the failure handler replaced that with a generic "please try again". That hides the one piece of information the user needs and invites a retry that cannot succeed. Prefer the server's reason when there is one, parenthesised after the generic message so it stays visibly attributed to the endpoint rather than reading as the plugin's own wording. --- js/admin-script.js | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/js/admin-script.js b/js/admin-script.js index 95844c0..226eae9 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -1009,12 +1009,40 @@ jQuery( document ).ready( function () { .done( function () { $status.text( i18n.reportThanks ); } ) - .fail( function () { - $status.text( i18n.reportFailed ); + .fail( function ( jqXHR ) { + $status.text( reportFailureMessage( jqXHR ) ); $submit.prop( 'disabled', false ); } ); } + /** + * Turn a failed submission into something the user can act on. + * + * The endpoint explains why it refused a submission ("Invalid + * option_name", "Rate limit exceeded"); showing only "please try again" + * hides that and invites a retry that cannot succeed. Prefer the server's + * reason and fall back to the generic message when there isn't one. + * + * @param {Object} jqXHR - The failed jQuery XHR object. + * @return {string} - The message to display. + */ + function reportFailureMessage( jqXHR ) { + const generic = aaaOptionOptimizer.i18n.reportFailed; + const reason = + jqXHR && + jqXHR.responseJSON && + typeof jqXHR.responseJSON.error === 'string' + ? jqXHR.responseJSON.error.trim() + : ''; + + if ( ! reason ) { + return generic; + } + // The reason comes from the reporting endpoint, so keep it visibly + // attributed rather than presenting it as the plugin's own wording. + return `${ generic } (${ reason })`; + } + /** * Persist the user's consent to contacting our servers, and remember it for * the rest of this page session so further popovers don't ask again. Best From 720abd05d7d9e7b524b8d801b64fdd90c5140c6c Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 9 Sep 2026 12:00:14 +0200 Subject: [PATCH 25/28] Close the Report popover once the report is sent A successful submission only swapped the status line to "Thanks" and left the popover open with its Submit button disabled, so the sole remaining action was "Cancel" -- which reads like undoing the thing that was just done. Testers reported it as the modal failing to close. Dismiss it a beat after the confirmation instead. The delay keeps the thanks message readable and gives the aria-live status time to start being announced, and the popover is closed through hidePopover() so the browser's own open state stays in step. The element is reused across opens, so a pending close is cancelled when the popover is reopened, and the timer re-checks that the popover is still open before firing -- the user may have dismissed it first, and hidePopover() throws on an element that is not open. --- js/admin-script.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/js/admin-script.js b/js/admin-script.js index 226eae9..43bf5b3 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -1008,6 +1008,7 @@ jQuery( document ).ready( function () { } ) .done( function () { $status.text( i18n.reportThanks ); + closeReportPopover( $popover, REPORT_SUBMITTED_CLOSE_DELAY ); } ) .fail( function ( jqXHR ) { $status.text( reportFailureMessage( jqXHR ) ); @@ -1015,6 +1016,43 @@ jQuery( document ).ready( function () { } ); } + /** + * Dismiss a popover once its report has been accepted. + * + * The popover is a native [popover] element, so it is closed the same way + * the Cancel button closes it rather than by hiding the node -- anything + * else would leave the browser believing it is still open. + * + * By the time the delay elapses the user may have dismissed it themselves + * (Esc, or a click outside), and hidePopover() throws on an element that + * is no longer open, so check before calling. The timer is kept per + * popover so a pending close is dropped if the row is redrawn. + * + * @param {jQuery} $popover - The popover jQuery element. + * @param {number} delay - Milliseconds to wait before closing. + */ + function closeReportPopover( $popover, delay ) { + const previous = $popover.data( 'closeTimer' ); + if ( previous ) { + clearTimeout( previous ); + } + $popover.data( + 'closeTimer', + setTimeout( function () { + const el = $popover[ 0 ]; + if ( ! el || ! el.isConnected ) { + return; + } + // :popover-open is the only reliable read of open state; the + // attribute stays put whether or not the popover is showing. + if ( ! el.matches( ':popover-open' ) ) { + return; + } + el.hidePopover(); + }, delay ) + ); + } + /** * Turn a failed submission into something the user can act on. * @@ -1075,6 +1113,11 @@ jQuery( document ).ready( function () { // appear and disappear while the user is still typing, so wait a beat // longer than the verification itself. const REPORT_PREFIX_REVEAL_DELAY = 400; + // A submitted report is finished business, so the popover closes itself + // rather than leaving "Cancel" as the only way out of a completed task. + // Long enough that the thanks message is readable, and that a screen + // reader has begun announcing the aria-live status, before it goes. + const REPORT_SUBMITTED_CLOSE_DELAY = 1500; jQuery( document ).on( 'input', '.aaa-report-input', function ( event ) { const $popover = jQuery( this ).closest( '.aaa-report-popover' ); @@ -1120,6 +1163,13 @@ jQuery( document ).ready( function () { if ( ! $popover.length ) { return; } + // The element is reused across opens, so a close still pending from a + // previous submission would otherwise shut this one as it appears. + const pendingClose = $popover.data( 'closeTimer' ); + if ( pendingClose ) { + clearTimeout( pendingClose ); + $popover.removeData( 'closeTimer' ); + } const value = $popover.find( '.aaa-report-input' ).val(); // Only once per popover; reopening shouldn't re-query wp.org. if ( ! value || $popover.data( 'guessVerified' ) ) { From 745a86f371d80e062dd2c0c2fa255731f824583c Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 9 Sep 2026 12:01:36 +0200 Subject: [PATCH 26/28] Remember which options this site has reported Reports are POSTed to an external endpoint and nothing was kept locally, so the only trace a report had happened was an in-memory JS object that died on reload. After refreshing, an already-reported option offered "Report" again as though nothing had been sent, and reopening the form showed none of what was submitted. A tester hit exactly this. Record the submission site-side and use it to say so: the trigger reads "Reported" instead of "Report", and the form notes which plugin it was reported as and prefills the slug that was actually sent rather than re-guessing from the option name. The wording claims only what we know. The endpoint is fire-and-forget into a moderation queue and never reports an outcome back, so the record means "we sent this", never "this was accepted", and the note says awaiting review rather than implying a decision. Re-reporting stays open, because a report that named the wrong plugin needs a way to be corrected; a new report overwrites the old record. The data sits beside `settings` rather than inside it, since the settings page saves that subarray wholesale and would otherwise drop it. --- css/style.css | 17 +++++++ js/admin-script.js | 103 +++++++++++++++++++++++++++++++++++++-- src/class-admin-page.php | 51 +++++++++++++++++++ src/class-rest.php | 58 ++++++++++++++++++++++ 4 files changed, 224 insertions(+), 5 deletions(-) diff --git a/css/style.css b/css/style.css index 1208563..1a6ef72 100644 --- a/css/style.css +++ b/css/style.css @@ -111,6 +111,23 @@ div.dt-container .dt-input { .aaa-report-trigger:focus-visible .aaa-report-trigger__action { color: #135e96; } +/* Already reported: still a link, because a wrong slug needs correcting, but + muted so it reads as a state rather than a call to action. */ +.aaa-report-trigger.is-reported .aaa-report-trigger__action { + color: #646970; + text-decoration: none; +} +.aaa-report-trigger.is-reported .aaa-report-trigger__action::before { + content: "\2713\00a0"; +} +.aaa-report-trigger.is-reported:hover .aaa-report-trigger__action, +.aaa-report-trigger.is-reported:focus-visible .aaa-report-trigger__action { + color: #135e96; + text-decoration: underline; +} +.aaa-report-reported { + margin-top: 0; +} .aaa-report-popover { /* Fixed rather than content-driven: the prefix field is revealed after the plugin is confirmed, and a width that grows with it would resize the diff --git a/js/admin-script.js b/js/admin-script.js index 43bf5b3..bb277b2 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -444,18 +444,52 @@ jQuery( document ).ready( function () { * @return {string} - The HTML for the source column. */ let reportPopoverSeq = 0; + + /** + * The report this site has already sent for an option, if any. + * + * Reports go to an external endpoint that never reports back, so a record + * here means "we sent this", never "this was accepted". + * + * @param {string} optionName - The option name. + * + * @return {Object|null} - The stored report, or null when never reported. + */ + function reportedRecord( optionName ) { + const reported = aaaOptionOptimizer.reportedOptions; + if ( + ! reported || + ! Object.prototype.hasOwnProperty.call( reported, optionName ) + ) { + return null; + } + return reported[ optionName ] || null; + } + function renderSourceColumn( row ) { const label = escapeHtml( row.plugin ); if ( row.plugin_known ) { return label; } const popoverId = `aaa_report_${ ++reportPopoverSeq }`; + const i18n = aaaOptionOptimizer.i18n; + const record = reportedRecord( row.name ); + // An already-reported option keeps its trigger -- the report may have + // named the wrong plugin -- but says so rather than inviting a first + // report that has in fact already been sent. + const action = record ? i18n.reportReported : i18n.reportOrigin; + const triggerClass = record + ? 'aaa-report-trigger is-reported' + : 'aaa-report-trigger'; + const title = record + ? ` title="${ escapeHtml( i18n.reportReportedPending ) }"` + : ''; return `${ renderReportPopover( row, popoverId ) } - `; + ) }"${ title }>${ label }${ escapeHtml( + action + ) }`; } /** @@ -469,15 +503,36 @@ jQuery( document ).ready( function () { function renderReportPopover( row, popoverId ) { const i18n = aaaOptionOptimizer.i18n; const optionName = escapeHtml( row.name ); + const record = reportedRecord( row.name ); + // Prefill with what was actually reported, so reopening shows the + // previous answer rather than re-guessing from the option name. + const initialSlug = + record && record.slug + ? record.slug + : guessPluginFromOption( row.name ); + // Only a note -- not a lock. The stored name is what the user chose at + // submission time, which is exactly what they need to see to judge + // whether it was right. + const reportedNote = record + ? `

${ escapeHtml( + i18n.reportReportedAs + ).replace( + '%s', + `${ escapeHtml( + record.name || record.slug + ) }` + ) }

` + : ''; return `

${ i18n.reportOriginOf } ${ optionName }

+ ${ reportedNote }
@@ -1008,6 +1063,7 @@ jQuery( document ).ready( function () { } ) .done( function () { $status.text( i18n.reportThanks ); + recordReport( optionName, state.slug, state.verifiedName ); closeReportPopover( $popover, REPORT_SUBMITTED_CLOSE_DELAY ); } ) .fail( function ( jqXHR ) { @@ -1086,6 +1142,43 @@ jQuery( document ).ready( function () { * the rest of this page session so further popovers don't ask again. Best * effort — a failure here doesn't block the report the user just made. */ + /** + * Remember locally that this option has been reported. + * + * Kept separate from the submission itself: the report went to an external + * endpoint, and this is only the site's own note that it was sent, so the + * table can say "Reported" after a reload. Best-effort -- a failure here + * costs a label, not the report, so it is not surfaced to the user. + * + * @param {string} optionName - The option that was reported. + * @param {string} slug - The slug it was reported as. + * @param {string} pluginName - The verified plugin name. + */ + function recordReport( optionName, slug, pluginName ) { + // Update the in-memory copy too, so a redraw before the next page load + // already shows the reported state. + if ( ! aaaOptionOptimizer.reportedOptions ) { + aaaOptionOptimizer.reportedOptions = {}; + } + aaaOptionOptimizer.reportedOptions[ optionName ] = { + slug, + name: pluginName || '', + }; + + jQuery.ajax( { + url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/record-report`, + method: 'POST', + contentType: 'application/json', + beforeSend: ( xhr ) => + xhr.setRequestHeader( 'X-WP-Nonce', aaaOptionOptimizer.nonce ), + data: JSON.stringify( { + option_name: optionName, + slug, + plugin_name: pluginName || '', + } ), + } ); + } + function persistConsent() { aaaOptionOptimizer.hasRemoteConsent = true; jQuery.ajax( { diff --git a/src/class-admin-page.php b/src/class-admin-page.php index 095de53..e589c43 100644 --- a/src/class-admin-page.php +++ b/src/class-admin-page.php @@ -99,6 +99,52 @@ public static function get_settings(): array { return \wp_parse_args( $settings, $defaults ); } + /** + * Get the options this site has already reported. + * + * Reports are POSTed to an external endpoint which never reports back, so + * this record is only ever "we sent this", not "this was accepted". It is + * kept beside `settings` rather than inside it so the settings page, which + * saves that subarray wholesale, cannot drop it. + * + * @return array> Reported options, keyed by option name. + */ + public static function get_reported_options(): array { + $option = \get_option( self::OPTION_NAME, [] ); + $reported = isset( $option['reported_options'] ) ? $option['reported_options'] : []; + + return \is_array( $reported ) ? $reported : []; + } + + /** + * Record that an option has been reported from this site. + * + * Re-reporting is allowed -- a user who picked the wrong plugin needs a way + * to correct it -- so an existing entry is overwritten rather than kept. + * + * @param string $option_name The option that was reported. + * @param string $slug The wp.org slug it was reported as. + * @param string $plugin_name The plugin name shown at verification time. + * + * @return void + */ + public static function record_reported_option( string $option_name, string $slug, string $plugin_name ): void { + $option = \get_option( self::OPTION_NAME, [] ); + + $reported = isset( $option['reported_options'] ) && \is_array( $option['reported_options'] ) + ? $option['reported_options'] + : []; + + $reported[ $option_name ] = [ + 'slug' => $slug, + 'name' => $plugin_name, + 'reported' => \gmdate( 'c' ), + ]; + + $option['reported_options'] = $reported; + \update_option( self::OPTION_NAME, $option ); + } + /** * Get option tracking. * @@ -207,6 +253,7 @@ public function enqueue_scripts( $hook ) { 'migration' => Database::get_migration_status(), 'reportUrl' => \esc_url_raw( $report_url ), 'hasRemoteConsent' => Known_Plugins::has_consent(), + 'reportedOptions' => self::get_reported_options(), 'installedPlugins' => $this->get_installed_plugins(), 'i18n' => [ 'filterBySource' => \esc_html__( 'Filter by source', 'aaa-option-optimizer' ), @@ -217,6 +264,10 @@ public function enqueue_scripts( $hook ) { 'createOptionFalse' => \esc_html__( 'Create option with value false', 'aaa-option-optimizer' ), 'unknownLabel' => \esc_html__( 'Unknown', 'aaa-option-optimizer' ), 'reportOrigin' => \esc_html__( 'Report', 'aaa-option-optimizer' ), + 'reportReported' => \esc_html__( 'Reported', 'aaa-option-optimizer' ), + /* translators: %s: plugin name the option was reported as. */ + 'reportReportedAs' => \esc_html__( 'Reported as %s. Awaiting review -- submit again to correct it.', 'aaa-option-optimizer' ), + 'reportReportedPending' => \esc_html__( 'Reported, awaiting review', 'aaa-option-optimizer' ), 'reportOriginOf' => \esc_html__( 'Report origin of', 'aaa-option-optimizer' ), 'reportSlugOrUrlLabel' => \esc_html__( 'wp.org slug or URL', 'aaa-option-optimizer' ), 'reportSlugPlaceholder' => \esc_html__( 'Pick an installed plugin, or type a slug or URL', 'aaa-option-optimizer' ), diff --git a/src/class-rest.php b/src/class-rest.php index b3957c6..bb28cce 100644 --- a/src/class-rest.php +++ b/src/class-rest.php @@ -58,6 +58,32 @@ public function register_rest_routes() { ] ); + \register_rest_route( + 'aaa-option-optimizer/v1', + '/record-report', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'record_report' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'option_name' => [ + 'required' => true, + 'type' => 'string', + ], + 'slug' => [ + 'required' => true, + 'type' => 'string', + ], + 'plugin_name' => [ + 'required' => false, + 'type' => 'string', + ], + ], + ] + ); + \register_rest_route( 'aaa-option-optimizer/v1', '/update-autoload', @@ -269,6 +295,38 @@ public function migrate_chunk() { return new \WP_REST_Response( $result, 200 ); } + /** + * Record that an unknown option has been reported from this site. + * + * The report itself goes to an external endpoint which never reports back, + * so this only remembers that the user sent one -- enough for the table to + * say so after a reload instead of offering "Report" as though nothing had + * happened. Re-reporting stays possible so a wrong slug can be corrected. + * + * @param WP_REST_Request $request The REST request. + * + * @return \WP_REST_Response + */ + public function record_report( $request ) { + $option_name = \sanitize_text_field( (string) $request->get_param( 'option_name' ) ); + $slug = \sanitize_key( (string) $request->get_param( 'slug' ) ); + $plugin_name = \sanitize_text_field( (string) $request->get_param( 'plugin_name' ) ); + + if ( '' === $option_name || '' === $slug ) { + return new \WP_REST_Response( + [ + 'success' => false, + 'error' => 'option_name and slug are required.', + ], + 400 + ); + } + + Admin_Page::record_reported_option( $option_name, $slug, $plugin_name ); + + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } + /** * Update autoload status of an option. * From 9a7358f8fad9bd2c1ae8ca03e33e72f6c75aec68 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 9 Sep 2026 12:02:31 +0200 Subject: [PATCH 27/28] Prefill sibling options from a reported prefix Recognition matches option prefixes against the shared known-plugins mapping, and a submitted report cannot change that: the endpoint is an inbox, and a maintainer publishes the mapping. So after reporting one option, its siblings stayed unrecognized and their forms stayed empty. The guess that fills those forms compares the option name against installed plugin slugs, which cannot bridge an abbreviation: `csmgr_` shares no substring with `squadeno-club-sports-manager`, so neither the plugin nor the prefix was ever offered for that plugin's options. A report that names a prefix is the user telling us which plugin owns a family of options -- first-hand evidence about this site. Keep it, and let it fill in the siblings: it outranks the slug guess, and it lets the prefix field offer a prefix the slug could never corroborate. Longest prefix wins, so a specific report beats a broader one, and transient wrappers are stripped so `_transient_csmgr_...` matches too. The popovers are built once when the table draws, so the ones already on the page are updated in place as well -- reporting an option is exactly when its siblings become knowable, and making the user reload to see it would waste what they just supplied. Inputs they have already typed into are left alone. This fills forms; it does not mark rows known. A user's own claim is good enough to prefill a field they can still correct, not to assert a verified origin, so the Source column still waits on the mapping. --- js/admin-script.js | 124 ++++++++++++++++++++++++++++++++++++++- src/class-admin-page.php | 41 ++++++++++++- src/class-rest.php | 7 ++- 3 files changed, 168 insertions(+), 4 deletions(-) diff --git a/js/admin-script.js b/js/admin-script.js index bb277b2..044f7e6 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -563,6 +563,40 @@ jQuery( document ).ready( function () {
`; } + /** + * The slug an option's prefix was reported as, if this site reported one. + * + * Transient wrappers are stripped first so `_transient_csmgr_occ_...` + * matches a `csmgr_` report the same way the bare option would. + * + * @param {string} optionName - The option name. + * + * @return {string} - The reported slug, or an empty string. + */ + function reportedPrefixSlug( optionName ) { + const prefixes = aaaOptionOptimizer.reportedPrefixes; + if ( ! prefixes ) { + return ''; + } + + const stripped = String( optionName || '' ) + .trim() + .replace( /^_(?:site_)?transient_(?:timeout_)?/, '' ); + if ( ! stripped ) { + return ''; + } + + // PHP hands these over longest-first, so the first hit is the most + // specific prefix rather than merely the first one declared. + for ( const prefix of Object.keys( prefixes ) ) { + if ( prefix && stripped.indexOf( prefix ) === 0 ) { + return prefixes[ prefix ].slug || ''; + } + } + + return ''; + } + /** * Guess which installed plugin an unknown option belongs to. * @@ -586,6 +620,16 @@ jQuery( document ).ready( function () { * @return {string} - An installed plugin slug, or an empty string. */ function guessPluginFromOption( optionName ) { + // A prefix this site has already reported is first-hand evidence, so it + // outranks guessing from the slug. It also reaches cases the guess + // cannot: an abbreviated prefix like `csmgr_` shares no substring with + // the slug `squadeno-club-sports-manager`, so only the user's own + // earlier report connects the two. + const reportedSlug = reportedPrefixSlug( optionName ); + if ( reportedSlug ) { + return reportedSlug; + } + const plugins = aaaOptionOptimizer.installedPlugins; if ( ! plugins ) { return ''; @@ -813,6 +857,22 @@ jQuery( document ).ready( function () { const stripped = String( optionName || '' ) .trim() .replace( /^_(?:site_)?transient_(?:timeout_)?/, '' ); + + // If this site already reported a prefix for this plugin, reuse it + // verbatim. The user established that pairing themselves, so it needs + // no corroboration from the slug -- and abbreviated prefixes only ever + // get here by this route. + const prefixes = aaaOptionOptimizer.reportedPrefixes || {}; + for ( const prefix of Object.keys( prefixes ) ) { + if ( + prefix && + prefixes[ prefix ].slug === slug && + stripped.indexOf( prefix ) === 0 + ) { + return prefix; + } + } + const match = stripped.match( /^_?[a-z0-9]+[_-]/i ); if ( ! match ) { return ''; @@ -1063,7 +1123,12 @@ jQuery( document ).ready( function () { } ) .done( function () { $status.text( i18n.reportThanks ); - recordReport( optionName, state.slug, state.verifiedName ); + recordReport( + optionName, + state.slug, + state.verifiedName, + reportPrefixValue( $popover ) + ); closeReportPopover( $popover, REPORT_SUBMITTED_CLOSE_DELAY ); } ) .fail( function ( jqXHR ) { @@ -1142,6 +1207,46 @@ jQuery( document ).ready( function () { * the rest of this page session so further popovers don't ask again. Best * effort — a failure here doesn't block the report the user just made. */ + /** + * Fill in the sibling popovers that share a freshly reported prefix. + * + * The popovers are built once by the DataTables render callback, so their + * inputs already hold the values that were correct when the table drew -- + * empty, for a prefix nothing had reported yet. Reporting one option is + * exactly when its siblings become knowable, and making the user reload to + * see that would waste the thing they just told us. + * + * Only untouched inputs are filled: anything the user has already typed + * into another popover is theirs, not ours to overwrite. + * + * @param {string} sourceOption - The option that was just reported. + * @param {string} prefix - The prefix the report covered. + * @param {string} slug - The slug it was reported as. + */ + function applyReportedPrefix( sourceOption, prefix, slug ) { + jQuery( '.aaa-report-popover' ).each( function () { + const $popover = jQuery( this ); + const optionName = $popover.data( 'option' ); + if ( ! optionName || optionName === sourceOption ) { + return; + } + + const stripped = String( optionName ).replace( + /^_(?:site_)?transient_(?:timeout_)?/, + '' + ); + if ( stripped.indexOf( prefix ) !== 0 ) { + return; + } + + const $input = $popover.find( '.aaa-report-input' ); + if ( $input.val() ) { + return; + } + $input.val( slug ); + } ); + } + /** * Remember locally that this option has been reported. * @@ -1153,8 +1258,9 @@ jQuery( document ).ready( function () { * @param {string} optionName - The option that was reported. * @param {string} slug - The slug it was reported as. * @param {string} pluginName - The verified plugin name. + * @param {string} prefix - The option prefix the report covered, if any. */ - function recordReport( optionName, slug, pluginName ) { + function recordReport( optionName, slug, pluginName, prefix ) { // Update the in-memory copy too, so a redraw before the next page load // already shows the reported state. if ( ! aaaOptionOptimizer.reportedOptions ) { @@ -1163,7 +1269,20 @@ jQuery( document ).ready( function () { aaaOptionOptimizer.reportedOptions[ optionName ] = { slug, name: pluginName || '', + prefix: prefix || '', }; + // Mirror it into the prefix map too, then push it into the popovers that + // are already on the page. + if ( prefix ) { + if ( ! aaaOptionOptimizer.reportedPrefixes ) { + aaaOptionOptimizer.reportedPrefixes = {}; + } + aaaOptionOptimizer.reportedPrefixes[ prefix ] = { + slug, + name: pluginName || '', + }; + applyReportedPrefix( optionName, prefix, slug ); + } jQuery.ajax( { url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/record-report`, @@ -1175,6 +1294,7 @@ jQuery( document ).ready( function () { option_name: optionName, slug, plugin_name: pluginName || '', + prefix: prefix || '', } ), } ); } diff --git a/src/class-admin-page.php b/src/class-admin-page.php index e589c43..f329ee1 100644 --- a/src/class-admin-page.php +++ b/src/class-admin-page.php @@ -125,10 +125,11 @@ public static function get_reported_options(): array { * @param string $option_name The option that was reported. * @param string $slug The wp.org slug it was reported as. * @param string $plugin_name The plugin name shown at verification time. + * @param string $prefix The option prefix the report covered, if given. * * @return void */ - public static function record_reported_option( string $option_name, string $slug, string $plugin_name ): void { + public static function record_reported_option( string $option_name, string $slug, string $plugin_name, string $prefix = '' ): void { $option = \get_option( self::OPTION_NAME, [] ); $reported = isset( $option['reported_options'] ) && \is_array( $option['reported_options'] ) @@ -138,6 +139,7 @@ public static function record_reported_option( string $option_name, string $slug $reported[ $option_name ] = [ 'slug' => $slug, 'name' => $plugin_name, + 'prefix' => $prefix, 'reported' => \gmdate( 'c' ), ]; @@ -145,6 +147,42 @@ public static function record_reported_option( string $option_name, string $slug \update_option( self::OPTION_NAME, $option ); } + /** + * The option prefixes this site has reported, and what they were reported as. + * + * A report that names a prefix is the user telling us which plugin owns a + * whole family of options. That is first-hand evidence about this site, so + * the sibling options can be prefilled from it while the submission waits + * for a maintainer to publish the prefix to the shared mapping. + * + * Longest prefix first, so a more specific report wins over a broader one. + * + * @return array> Reported plugin data, keyed by prefix. + */ + public static function get_reported_prefixes(): array { + $prefixes = []; + + foreach ( self::get_reported_options() as $record ) { + if ( empty( $record['prefix'] ) || empty( $record['slug'] ) ) { + continue; + } + + $prefixes[ $record['prefix'] ] = [ + 'slug' => $record['slug'], + 'name' => isset( $record['name'] ) ? $record['name'] : '', + ]; + } + + \uksort( + $prefixes, + static function ( $a, $b ) { + return \strlen( (string) $b ) <=> \strlen( (string) $a ); + } + ); + + return $prefixes; + } + /** * Get option tracking. * @@ -254,6 +292,7 @@ public function enqueue_scripts( $hook ) { 'reportUrl' => \esc_url_raw( $report_url ), 'hasRemoteConsent' => Known_Plugins::has_consent(), 'reportedOptions' => self::get_reported_options(), + 'reportedPrefixes' => self::get_reported_prefixes(), 'installedPlugins' => $this->get_installed_plugins(), 'i18n' => [ 'filterBySource' => \esc_html__( 'Filter by source', 'aaa-option-optimizer' ), diff --git a/src/class-rest.php b/src/class-rest.php index bb28cce..546ff23 100644 --- a/src/class-rest.php +++ b/src/class-rest.php @@ -80,6 +80,10 @@ public function register_rest_routes() { 'required' => false, 'type' => 'string', ], + 'prefix' => [ + 'required' => false, + 'type' => 'string', + ], ], ] ); @@ -311,6 +315,7 @@ public function record_report( $request ) { $option_name = \sanitize_text_field( (string) $request->get_param( 'option_name' ) ); $slug = \sanitize_key( (string) $request->get_param( 'slug' ) ); $plugin_name = \sanitize_text_field( (string) $request->get_param( 'plugin_name' ) ); + $prefix = \sanitize_text_field( (string) $request->get_param( 'prefix' ) ); if ( '' === $option_name || '' === $slug ) { return new \WP_REST_Response( @@ -322,7 +327,7 @@ public function record_report( $request ) { ); } - Admin_Page::record_reported_option( $option_name, $slug, $plugin_name ); + Admin_Page::record_reported_option( $option_name, $slug, $plugin_name, $prefix ); return new \WP_REST_Response( [ 'success' => true ], 200 ); } From 26753b780a576f904a66fef9e97919f1595776fc Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 9 Sep 2026 12:02:47 +0200 Subject: [PATCH 28/28] Focus the slug field when the Report form opens Opening the form left the caret nowhere, so filling in the one field it asks about started with a click or a tab. Hand focus to it on open. The trigger's click runs before the browser shows the popover and focusing a hidden element does nothing, so the focus is deferred a frame and re-checks that the popover really opened. The field is selected rather than merely focused: a prefilled slug is a suggestion, so typing replaces it while editing it still works. Focus also opens the suggestion list, so the installed plugins are now visible on open instead of only after the user guesses that typing reveals them. --- js/admin-script.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/js/admin-script.js b/js/admin-script.js index 044f7e6..80aef57 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -1383,7 +1383,26 @@ jQuery( document ).ready( function () { clearTimeout( pendingClose ); $popover.removeData( 'closeTimer' ); } - const value = $popover.find( '.aaa-report-input' ).val(); + const $input = $popover.find( '.aaa-report-input' ); + + // Put the caret in the slug field, which is the only thing to do here. + // This click runs before the popover is shown, and focusing a hidden + // element does nothing, so hand the focus over once the browser has + // opened it. Focus also opens the suggestion list, which is the point: + // the installed plugins are visible without having to guess that + // typing reveals them. + window.requestAnimationFrame( function () { + const el = $popover[ 0 ]; + if ( ! el || ! el.matches( ':popover-open' ) ) { + return; + } + // Select rather than just focus: a prefilled slug is a suggestion, + // so typing replaces it outright while the caret still lands at + // the end for anyone who wants to edit it instead. + $input.trigger( 'focus' ).trigger( 'select' ); + } ); + + const value = $input.val(); // Only once per popover; reopening shouldn't re-query wp.org. if ( ! value || $popover.data( 'guessVerified' ) ) { return;