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 ) }
+
+ ${ label } — ${ aaaOptionOptimizer.i18n.reportOrigin }?
+ `;
+ }
+
+ /**
+ * 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 `
+
X
+
${ i18n.reportOriginOf } ${ optionName }
+
+
+ ${ i18n.reportSlugOrUrlLabel }
+
+
+
+
+
${ escapeHtml( i18n.reportPrivacyNote ) }
+
+ ${
+ i18n.reportCancel
+ }
+ ${
+ i18n.reportSubmit
+ }
+
+
`;
+ }
+
+ /**
+ * 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 }?
- `;
+ ) }">${ 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 ) }
---
readme.txt | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/readme.txt b/readme.txt
index d0ae23a..8bfb6ff 100644
--- a/readme.txt
+++ b/readme.txt
@@ -52,8 +52,31 @@ Please do a pull request via GitHub on [this file](https://github.com/ProgressPl
1. Screenshot of the admin screen, initial tab.
2. Screenshot of the "All options" screen, showing you can browse all the options.
+== External services ==
+
+This plugin connects to two external services to identify the source plugins of WordPress options.
+
+= WordPress.org plugin directory (api.wordpress.org and ps.w.org) =
+
+Once a day, the plugin fetches an updated list of recognized plugins from `https://ps.w.org/aaa-option-optimizer/assets/known-plugins.json`. This lets the plugin identify newly-added plugins as the maintained list grows, without requiring a plugin update. Only the JSON file is requested; no data is sent.
+
+When you click "Report" on an option whose source is "Unknown" and type a plugin slug, the plugin queries `https://api.wordpress.org/plugins/info/1.0/{slug}.json` to verify the slug exists and to display the official plugin name for confirmation. Only the slug you type is sent.
+
+WordPress.org terms of service: https://wordpress.org/about/privacy/
+
+= Origin reporting endpoint (option-optimizer-api.progressplanner.com) =
+
+When you submit a "Report origin" form, the plugin sends the option name you reported, the wp.org plugin slug you supplied, and your site's hostname to `https://option-optimizer-api.progressplanner.com/submit`. The submission is recorded as a GitHub issue for a maintainer to review and add to the recognized plugins list. The site hostname is hashed before storage and never published. The option name and slug appear in the public GitHub issue. Submissions only happen when you click Submit on the Report form; nothing is sent automatically.
+
+The endpoint is operated by the plugin maintainers. The submission URL can be overridden via the `aaa_option_optimizer_report_url` filter for users who want to disable or redirect the feature.
+
== Changelog ==
+= 1.7.0 =
+
+* 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.
+* Recognized-plugins list now refreshes from wp.org once a day in the background, so the list grows for users without requiring plugin updates.
+
= 1.6.1 =
* Fix infinite recursion in option access monitoring that could cause a fatal error in certain hosting environments.
From 84564d2b009bf14eb8165caac4ea79faed532a22 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Tue, 2 Jun 2026 11:15:39 +0200
Subject: [PATCH 12/28] Host known-plugins mapping off wordpress.org
The WP.org plugin team asked us not to fetch the data file from
wordpress.org (ps.w.org). Move the fetch to our own Cloudflare worker
at option-optimizer-api.progressplanner.com, and POST the plugin and
WordPress versions with the request so the maintainers can keep
anonymous usage statistics. No site identity is sent.
Add the aaa_option_optimizer_known_plugins_url filter so the fetch can
be redirected or disabled, and update the External services disclosure
in readme.txt accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
readme.txt | 8 +++----
src/class-known-plugins.php | 47 ++++++++++++++++++++++++++++++++-----
src/class-plugin.php | 2 +-
3 files changed, 46 insertions(+), 11 deletions(-)
diff --git a/readme.txt b/readme.txt
index 8bfb6ff..96f38a3 100644
--- a/readme.txt
+++ b/readme.txt
@@ -56,15 +56,15 @@ Please do a pull request via GitHub on [this file](https://github.com/ProgressPl
This plugin connects to two external services to identify the source plugins of WordPress options.
-= WordPress.org plugin directory (api.wordpress.org and ps.w.org) =
-
-Once a day, the plugin fetches an updated list of recognized plugins from `https://ps.w.org/aaa-option-optimizer/assets/known-plugins.json`. This lets the plugin identify newly-added plugins as the maintained list grows, without requiring a plugin update. Only the JSON file is requested; no data is sent.
+= WordPress.org plugin directory (api.wordpress.org) =
When you click "Report" on an option whose source is "Unknown" and type a plugin slug, the plugin queries `https://api.wordpress.org/plugins/info/1.0/{slug}.json` to verify the slug exists and to display the official plugin name for confirmation. Only the slug you type is sent.
WordPress.org terms of service: https://wordpress.org/about/privacy/
-= Origin reporting endpoint (option-optimizer-api.progressplanner.com) =
+= Known-plugins mapping and origin reporting (option-optimizer-api.progressplanner.com) =
+
+Once a day, the plugin fetches an updated list of recognized plugins from `https://option-optimizer-api.progressplanner.com/known-plugins.json`. This lets the plugin identify newly-added plugins as the maintained list grows, without requiring a plugin update. With this request the plugin sends your plugin version and WordPress version, so the maintainers can keep anonymous usage statistics. No site identity is sent. The fetch URL can be overridden, or the feature disabled, via the `aaa_option_optimizer_known_plugins_url` filter.
When you submit a "Report origin" form, the plugin sends the option name you reported, the wp.org plugin slug you supplied, and your site's hostname to `https://option-optimizer-api.progressplanner.com/submit`. The submission is recorded as a GitHub issue for a maintainer to review and add to the recognized plugins list. The site hostname is hashed before storage and never published. The option name and slug appear in the public GitHub issue. Submissions only happen when you click Submit on the Report form; nothing is sent automatically.
diff --git a/src/class-known-plugins.php b/src/class-known-plugins.php
index 9c20e70..2bf743d 100644
--- a/src/class-known-plugins.php
+++ b/src/class-known-plugins.php
@@ -10,13 +10,13 @@
/**
* Class Known_Plugins
*
- * Fetches the latest known-plugins.json from wp.org and caches it locally.
- * Falls back to the JSON file bundled with the plugin when no fresh remote
- * copy is available.
+ * Fetches the latest known-plugins mapping from the plugin maintainers' server
+ * and caches it locally. Falls back to the JSON file bundled with the plugin
+ * when no fresh remote copy is available.
*/
class Known_Plugins {
- const REMOTE_URL = 'https://ps.w.org/aaa-option-optimizer/assets/known-plugins.json';
+ const REMOTE_URL = 'https://option-optimizer-api.progressplanner.com/known-plugins.json';
const CACHE_KEY = 'aaa_option_optimizer_known_plugins';
const CRON_HOOK = 'aaa_option_optimizer_refresh_known_plugins';
@@ -50,13 +50,28 @@ public function get(): array {
/**
* Refresh the cached mapping from the remote URL.
*
+ * Sends the plugin and WordPress versions so the maintainers can keep
+ * anonymous usage statistics for the maintained mapping. No site identity
+ * is transmitted.
+ *
* @return bool True when a fresh copy was stored, false otherwise.
*/
public function refresh(): bool {
- $response = \wp_remote_get(
- self::REMOTE_URL,
+ /**
+ * Filters the URL the known-plugins mapping is fetched from.
+ *
+ * Allows users to redirect or disable the remote fetch.
+ *
+ * @param string $url The remote URL.
+ */
+ $url = \apply_filters( 'aaa_option_optimizer_known_plugins_url', self::REMOTE_URL );
+
+ $response = \wp_remote_post(
+ $url,
[
'timeout' => 10,
+ 'headers' => [ 'Content-Type' => 'application/json' ],
+ 'body' => (string) \wp_json_encode( $this->stats_payload() ),
]
);
@@ -80,6 +95,26 @@ public function refresh(): bool {
return true;
}
+ /**
+ * Build the anonymous stats payload sent with the refresh request.
+ *
+ * @return array
+ */
+ private function stats_payload(): array {
+ global $wp_version;
+
+ $plugin_version = '';
+ if ( \function_exists( 'get_file_data' ) ) {
+ $data = \get_file_data( AAA_OPTION_OPTIMIZER_FILE, [ 'Version' => 'Version' ] );
+ $plugin_version = isset( $data['Version'] ) ? (string) $data['Version'] : '';
+ }
+
+ return [
+ 'plugin_version' => $plugin_version,
+ 'wp_version' => (string) $wp_version,
+ ];
+ }
+
/**
* Load the JSON file bundled with the plugin.
*
diff --git a/src/class-plugin.php b/src/class-plugin.php
index 5d91f48..464f347 100644
--- a/src/class-plugin.php
+++ b/src/class-plugin.php
@@ -167,7 +167,7 @@ protected function add_option_usage( $option_name ) {
}
/**
- * Refresh the cached known-plugins mapping from wp.org.
+ * Refresh the cached known-plugins mapping from the maintainers' server.
*
* @return void
*/
From 0a7dc4efef758d8ced8f1db622a7b26d6f973854 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Tue, 2 Jun 2026 11:29:10 +0200
Subject: [PATCH 13/28] Remove wp.org SVN sync workflow
The known-plugins mapping is now served from our own Cloudflare worker
(option-optimizer-api.progressplanner.com), not from wordpress.org, so
the workflow that published the file to the wp.org SVN /assets/ directory
is no longer used.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.github/workflows/sync-known-plugins.yml | 83 ------------------------
1 file changed, 83 deletions(-)
delete mode 100644 .github/workflows/sync-known-plugins.yml
diff --git a/.github/workflows/sync-known-plugins.yml b/.github/workflows/sync-known-plugins.yml
deleted file mode 100644
index c5af8a7..0000000
--- a/.github/workflows/sync-known-plugins.yml
+++ /dev/null
@@ -1,83 +0,0 @@
-name: Sync known-plugins.json to wp.org SVN
-
-on:
- schedule:
- - cron: '0 3 * * *'
- push:
- branches:
- - 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 (main only)
- uses: actions/checkout@v4
- with:
- ref: main
-
- - 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
-
- - name: Copy JSON into assets and detect changes
- id: diff
- run: |
- 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."
From 186809c9b3285954a0bfc76016d98cd0c93fd788 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Tue, 2 Jun 2026 14:09:31 +0200
Subject: [PATCH 14/28] Add filter to override the option-to-plugin mapping
Add the aaa_option_optimizer_plugin_name filter to Map_Plugin_To_Options
so integrations can recognize options the bundled known-plugins list does
not, or override an existing match. Returning null leaves the option
unrecognized.
The filter is applied in find_match(), the single lookup used by both
get_plugin_name() and is_known(), so a filtered match is treated as known
everywhere (e.g. it suppresses the "Report origin" prompt). The filter
name follows the existing aaa_option_optimizer_* convention. Requested by
a user.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/class-map-plugin-to-options.php | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/class-map-plugin-to-options.php b/src/class-map-plugin-to-options.php
index ffeaae1..06ca4c2 100644
--- a/src/class-map-plugin-to-options.php
+++ b/src/class-map-plugin-to-options.php
@@ -56,14 +56,28 @@ private function find_match( string $option ): ?string {
$this->plugins_list = $known->get();
}
+ $match = null;
foreach ( $this->plugins_list as $plugin ) {
foreach ( $plugin['option_prefixes'] as $prefix ) {
if ( strpos( $option, $prefix ) === 0 && isset( $plugin['name'] ) ) {
- return $plugin['name'];
+ $match = $plugin['name'];
+ break 2;
}
}
}
- return null;
+ /**
+ * Filters the plugin name an option is mapped to.
+ *
+ * Lets integrations recognize options the bundled known-plugins list
+ * does not, or override an existing match. Return a plugin name to mark
+ * the option as known, or null to leave it unrecognized. Both
+ * get_plugin_name() and is_known() honor this filter, so a filtered
+ * match is treated as known everywhere.
+ *
+ * @param string|null $match The matched plugin name, or null if unknown.
+ * @param string $option The option name being looked up.
+ */
+ return \apply_filters( 'aaa_option_optimizer_plugin_name', $match, $option );
}
}
From 32a74137d014a6f78367b0f80f1d7e5c5209ced0 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Wed, 3 Jun 2026 08:44:31 +0200
Subject: [PATCH 15/28] Require opt-in consent before contacting our servers
The daily known-plugins refresh contacts our server and sends the plugin
and WordPress version for anonymous stats. The WP.org plugin team allows
this only with the user's knowledgeable consent, so make it opt-in:
- Add a remote_data_consent setting (default off). Known_Plugins::refresh()
bails without it, and the daily cron is only scheduled while it is on
(and cleared when it is turned off).
- Add a checkbox on the settings tab to grant/revoke consent, with a
description of what is fetched and sent.
- In the Report popover (which also contacts our server), show an inline
consent checkbox when consent has not been granted; Submit stays
disabled until it is ticked. Granting it there persists consent via a
new /set-consent REST route, so the daily refresh starts and the user
is not asked again.
- Update the readme External services disclosure and changelog to note the
fetch is opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
js/admin-script.js | 84 ++++++++++++++++++++++++++++++++++++-
readme.txt | 4 +-
src/class-admin-page.php | 37 +++++++++++++---
src/class-known-plugins.php | 18 ++++++++
src/class-plugin.php | 12 ++++--
src/class-rest.php | 54 ++++++++++++++++++++++++
6 files changed, 197 insertions(+), 12 deletions(-)
diff --git a/js/admin-script.js b/js/admin-script.js
index b4ef281..d0e8dcd 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -462,6 +462,7 @@ jQuery( document ).ready( function () {
${ escapeHtml( i18n.reportPrivacyNote ) }
+ ${ renderConsentField() }
${
i18n.reportCancel
@@ -473,6 +474,53 @@ jQuery( document ).ready( function () {
`;
}
+ /**
+ * Renders the consent checkbox shown in the Report popover when the user
+ * has not yet consented to contacting our servers. Returns an empty string
+ * once consent has been granted (globally or earlier this session).
+ *
+ * @return {string} - The consent field HTML, or an empty string.
+ */
+ function renderConsentField() {
+ if ( aaaOptionOptimizer.hasRemoteConsent ) {
+ return '';
+ }
+ return `
+
+
+ ${ escapeHtml( aaaOptionOptimizer.i18n.reportConsentLabel ) }
+
+
`;
+ }
+
+ /**
+ * Whether the report in this popover may be submitted: a slug must be
+ * verified, and consent must be granted (globally or via the popover
+ * checkbox).
+ *
+ * @param {jQuery} $popover - The popover jQuery element.
+ * @return {boolean} - True when the report may be submitted.
+ */
+ function reportCanSubmit( $popover ) {
+ const optionName = $popover.data( 'option' );
+ const state = reportState[ optionName ];
+ const verified = !! ( state && state.slug && state.verifiedName );
+ return verified && hasReportConsent( $popover );
+ }
+
+ /**
+ * Whether consent is satisfied for this popover.
+ *
+ * @param {jQuery} $popover - The popover jQuery element.
+ * @return {boolean} - True when consent is satisfied.
+ */
+ function hasReportConsent( $popover ) {
+ if ( aaaOptionOptimizer.hasRemoteConsent ) {
+ return true;
+ }
+ return $popover.find( '.aaa-report-consent-input' ).is( ':checked' );
+ }
+
/**
* Extract a wp.org plugin slug from a slug or URL.
*
@@ -547,7 +595,7 @@ jQuery( document ).ready( function () {
data.name
) }`
);
- $submit.prop( 'disabled', false );
+ $submit.prop( 'disabled', ! reportCanSubmit( $popover ) );
} )
.fail( function () {
$status.text( i18n.reportVerifyError );
@@ -566,12 +614,21 @@ jQuery( document ).ready( function () {
if ( ! state || ! state.slug ) {
return;
}
+ if ( ! hasReportConsent( $popover ) ) {
+ return;
+ }
const $status = $popover.find( '.aaa-report-status' );
const $submit = $popover.find( '.aaa-report-submit' );
$submit.prop( 'disabled', true );
$status.text( i18n.reportSubmitting );
+ // If consent was granted here (not previously), persist it so the daily
+ // refresh starts and the user isn't asked again.
+ if ( ! aaaOptionOptimizer.hasRemoteConsent ) {
+ persistConsent();
+ }
+
jQuery
.ajax( {
url: aaaOptionOptimizer.reportUrl,
@@ -593,6 +650,23 @@ jQuery( document ).ready( function () {
} );
}
+ /**
+ * 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
+ * effort — a failure here doesn't block the report the user just made.
+ */
+ function persistConsent() {
+ aaaOptionOptimizer.hasRemoteConsent = true;
+ jQuery.ajax( {
+ url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/set-consent`,
+ method: 'POST',
+ contentType: 'application/json',
+ beforeSend: ( xhr ) =>
+ xhr.setRequestHeader( 'X-WP-Nonce', aaaOptionOptimizer.nonce ),
+ data: JSON.stringify( { consent: true } ),
+ } );
+ }
+
// Debounced wp.org verification on input change. Per-popover timer so
// concurrently-open popovers don't cancel each other's verification.
jQuery( document ).on( 'input', '.aaa-report-input', function () {
@@ -609,6 +683,14 @@ jQuery( document ).ready( function () {
);
} );
+ // Re-evaluate the submit button when consent is toggled.
+ jQuery( document ).on( 'change', '.aaa-report-consent-input', function () {
+ const $popover = jQuery( this ).closest( '.aaa-report-popover' );
+ $popover
+ .find( '.aaa-report-submit' )
+ .prop( 'disabled', ! reportCanSubmit( $popover ) );
+ } );
+
// Submit handler.
jQuery( document ).on( 'click', '.aaa-report-submit', function () {
const $popover = jQuery( this ).closest( '.aaa-report-popover' );
diff --git a/readme.txt b/readme.txt
index 96f38a3..39c0222 100644
--- a/readme.txt
+++ b/readme.txt
@@ -64,7 +64,7 @@ WordPress.org terms of service: https://wordpress.org/about/privacy/
= Known-plugins mapping and origin reporting (option-optimizer-api.progressplanner.com) =
-Once a day, the plugin fetches an updated list of recognized plugins from `https://option-optimizer-api.progressplanner.com/known-plugins.json`. This lets the plugin identify newly-added plugins as the maintained list grows, without requiring a plugin update. With this request the plugin sends your plugin version and WordPress version, so the maintainers can keep anonymous usage statistics. No site identity is sent. The fetch URL can be overridden, or the feature disabled, via the `aaa_option_optimizer_known_plugins_url` filter.
+**This is off until you opt in.** Once you enable "Keep the known-plugins list up to date automatically" (on the plugin's settings tab, or by agreeing in the Report popover), the plugin fetches an updated list of recognized plugins once a day from `https://option-optimizer-api.progressplanner.com/known-plugins.json`. This lets the plugin identify newly-added plugins as the maintained list grows, without requiring a plugin update. With this request the plugin sends your plugin version and WordPress version, so the maintainers can keep anonymous usage statistics. No site identity is sent. Until you opt in, only the list bundled with the plugin is used and no request is made. The fetch URL can be overridden, or the feature disabled, via the `aaa_option_optimizer_known_plugins_url` filter.
When you submit a "Report origin" form, the plugin sends the option name you reported, the wp.org plugin slug you supplied, and your site's hostname to `https://option-optimizer-api.progressplanner.com/submit`. The submission is recorded as a GitHub issue for a maintainer to review and add to the recognized plugins list. The site hostname is hashed before storage and never published. The option name and slug appear in the public GitHub issue. Submissions only happen when you click Submit on the Report form; nothing is sent automatically.
@@ -75,7 +75,7 @@ The endpoint is operated by the plugin maintainers. The submission URL can be ov
= 1.7.0 =
* 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.
-* Recognized-plugins list now refreshes from wp.org once a day in the background, so the list grows for users without requiring plugin updates.
+* 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.
= 1.6.1 =
diff --git a/src/class-admin-page.php b/src/class-admin-page.php
index 046bb82..ba5cca8 100644
--- a/src/class-admin-page.php
+++ b/src/class-admin-page.php
@@ -73,6 +73,11 @@ public function sanitize_settings( $input ): array {
}
$existing['settings']['option_tracking'] = $option_tracking;
+ // Consent to contacting our servers (checkbox: present means granted).
+ $existing['settings']['remote_data_consent'] = isset( $input['settings']['remote_data_consent'] )
+ ? (bool) $input['settings']['remote_data_consent']
+ : false;
+
// Return the full option structure with merged settings.
return $existing;
}
@@ -84,7 +89,8 @@ public function sanitize_settings( $input ): array {
*/
public static function get_settings(): array {
$defaults = [
- 'option_tracking' => 'pre_option',
+ 'option_tracking' => 'pre_option',
+ 'remote_data_consent' => false,
];
$option_optimizer = \get_option( self::OPTION_NAME, [] );
@@ -196,11 +202,12 @@ public function enqueue_scripts( $hook ) {
'aaa-option-optimizer-admin-js',
'aaaOptionOptimizer',
[
- 'root' => \esc_url_raw( \rest_url() ),
- 'nonce' => \wp_create_nonce( 'wp_rest' ),
- 'migration' => Database::get_migration_status(),
- 'reportUrl' => \esc_url_raw( $report_url ),
- 'i18n' => [
+ 'root' => \esc_url_raw( \rest_url() ),
+ 'nonce' => \wp_create_nonce( 'wp_rest' ),
+ 'migration' => Database::get_migration_status(),
+ 'reportUrl' => \esc_url_raw( $report_url ),
+ 'hasRemoteConsent' => Known_Plugins::has_consent(),
+ 'i18n' => [
'filterBySource' => \esc_html__( 'Filter by source', 'aaa-option-optimizer' ),
'showValue' => \esc_html__( 'Show', 'aaa-option-optimizer' ),
'addAutoload' => \esc_html__( 'Add autoload', 'aaa-option-optimizer' ),
@@ -222,6 +229,7 @@ public function enqueue_scripts( $hook ) {
'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' ),
+ 'reportConsentLabel' => \esc_html__( 'I agree to send this report to option-optimizer-api.progressplanner.com, and to let the plugin keep the known-plugins list up to date daily.', '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' ),
@@ -556,6 +564,23 @@ private function render_settings_tab( $option_optimizer, $result ): void {
+
+
+
+
+ >
+
+
+
+ option-optimizer-api.progressplanner.com'
+ );
+ ?>
+
+
'POST',
+ 'callback' => [ $this, 'set_consent' ],
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_options' );
+ },
+ 'args' => [
+ 'consent' => [
+ 'required' => true,
+ 'type' => 'boolean',
+ ],
+ ],
+ ]
+ );
+
\register_rest_route(
'aaa-option-optimizer/v1',
'/update-autoload',
@@ -205,6 +223,42 @@ public function reset_stats() {
return new \WP_REST_Response( [ 'success' => true ], 200 );
}
+ /**
+ * Set the user's consent to contacting our servers.
+ *
+ * Persists the flag into the plugin settings and (un)schedules the daily
+ * known-plugins refresh to match, so granting consent from the Report
+ * popover takes effect immediately.
+ *
+ * @param WP_REST_Request $request The REST request.
+ *
+ * @return \WP_REST_Response
+ */
+ public function set_consent( $request ) {
+ $consent = (bool) $request->get_param( 'consent' );
+
+ $option = \get_option( Admin_Page::OPTION_NAME, [] );
+ $option['settings'] = isset( $option['settings'] ) ? $option['settings'] : [];
+ $option['settings']['remote_data_consent'] = $consent;
+ \update_option( Admin_Page::OPTION_NAME, $option );
+
+ if ( $consent ) {
+ if ( ! \wp_next_scheduled( Known_Plugins::CRON_HOOK ) ) {
+ \wp_schedule_event( \time(), 'daily', Known_Plugins::CRON_HOOK );
+ }
+ } elseif ( \wp_next_scheduled( Known_Plugins::CRON_HOOK ) ) {
+ \wp_clear_scheduled_hook( Known_Plugins::CRON_HOOK );
+ }
+
+ return new \WP_REST_Response(
+ [
+ 'success' => true,
+ 'consent' => $consent,
+ ],
+ 200
+ );
+ }
+
/**
* Migrate a chunk of data from old format to custom table.
*
From 19a31c2f475e2792661f8fc8639ada7dc3d51437 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 11:29:29 +0200
Subject: [PATCH 16/28] Rebuild Playground artifact
The previous Playground ZIP artifact expired and was removed by GitHub.
Empty commit to re-trigger the Playground Comment workflow.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
From a9a25e4fae896d53a33370f4f2c880fcf02d1ba3 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 12:17:25 +0200
Subject: [PATCH 17/28] Suggest installed plugins in the Report origin field
The slug field required typing a wp.org slug from memory, which is the
hardest part of reporting an unknown option. Attach a datalist of the
plugins installed on the site so the slug can be picked from a list.
The option value is the slug (what gets submitted) and the label is the
plugin name, so the list reads well while filling in the right value.
Suggestions are derived from each plugin's directory name and are read
locally; nothing about the installed plugins is transmitted.
The input stays free text on purpose. Unknown options are frequently
orphans left behind by a plugin that has since been deleted, so the
plugin cannot be offered as a suggestion and must still be reportable by
typing a slug or pasting a wp.org URL.
A datalist pick fires the same input event as typing, so both routes go
through the existing debounced wordpress.org verification unchanged. A
directory name that differs from the wp.org slug simply fails that check
rather than producing a bad report.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
---
js/admin-script.js | 38 ++++++++++++++++++++++++++++++++++++-
readme.txt | 3 ++-
src/class-admin-page.php | 41 +++++++++++++++++++++++++++++++++++++++-
3 files changed, 79 insertions(+), 3 deletions(-)
diff --git a/js/admin-script.js b/js/admin-script.js
index d0e8dcd..40d2817 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -455,11 +455,13 @@ jQuery( document ).ready( function () {
${ i18n.reportSlugOrUrlLabel }
-
+ ${ renderInstalledPluginOptions( `${ popoverId }_list` ) }
+ ${ escapeHtml( i18n.reportSlugOrUrlHelp ) }
${ escapeHtml( i18n.reportPrivacyNote ) }
${ renderConsentField() }
@@ -474,6 +476,40 @@ jQuery( document ).ready( function () {
`;
}
+ /**
+ * Renders the datalist of installed plugins offered as suggestions in the
+ * Report popover.
+ *
+ * The option value is the slug, since that is what gets submitted and what
+ * lands in the field when a suggestion is picked. The plugin name is shown
+ * as the label so the list is readable. The input stays free text: plugins
+ * that have been deleted still need to be reportable by typing their slug.
+ *
+ * @param {string} listId - The id to give the datalist element.
+ * @return {string} - The datalist HTML, or an empty string when there is
+ * nothing to suggest.
+ */
+ function renderInstalledPluginOptions( listId ) {
+ const plugins = aaaOptionOptimizer.installedPlugins;
+ if ( ! plugins ) {
+ return '';
+ }
+ const options = Object.keys( plugins )
+ .map(
+ ( slug ) =>
+ ` `
+ )
+ .join( '' );
+ if ( ! options ) {
+ return '';
+ }
+ return `${ options } `;
+ }
+
/**
* Renders the consent checkbox shown in the Report popover when the user
* has not yet consented to contacting our servers. Returns an empty string
diff --git a/readme.txt b/readme.txt
index 39c0222..3b309d3 100644
--- a/readme.txt
+++ b/readme.txt
@@ -58,7 +58,7 @@ This plugin connects to two external services to identify the source plugins of
= WordPress.org plugin directory (api.wordpress.org) =
-When you click "Report" on an option whose source is "Unknown" and type a plugin slug, the plugin queries `https://api.wordpress.org/plugins/info/1.0/{slug}.json` to verify the slug exists and to display the official plugin name for confirmation. Only the slug you type is sent.
+When you click "Report" on an option whose source is "Unknown" and choose or type a plugin slug, the plugin queries `https://api.wordpress.org/plugins/info/1.0/{slug}.json` to verify the slug exists and to display the official plugin name for confirmation. Only the slug is sent. The list of installed plugins offered as suggestions in that field is read locally and is never sent anywhere.
WordPress.org terms of service: https://wordpress.org/about/privacy/
@@ -75,6 +75,7 @@ The endpoint is operated by the plugin maintainers. The submission URL can be ov
= 1.7.0 =
* 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.
* 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.
= 1.6.1 =
diff --git a/src/class-admin-page.php b/src/class-admin-page.php
index ba5cca8..4c8187c 100644
--- a/src/class-admin-page.php
+++ b/src/class-admin-page.php
@@ -207,6 +207,7 @@ public function enqueue_scripts( $hook ) {
'migration' => Database::get_migration_status(),
'reportUrl' => \esc_url_raw( $report_url ),
'hasRemoteConsent' => Known_Plugins::has_consent(),
+ 'installedPlugins' => $this->get_installed_plugins(),
'i18n' => [
'filterBySource' => \esc_html__( 'Filter by source', 'aaa-option-optimizer' ),
'showValue' => \esc_html__( 'Show', 'aaa-option-optimizer' ),
@@ -218,7 +219,8 @@ public function enqueue_scripts( $hook ) {
'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' ),
+ 'reportSlugPlaceholder' => \esc_html__( 'Pick an installed plugin, or type a slug or URL', 'aaa-option-optimizer' ),
+ 'reportSlugOrUrlHelp' => \esc_html__( 'Choose from the installed plugins, or type the slug yourself if the plugin has been removed.', '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' ),
@@ -280,6 +282,43 @@ public function enqueue_scripts( $hook ) {
);
}
+ /**
+ * Build the list of installed plugins offered as Report popover suggestions.
+ *
+ * The array key is the plugin's directory name, which for plugins hosted on
+ * wordpress.org matches their wp.org slug — the value the report endpoint
+ * expects. Plugins living in a single file at the plugins root have no
+ * directory to derive a slug from and are skipped.
+ *
+ * These are suggestions only. A directory name can differ from the wp.org
+ * slug (renamed folders, plugins hosted elsewhere), so a picked slug still
+ * goes through the same wordpress.org verification as a typed one.
+ *
+ * @return array Slug => plugin name.
+ */
+ private function get_installed_plugins(): array {
+ if ( ! \function_exists( 'get_plugins' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+
+ $plugins = [];
+ foreach ( \get_plugins() as $file => $data ) {
+ // "slug/slug.php" gives a directory to use; "single-file.php" does not.
+ if ( \strpos( $file, '/' ) === false ) {
+ continue;
+ }
+
+ $slug = \dirname( $file );
+ if ( ! isset( $plugins[ $slug ] ) ) {
+ $plugins[ $slug ] = isset( $data['Name'] ) ? (string) $data['Name'] : $slug;
+ }
+ }
+
+ \asort( $plugins, SORT_NATURAL | SORT_FLAG_CASE );
+
+ return $plugins;
+ }
+
/**
* Renders the admin page.
*
From 8e7df2f5f8a672b7b19780613d96f9a8a6d98ff0 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 14:01:41 +0200
Subject: [PATCH 18/28] Report the option prefix alongside the option name
A report identified a single option, so a maintainer had to generalize to
a prefix by hand before the mapping could recognize the plugin's other
options. Submit a suggested prefix as well, letting one report cover
every option sharing it.
Both fields are sent. The prefix is a guess and the option name is ground
truth, so the maintainer needs the latter to verify the former.
The suggestion strips transient wrappers and keeps everything through the
first separator, matching how prefixes in the mapping are written -- 108
of the 139 non-core entries end at an underscore, 7 at a hyphen. It
cannot be trusted blindly: "wordpress_api_key" belongs to Akismet, not
core, and yields an overly broad "wordpress_". So the field is editable
and may be cleared, which reports only the option that was clicked.
The privacy note now enumerates the fuller payload, including the site
address it has been sending all along.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
---
js/admin-script.js | 49 ++++++++++++++++++++++++++++++++++++++++
readme.txt | 3 ++-
src/class-admin-page.php | 4 +++-
3 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/js/admin-script.js b/js/admin-script.js
index 40d2817..b047a10 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -462,6 +462,15 @@ jQuery( document ).ready( function () {
${ renderInstalledPluginOptions( `${ popoverId }_list` ) }
${ escapeHtml( i18n.reportSlugOrUrlHelp ) }
+
+
+ ${ escapeHtml( i18n.reportPrefixLabel ) }
+
+
+
+ ${ escapeHtml( i18n.reportPrefixHelp ) }
${ escapeHtml( i18n.reportPrivacyNote ) }
${ renderConsentField() }
@@ -510,6 +519,29 @@ jQuery( document ).ready( function () {
) }">${ options }`;
}
+ /**
+ * Suggest the option prefix a plugin uses, derived from an option name.
+ *
+ * Transient wrappers are stripped first so the suggestion describes the
+ * underlying option rather than the caching layer, then everything through
+ * the first separator is kept -- nearly every prefix in the mapping ends at
+ * an underscore or a hyphen.
+ *
+ * This is only a starting point. It cannot tell that "wordpress_api_key"
+ * belongs to Akismet rather than to core, so the field stays editable and
+ * the maintainer reviewing the report sees the exact option name too.
+ *
+ * @param {string} optionName - The option being reported.
+ * @return {string} - The suggested prefix, or an empty string.
+ */
+ function suggestOptionPrefix( optionName ) {
+ const stripped = String( optionName || '' )
+ .trim()
+ .replace( /^_(?:site_)?transient_(?:timeout_)?/, '' );
+ const match = stripped.match( /^_?[a-z0-9]+[_-]/i );
+ return match ? match[ 0 ] : '';
+ }
+
/**
* Renders the consent checkbox shown in the Report popover when the user
* has not yet consented to contacting our servers. Returns an empty string
@@ -557,6 +589,22 @@ jQuery( document ).ready( function () {
return $popover.find( '.aaa-report-consent-input' ).is( ':checked' );
}
+ /**
+ * The prefix the user is submitting alongside the option name.
+ *
+ * Prefilled with a suggestion but freely editable, so whatever comes back
+ * here is the user's answer -- including an empty string, which means
+ * "report just this one option".
+ *
+ * @param {jQuery} $popover - The popover jQuery element.
+ * @return {string} - The prefix, or an empty string.
+ */
+ function reportPrefixValue( $popover ) {
+ return String(
+ $popover.find( '.aaa-report-prefix' ).val() || ''
+ ).trim();
+ }
+
/**
* Extract a wp.org plugin slug from a slug or URL.
*
@@ -672,6 +720,7 @@ jQuery( document ).ready( function () {
contentType: 'application/json',
data: JSON.stringify( {
option_name: optionName,
+ option_prefix: reportPrefixValue( $popover ),
slug: state.slug,
site: window.location.hostname,
} ),
diff --git a/readme.txt b/readme.txt
index 3b309d3..987d17d 100644
--- a/readme.txt
+++ b/readme.txt
@@ -66,7 +66,7 @@ WordPress.org terms of service: https://wordpress.org/about/privacy/
**This is off until you opt in.** Once you enable "Keep the known-plugins list up to date automatically" (on the plugin's settings tab, or by agreeing in the Report popover), the plugin fetches an updated list of recognized plugins once a day from `https://option-optimizer-api.progressplanner.com/known-plugins.json`. This lets the plugin identify newly-added plugins as the maintained list grows, without requiring a plugin update. With this request the plugin sends your plugin version and WordPress version, so the maintainers can keep anonymous usage statistics. No site identity is sent. Until you opt in, only the list bundled with the plugin is used and no request is made. The fetch URL can be overridden, or the feature disabled, via the `aaa_option_optimizer_known_plugins_url` filter.
-When you submit a "Report origin" form, the plugin sends the option name you reported, the wp.org plugin slug you supplied, and your site's hostname to `https://option-optimizer-api.progressplanner.com/submit`. The submission is recorded as a GitHub issue for a maintainer to review and add to the recognized plugins list. The site hostname is hashed before storage and never published. The option name and slug appear in the public GitHub issue. Submissions only happen when you click Submit on the Report form; nothing is sent automatically.
+When you submit a "Report origin" form, the plugin sends the option name you reported, the option prefix shown in the form, the wp.org plugin slug you supplied, and your site's hostname to `https://option-optimizer-api.progressplanner.com/submit`. The submission is recorded as a GitHub issue for a maintainer to review and add to the recognized plugins list. The site hostname is hashed before storage and never published. The option name and slug appear in the public GitHub issue. Submissions only happen when you click Submit on the Report form; nothing is sent automatically.
The endpoint is operated by the plugin maintainers. The submission URL can be overridden via the `aaa_option_optimizer_report_url` filter for users who want to disable or redirect the feature.
@@ -76,6 +76,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.
+* "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.
= 1.6.1 =
diff --git a/src/class-admin-page.php b/src/class-admin-page.php
index 4c8187c..67fbaed 100644
--- a/src/class-admin-page.php
+++ b/src/class-admin-page.php
@@ -221,6 +221,8 @@ public function enqueue_scripts( $hook ) {
'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' ),
'reportSlugOrUrlHelp' => \esc_html__( 'Choose from the installed plugins, or type the slug yourself if the plugin has been removed.', 'aaa-option-optimizer' ),
+ 'reportPrefixLabel' => \esc_html__( 'Option prefix this plugin uses (optional)', 'aaa-option-optimizer' ),
+ 'reportPrefixHelp' => \esc_html__( 'Sent as a suggestion so one report can cover every option sharing this prefix. Correct it if it looks wrong, or clear it to report just this option.', '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' ),
@@ -230,7 +232,7 @@ public function enqueue_scripts( $hook ) {
'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' ),
+ 'reportPrivacyNote' => \esc_html__( 'We send the option name, the prefix and the slug you provide, and your site address. Never the option value.', 'aaa-option-optimizer' ),
'reportConsentLabel' => \esc_html__( 'I agree to send this report to option-optimizer-api.progressplanner.com, and to let the plugin keep the known-plugins list up to date daily.', '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' ),
From e1fc67d597843a61109584d35b9bcf0b76b872e8 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 14:28:58 +0200
Subject: [PATCH 19/28] Only offer the option prefix once a plugin is confirmed
The prefix field was always visible and always prefilled, which asked the
user about a prefix before they had said which plugin the option belongs
to. Hide the field until wp.org verification succeeds, then reveal it.
The suggestion itself was also wrong for a whole class of options. Core
keys several transients by plugin slug, so
`_site_transient_wp_plugin_dependencies_plugin_timeout_progress-planner`
yielded `wp_` -- core's prefix, not the plugin's, and broad enough to
mismap a large share of core options. The plugin's actual identity sits
at the end of that name, not the start.
The suggestion is now derived against the confirmed slug and only offered
when the two share a leading stem, with core's own prefixes rejected
outright. No confident suggestion leaves the field empty, which reports
just the one option.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
---
js/admin-script.js | 94 ++++++++++++++++++++++++++++++++--------
src/class-admin-page.php | 2 +-
2 files changed, 76 insertions(+), 20 deletions(-)
diff --git a/js/admin-script.js b/js/admin-script.js
index b047a10..a2ffe1c 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -462,15 +462,15 @@ jQuery( document ).ready( function () {
${ renderInstalledPluginOptions( `${ popoverId }_list` ) }
${ escapeHtml( i18n.reportSlugOrUrlHelp ) }
-
-
- ${ escapeHtml( i18n.reportPrefixLabel ) }
-
-
-
- ${ escapeHtml( i18n.reportPrefixHelp ) }
+
+
+
+ ${ escapeHtml( i18n.reportPrefixLabel ) }
+
+
+
+
${ escapeHtml( i18n.reportPrefixHelp ) }
+
${ escapeHtml( i18n.reportPrivacyNote ) }
${ renderConsentField() }
@@ -520,26 +520,72 @@ jQuery( document ).ready( function () {
}
/**
- * Suggest the option prefix a plugin uses, derived from an option name.
+ * Prefixes that describe WordPress core's own bookkeeping rather than the
+ * plugin an option belongs to. Core keys several transients *by* plugin
+ * slug (`_site_transient_wp_plugin_dependencies_plugin_timeout_`),
+ * so the leading token is core's, not the plugin's -- suggesting it would
+ * claim a large share of core options for whichever plugin was reported.
*
- * Transient wrappers are stripped first so the suggestion describes the
- * underlying option rather than the caching layer, then everything through
- * the first separator is kept -- nearly every prefix in the mapping ends at
- * an underscore or a hyphen.
+ * @type {string[]}
+ */
+ const CORE_PREFIXES = [
+ 'wp_',
+ 'update_',
+ 'updates_',
+ 'theme_',
+ 'widget_',
+ 'can_compress_',
+ 'dismissed_',
+ 'auto_update_',
+ 'browser_',
+ 'php_check_',
+ 'plugin_',
+ 'settings_',
+ ];
+
+ /**
+ * Suggest the option prefix a plugin uses.
*
- * This is only a starting point. It cannot tell that "wordpress_api_key"
- * belongs to Akismet rather than to core, so the field stays editable and
- * the maintainer reviewing the report sees the exact option name too.
+ * Derived from the option name, but only accepted when it looks like it
+ * actually belongs to the plugin the user picked: either it starts with a
+ * recognizable piece of the slug, or the option simply starts with the
+ * slug's own token. Anything that resolves to one of core's own prefixes
+ * is discarded rather than suggested.
+ *
+ * Returning an empty string is a normal outcome and means "no confident
+ * suggestion" -- the field is left blank for the user to fill in or leave
+ * alone, and an empty prefix reports just the one option.
*
* @param {string} optionName - The option being reported.
+ * @param {string} slug - The verified wp.org slug.
* @return {string} - The suggested prefix, or an empty string.
*/
- function suggestOptionPrefix( optionName ) {
+ function suggestOptionPrefix( optionName, slug ) {
const stripped = String( optionName || '' )
.trim()
.replace( /^_(?:site_)?transient_(?:timeout_)?/, '' );
const match = stripped.match( /^_?[a-z0-9]+[_-]/i );
- return match ? match[ 0 ] : '';
+ if ( ! match ) {
+ return '';
+ }
+
+ const candidate = match[ 0 ];
+ if ( CORE_PREFIXES.includes( candidate.toLowerCase() ) ) {
+ return '';
+ }
+
+ // Only suggest a prefix that plausibly belongs to the picked plugin:
+ // the slug's tokens and the candidate should share a leading stem.
+ const token = candidate.toLowerCase().replace( /[^a-z0-9]/g, '' );
+ const slugTokens = String( slug || '' )
+ .toLowerCase()
+ .split( '-' )
+ .filter( Boolean );
+ const related = slugTokens.some(
+ ( part ) => part.startsWith( token ) || token.startsWith( part )
+ );
+
+ return related ? candidate : '';
}
/**
@@ -648,6 +694,8 @@ jQuery( document ).ready( function () {
reportState[ optionName ] = { slug: '', verifiedName: '' };
$submit.prop( 'disabled', true );
+ // The prefix belongs to a confirmed plugin; hide it until there is one.
+ $popover.find( '.aaa-report-prefix-field' ).prop( 'hidden', true );
if ( ! slug ) {
$status.text( '' );
@@ -679,6 +727,14 @@ jQuery( document ).ready( function () {
data.name
) }`
);
+ // Now that a plugin is confirmed, offer the prefix field --
+ // prefilled only when the suggestion relates to that plugin.
+ $popover
+ .find( '.aaa-report-prefix' )
+ .val( suggestOptionPrefix( optionName, slug ) );
+ $popover
+ .find( '.aaa-report-prefix-field' )
+ .prop( 'hidden', false );
$submit.prop( 'disabled', ! reportCanSubmit( $popover ) );
} )
.fail( function () {
diff --git a/src/class-admin-page.php b/src/class-admin-page.php
index 67fbaed..171d6ba 100644
--- a/src/class-admin-page.php
+++ b/src/class-admin-page.php
@@ -222,7 +222,7 @@ public function enqueue_scripts( $hook ) {
'reportSlugPlaceholder' => \esc_html__( 'Pick an installed plugin, or type a slug or URL', 'aaa-option-optimizer' ),
'reportSlugOrUrlHelp' => \esc_html__( 'Choose from the installed plugins, or type the slug yourself if the plugin has been removed.', 'aaa-option-optimizer' ),
'reportPrefixLabel' => \esc_html__( 'Option prefix this plugin uses (optional)', 'aaa-option-optimizer' ),
- 'reportPrefixHelp' => \esc_html__( 'Sent as a suggestion so one report can cover every option sharing this prefix. Correct it if it looks wrong, or clear it to report just this option.', 'aaa-option-optimizer' ),
+ 'reportPrefixHelp' => \esc_html__( 'Optional. If this plugin names its options with a shared prefix, adding it lets one report cover all of them. Leave empty to report only this option.', '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' ),
From 68008a149853aa75d8b5f536719dc8e5efd14262 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 14:36:27 +0200
Subject: [PATCH 20/28] Steady the Report popover while the slug is being typed
Three problems, all visible while typing a slug by hand.
The popover was sized by its content, so revealing the prefix field
widened it mid-interaction. Give it a fixed width and lay the prefix
input out like the slug input above it.
Verification fired 350 ms after every keystroke, so a slug typed by hand
was checked at each prefix and reported "Plugin not found" for a name
still being written. Wait 900 ms after a typed character; a datalist pick
or a paste arrives whole, so those still verify promptly.
Typing "progress-planner" passes through "progress", which is itself a
real plugin, so verification succeeded mid-word and the prefix field
appeared for the wrong plugin. Responses now write only when the input
still holds the slug they were for, and the reveal is deferred and
re-checked, so it follows a settled answer rather than flickering
through the intermediate ones.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
---
css/style.css | 14 +++++++--
js/admin-script.js | 74 +++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 78 insertions(+), 10 deletions(-)
diff --git a/css/style.css b/css/style.css
index 39c4442..27763a1 100644
--- a/css/style.css
+++ b/css/style.css
@@ -112,13 +112,23 @@ div.dt-container .dt-input {
color: #135e96;
}
.aaa-report-popover {
- min-width: 380px;
+ /* 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
+ popover mid-interaction. */
+ box-sizing: border-box;
+ width: 480px;
+ max-width: 100%;
min-height: auto;
}
-.aaa-report-popover .aaa-report-input {
+.aaa-report-popover .aaa-report-input,
+.aaa-report-popover .aaa-report-prefix {
+ box-sizing: border-box;
width: 100%;
margin-top: 4px;
}
+.aaa-report-popover .aaa-report-prefix-field label {
+ display: block;
+}
.aaa-report-popover .aaa-report-status {
min-height: 1.2em;
margin: 8px 0;
diff --git a/js/admin-script.js b/js/admin-script.js
index a2ffe1c..f0c91c1 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -695,6 +695,10 @@ jQuery( document ).ready( function () {
reportState[ optionName ] = { slug: '', verifiedName: '' };
$submit.prop( 'disabled', true );
// The prefix belongs to a confirmed plugin; hide it until there is one.
+ const pendingReveal = $popover.data( 'revealTimer' );
+ if ( pendingReveal ) {
+ clearTimeout( pendingReveal );
+ }
$popover.find( '.aaa-report-prefix-field' ).prop( 'hidden', true );
if ( ! slug ) {
@@ -704,6 +708,8 @@ jQuery( document ).ready( function () {
$status.text( i18n.reportVerifying );
+ const $input = $popover.find( '.aaa-report-input' );
+
jQuery
.ajax( {
url: `https://api.wordpress.org/plugins/info/1.0/${ encodeURIComponent(
@@ -714,6 +720,12 @@ jQuery( document ).ready( function () {
timeout: 8000,
} )
.done( function ( data ) {
+ // The field may have moved on while this was in flight --
+ // typing "progress-planner" passes through "progress", which
+ // is itself a real slug. Only the current value may write.
+ if ( normalizeSlug( $input.val() ) !== slug ) {
+ return;
+ }
if ( ! data || data.error || ! data.name ) {
$status.text( i18n.reportNotFound );
return;
@@ -729,15 +741,32 @@ jQuery( document ).ready( function () {
);
// Now that a plugin is confirmed, offer the prefix field --
// prefilled only when the suggestion relates to that plugin.
- $popover
- .find( '.aaa-report-prefix' )
- .val( suggestOptionPrefix( optionName, slug ) );
- $popover
- .find( '.aaa-report-prefix-field' )
- .prop( 'hidden', false );
+ // Deferred so it doesn't flash in and out mid-word when a
+ // half-typed slug happens to be a real plugin.
+ const previousReveal = $popover.data( 'revealTimer' );
+ if ( previousReveal ) {
+ clearTimeout( previousReveal );
+ }
+ $popover.data(
+ 'revealTimer',
+ setTimeout( function () {
+ if ( normalizeSlug( $input.val() ) !== slug ) {
+ return;
+ }
+ $popover
+ .find( '.aaa-report-prefix' )
+ .val( suggestOptionPrefix( optionName, slug ) );
+ $popover
+ .find( '.aaa-report-prefix-field' )
+ .prop( 'hidden', false );
+ }, REPORT_PREFIX_REVEAL_DELAY )
+ );
$submit.prop( 'disabled', ! reportCanSubmit( $popover ) );
} )
.fail( function () {
+ if ( normalizeSlug( $input.val() ) !== slug ) {
+ return;
+ }
$status.text( i18n.reportVerifyError );
} );
}
@@ -810,7 +839,21 @@ jQuery( document ).ready( function () {
// Debounced wp.org verification on input change. Per-popover timer so
// concurrently-open popovers don't cancel each other's verification.
- jQuery( document ).on( 'input', '.aaa-report-input', function () {
+ //
+ // Typing a slug by hand passes through many prefixes that are themselves
+ // valid-looking slugs, and querying each one reports "Plugin not found"
+ // for a name the user is still in the middle of writing. Wait long enough
+ // for a pause in typing. Picking from the datalist or pasting a URL
+ // delivers a complete value in one event, so those verify promptly.
+ const REPORT_TYPING_DELAY = 900;
+ const REPORT_COMPLETE_DELAY = 150;
+ // The prefix field is a second question, asked only once the first is
+ // settled. Revealing it the moment a slug happens to verify makes it
+ // appear and disappear while the user is still typing, so wait a beat
+ // longer than the verification itself.
+ const REPORT_PREFIX_REVEAL_DELAY = 400;
+
+ jQuery( document ).on( 'input', '.aaa-report-input', function ( event ) {
const $popover = jQuery( this ).closest( '.aaa-report-popover' );
const raw = jQuery( this ).val();
const slug = normalizeSlug( raw );
@@ -818,9 +861,24 @@ jQuery( document ).ready( function () {
if ( previous ) {
clearTimeout( previous );
}
+
+ // A datalist pick or a paste arrives whole rather than character by
+ // character; `inputType` is absent or non-insertText for those.
+ const inputType = event.originalEvent && event.originalEvent.inputType;
+ const typedOneChar = inputType === 'insertText';
+ const delay = typedOneChar
+ ? REPORT_TYPING_DELAY
+ : REPORT_COMPLETE_DELAY;
+
+ // Clear a stale "not found" while the user is still typing, so the
+ // popover doesn't argue with a half-written slug.
+ if ( typedOneChar ) {
+ $popover.find( '.aaa-report-status' ).text( '' );
+ }
+
$popover.data(
'verifyTimer',
- setTimeout( () => verifyReportSlug( $popover, slug ), 350 )
+ setTimeout( () => verifyReportSlug( $popover, slug ), delay )
);
} );
From a2c392d7f29348183cfe9e6add31b31c56a2d0df Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 14:47:43 +0200
Subject: [PATCH 21/28] Decode wp.org plugin names and scroll the popover when
it is tall
wordpress.org returns plugin names with HTML entities already encoded, so
escaping one for display encoded the ampersand a second time and rendered
a literal "&" -- "Rankings & Traffic" instead of "Rankings &
Traffic". Decode the entities before escaping. Escaping still happens, so
markup in a plugin name is still inert; the worker already decodes these
server-side for the same reason.
The popover also carried `max-height: 80%` with `overflow: visible`, so on
a short viewport the content ran past the edge and the Cancel and Submit
buttons were unreachable. Scroll the popover body instead, and make the
close button sticky so it does not scroll away with the content.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
---
css/style.css | 13 +++++++++++++
js/admin-script.js | 25 +++++++++++++++++++++++--
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/css/style.css b/css/style.css
index 27763a1..9bd5f87 100644
--- a/css/style.css
+++ b/css/style.css
@@ -119,6 +119,19 @@ div.dt-container .dt-input {
width: 480px;
max-width: 100%;
min-height: auto;
+ /* The base popover is `overflow: visible`, so on a short viewport the
+ content spills past `max-height` and the buttons end up off-screen.
+ Scroll the popover's own body instead. */
+ max-height: min(80vh, 100%);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+.aaa-report-popover .aaa-option-optimizer-popover__close {
+ /* Absolute positioning would scroll away with the content now that the
+ popover scrolls; keep the close button reachable at the top. */
+ position: sticky;
+ float: right;
+ top: 0;
}
.aaa-report-popover .aaa-report-input,
.aaa-report-popover .aaa-report-prefix {
diff --git a/js/admin-script.js b/js/admin-script.js
index f0c91c1..7d1a3f0 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -415,6 +415,26 @@ jQuery( document ).ready( function () {
.replace( /'/g, ''' );
}
+ /**
+ * Decode the HTML entities wordpress.org returns in plugin names.
+ *
+ * The API hands back already-encoded text ("Rankings & Traffic"), so
+ * escaping it for display would encode the ampersand a second time and
+ * render a literal "&". Decode first, then escape as usual.
+ *
+ * Uses the textarea trick rather than a regex so every named and numeric
+ * entity is handled; assigning to `innerHTML` on a detached textarea
+ * parses entities without executing anything or running markup.
+ *
+ * @param {string} value - Text that may contain HTML entities.
+ * @return {string} - The decoded text.
+ */
+ function decodeEntities( value ) {
+ const textarea = document.createElement( 'textarea' );
+ textarea.innerHTML = String( value || '' );
+ return textarea.value;
+ }
+
/**
* Renders the Source column. For unknown sources, wraps the label
* in a button that opens a Report Origin popover.
@@ -730,13 +750,14 @@ jQuery( document ).ready( function () {
$status.text( i18n.reportNotFound );
return;
}
+ const pluginName = decodeEntities( data.name );
reportState[ optionName ] = {
slug,
- verifiedName: data.name,
+ verifiedName: pluginName,
};
$status.html(
`✓ ${ i18n.reportVerified } ${ escapeHtml(
- data.name
+ pluginName
) } `
);
// Now that a plugin is confirmed, offer the prefix field --
From 9d3dda8af292bed491942ec379cb67debecabe14 Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 14:57:19 +0200
Subject: [PATCH 22/28] Replace the datalist with an in-page suggestion list
The native datalist rendered as an unstyleable OS popup, positioned
inconsistently across browsers, and led with the slug when the plugin
name is what people actually recognize.
Build the list in the page instead: plugin name first with the slug
alongside, filtered on both as you type, prefix matches ranked ahead of
substring ones, and styled with the rest of the popover. Keyboard support
is the usual combobox contract -- arrows move a highlight, Enter commits
it, Escape closes the list without disturbing the popover -- announced
through role="combobox" and aria-activedescendant.
Typing a slug by hand stays first class, which matters because unknown
options are often orphans of a plugin that is no longer installed and so
can never appear in the list. Arrowing highlights without overwriting the
field, Enter with nothing highlighted verifies what was typed rather than
being swallowed by the list, and a query matching no installed plugin
closes the list to leave a plain text field.
The list markup is a div, not a p: a paragraph cannot contain a list, and
the browser was silently reparenting the ul out to the popover, where it
picked up the popover's padding box and no longer lined up with the field.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
---
css/style.css | 55 +++++++++
js/admin-script.js | 261 ++++++++++++++++++++++++++++++++++-----
src/class-admin-page.php | 5 +
3 files changed, 290 insertions(+), 31 deletions(-)
diff --git a/css/style.css b/css/style.css
index 9bd5f87..1208563 100644
--- a/css/style.css
+++ b/css/style.css
@@ -136,12 +136,67 @@ div.dt-container .dt-input {
.aaa-report-popover .aaa-report-input,
.aaa-report-popover .aaa-report-prefix {
box-sizing: border-box;
+ display: block;
+ /* WP admin's .regular-text sets a fixed width; the field should fill the
+ popover so the suggestion list lines up with it. */
width: 100%;
+ max-width: 100%;
margin-top: 4px;
}
.aaa-report-popover .aaa-report-prefix-field label {
display: block;
}
+.aaa-report-combo {
+ /* Positioning context for the suggestion list, so it lines up with the
+ field rather than with the popover's (asymmetric) padding box. */
+ position: relative;
+ margin: 0 0 1em;
+}
+.aaa-report-combo label {
+ display: block;
+}
+.aaa-report-list {
+ /* In-page rather than a native dropdown, so it inherits the popover's
+ styling and always appears directly under the field. */
+ position: absolute;
+ z-index: 20;
+ left: 0;
+ right: 0;
+ margin: 2px 0 0;
+ padding: 0;
+ max-height: 220px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ list-style: none;
+ background: #fff;
+ border: 1px solid #8c8f94;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+.aaa-report-option {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 0;
+ padding: 6px 10px;
+ cursor: pointer;
+}
+.aaa-report-option:hover,
+.aaa-report-option.is-active {
+ background: #2271b1;
+ color: #fff;
+}
+.aaa-report-option__name {
+ /* The plugin name is what people recognize, so it leads. */
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+.aaa-report-option__slug {
+ flex: none;
+ font-family: monospace;
+ font-size: 12px;
+ opacity: 0.7;
+}
.aaa-report-popover .aaa-report-status {
min-height: 1.2em;
margin: 8px 0;
diff --git a/js/admin-script.js b/js/admin-script.js
index 7d1a3f0..e1c769d 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -472,15 +472,17 @@ jQuery( document ).ready( function () {
return `
X
${ i18n.reportOriginOf } ${ optionName }
-
-
+
${ escapeHtml( i18n.reportSlugOrUrlHelp ) }
@@ -506,37 +508,158 @@ jQuery( document ).ready( function () {
}
/**
- * Renders the datalist of installed plugins offered as suggestions in the
- * Report popover.
+ * Find installed plugins matching what the user has typed.
*
- * The option value is the slug, since that is what gets submitted and what
- * lands in the field when a suggestion is picked. The plugin name is shown
- * as the label so the list is readable. The input stays free text: plugins
- * that have been deleted still need to be reportable by typing their slug.
+ * Matches on plugin name as well as slug, since people recognize
+ * "All in One SEO" rather than `all-in-one-seo-pack`. Matches that begin
+ * with the query sort ahead of ones that merely contain it, so typing
+ * "seo" offers the plugins named SEO-something first.
*
- * @param {string} listId - The id to give the datalist element.
- * @return {string} - The datalist HTML, or an empty string when there is
- * nothing to suggest.
+ * @param {string} query - The current input value.
+ * @return {Array<{slug: string, name: string}>} - Matching plugins.
*/
- function renderInstalledPluginOptions( listId ) {
+ function matchInstalledPlugins( query ) {
const plugins = aaaOptionOptimizer.installedPlugins;
if ( ! plugins ) {
- return '';
+ return [];
}
- const options = Object.keys( plugins )
- .map(
- ( slug ) =>
- ` `
- )
- .join( '' );
- if ( ! options ) {
- return '';
+ const needle = String( query || '' )
+ .trim()
+ .toLowerCase();
+ const all = Object.keys( plugins ).map( ( slug ) => ( {
+ slug,
+ name: plugins[ slug ],
+ } ) );
+
+ if ( ! needle ) {
+ return all;
+ }
+
+ const scored = [];
+ for ( const item of all ) {
+ const slug = item.slug.toLowerCase();
+ const name = item.name.toLowerCase();
+ if ( slug.startsWith( needle ) || name.startsWith( needle ) ) {
+ scored.push( { item, rank: 0 } );
+ } else if ( slug.includes( needle ) || name.includes( needle ) ) {
+ scored.push( { item, rank: 1 } );
+ }
+ }
+ scored.sort( ( a, b ) => a.rank - b.rank );
+ return scored.map( ( entry ) => entry.item );
+ }
+
+ /**
+ * Render the suggestion list for a popover and show or hide it.
+ *
+ * The list is an aid, never a gate: it closes when nothing matches so the
+ * user is left typing into a plain text field, which is what reporting a
+ * plugin that is no longer installed needs.
+ *
+ * @param {jQuery} $popover - The popover jQuery element.
+ * @param {string} query - The current input value.
+ */
+ function renderReportList( $popover, query ) {
+ const matches = matchInstalledPlugins( query );
+
+ if ( ! matches.length ) {
+ closeReportList( $popover );
+ return;
+ }
+
+ const $list = $popover.find( '.aaa-report-list' );
+ const $input = $popover.find( '.aaa-report-input' );
+ const listId = $list.attr( 'id' );
+
+ $list.html(
+ matches
+ .map(
+ ( item, index ) =>
+ `
${ escapeHtml(
+ item.name
+ ) } ${ escapeHtml(
+ item.slug
+ ) } `
+ )
+ .join( '' )
+ );
+ $list.prop( 'hidden', false );
+ $input.attr( 'aria-expanded', 'true' );
+ $popover.data( 'activeOption', -1 );
+ $input.removeAttr( 'aria-activedescendant' );
+ }
+
+ /**
+ * Hide the suggestion list and reset its selection state.
+ *
+ * @param {jQuery} $popover - The popover jQuery element.
+ */
+ function closeReportList( $popover ) {
+ const $input = $popover.find( '.aaa-report-input' );
+ $popover.find( '.aaa-report-list' ).prop( 'hidden', true ).empty();
+ $input.attr( 'aria-expanded', 'false' );
+ $input.removeAttr( 'aria-activedescendant' );
+ $popover.data( 'activeOption', -1 );
+ }
+
+ /**
+ * Move the highlight within the open suggestion list.
+ *
+ * Highlighting only marks an option; it does not put it in the field, so
+ * arrowing through the list never overwrites what the user typed until
+ * they commit with Enter or a click.
+ *
+ * @param {jQuery} $popover - The popover jQuery element.
+ * @param {number} step - How far to move (1 down, -1 up).
+ */
+ function moveReportListActive( $popover, step ) {
+ const $options = $popover.find( '.aaa-report-option' );
+ if ( ! $options.length ) {
+ return;
+ }
+ const current = $popover.data( 'activeOption' );
+ const index = typeof current === 'number' ? current : -1;
+ let next = index + step;
+ if ( next < 0 ) {
+ next = $options.length - 1;
+ } else if ( next >= $options.length ) {
+ next = 0;
+ }
+
+ $options.attr( 'aria-selected', 'false' ).removeClass( 'is-active' );
+ const $active = $options.eq( next );
+ $active.attr( 'aria-selected', 'true' ).addClass( 'is-active' );
+ $popover.data( 'activeOption', next );
+ $popover
+ .find( '.aaa-report-input' )
+ .attr( 'aria-activedescendant', $active.attr( 'id' ) );
+
+ const option = $active.get( 0 );
+ if ( option && option.scrollIntoView ) {
+ option.scrollIntoView( { block: 'nearest' } );
+ }
+ }
+
+ /**
+ * Put a slug into the field and verify it straight away.
+ *
+ * Used when the user commits a suggestion, which is a complete value and
+ * so does not need the typing debounce.
+ *
+ * @param {jQuery} $popover - The popover jQuery element.
+ * @param {string} slug - The slug to apply.
+ */
+ function chooseReportSlug( $popover, slug ) {
+ const $input = $popover.find( '.aaa-report-input' );
+ $input.val( slug );
+ closeReportList( $popover );
+ const pending = $popover.data( 'verifyTimer' );
+ if ( pending ) {
+ clearTimeout( pending );
}
- return `
${ options } `;
+ verifyReportSlug( $popover, normalizeSlug( slug ) );
}
/**
@@ -901,6 +1024,82 @@ jQuery( document ).ready( function () {
'verifyTimer',
setTimeout( () => verifyReportSlug( $popover, slug ), delay )
);
+
+ // Keep the suggestion list in step with what is being typed.
+ renderReportList( $popover, raw );
+ } );
+
+ // 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 () {
+ const $popover = jQuery( this ).closest( '.aaa-report-popover' );
+ renderReportList( $popover, jQuery( this ).val() );
+ } );
+
+ // Commit a suggestion on click.
+ jQuery( document ).on( 'mousedown', '.aaa-report-option', function ( e ) {
+ // mousedown rather than click, and prevented, so the input keeps
+ // focus and the blur handler doesn't close the list first.
+ e.preventDefault();
+ const $popover = jQuery( this ).closest( '.aaa-report-popover' );
+ chooseReportSlug( $popover, jQuery( this ).data( 'slug' ) );
+ } );
+
+ // Close the list when focus leaves the field.
+ jQuery( document ).on( 'blur', '.aaa-report-input', function () {
+ const $popover = jQuery( this ).closest( '.aaa-report-popover' );
+ setTimeout( () => closeReportList( $popover ), 120 );
+ } );
+
+ // Keyboard handling for the combobox.
+ jQuery( document ).on( 'keydown', '.aaa-report-input', function ( e ) {
+ const $popover = jQuery( this ).closest( '.aaa-report-popover' );
+ const $list = $popover.find( '.aaa-report-list' );
+ const isOpen = ! $list.prop( 'hidden' );
+
+ if ( e.key === 'ArrowDown' || e.key === 'ArrowUp' ) {
+ e.preventDefault();
+ if ( ! isOpen ) {
+ renderReportList( $popover, jQuery( this ).val() );
+ return;
+ }
+ moveReportListActive( $popover, e.key === 'ArrowDown' ? 1 : -1 );
+ return;
+ }
+
+ if ( e.key === 'Escape' ) {
+ // Close the list but leave the popover and the typed value alone.
+ if ( isOpen ) {
+ e.preventDefault();
+ e.stopPropagation();
+ closeReportList( $popover );
+ }
+ return;
+ }
+
+ if ( e.key === 'Enter' ) {
+ const active = $popover.data( 'activeOption' );
+ // Only take over Enter when an option is actually highlighted;
+ // otherwise the user is committing what they typed themselves.
+ if ( isOpen && typeof active === 'number' && active >= 0 ) {
+ e.preventDefault();
+ const slug = $popover
+ .find( '.aaa-report-option' )
+ .eq( active )
+ .data( 'slug' );
+ chooseReportSlug( $popover, slug );
+ return;
+ }
+ // A typed slug: close the list and verify it now rather than
+ // waiting out the typing debounce.
+ e.preventDefault();
+ closeReportList( $popover );
+ const pending = $popover.data( 'verifyTimer' );
+ if ( pending ) {
+ clearTimeout( pending );
+ }
+ verifyReportSlug( $popover, normalizeSlug( jQuery( this ).val() ) );
+ }
} );
// Re-evaluate the submit button when consent is toggled.
diff --git a/src/class-admin-page.php b/src/class-admin-page.php
index 171d6ba..095de53 100644
--- a/src/class-admin-page.php
+++ b/src/class-admin-page.php
@@ -221,6 +221,11 @@ public function enqueue_scripts( $hook ) {
'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' ),
'reportSlugOrUrlHelp' => \esc_html__( 'Choose from the installed plugins, or type the slug yourself if the plugin has been removed.', 'aaa-option-optimizer' ),
+ 'reportListLabel' => \esc_html__( 'Installed plugins', 'aaa-option-optimizer' ),
+ 'reportNoMatches' => \esc_html__( 'No installed plugin matches. Press Enter to use what you typed.', 'aaa-option-optimizer' ),
+ /* translators: %d: number of matching plugins. */
+ 'reportMatchCount' => \esc_html__( '%d plugins match', 'aaa-option-optimizer' ),
+ 'reportMatchCountOne' => \esc_html__( '1 plugin matches', 'aaa-option-optimizer' ),
'reportPrefixLabel' => \esc_html__( 'Option prefix this plugin uses (optional)', 'aaa-option-optimizer' ),
'reportPrefixHelp' => \esc_html__( 'Optional. If this plugin names its options with a shared prefix, adding it lets one report cover all of them. Leave empty to report only this option.', 'aaa-option-optimizer' ),
'reportVerifying' => \esc_html__( 'Checking wp.org…', 'aaa-option-optimizer' ),
From 6c97f1b16e4ebcc743d562a42421aff9fa6ed66a Mon Sep 17 00:00:00 2001
From: Filip Ilic
Date: Mon, 7 Sep 2026 15:12:02 +0200
Subject: [PATCH 23/28] Prefill the Report form with the plugin an option names
The known-plugins mapping is maintained per prefix, not per plugin, so a
plugin is often half-recognized: `wpseo_titles` resolves to Yoast while
`indexables_indexation_reason` beside it shows a bare "Unknown" and asks
the user to identify a plugin that is already named several rows above.
The site knows which plugins are installed, so an unrecognized option
naming one of them says where it most likely came from. Prefill the
Report form with that slug and verify it as the popover opens, so the
common case becomes agreeing with an answer rather than supplying one.
Deliberately kept out of the table: the Source column already stacks a
label and an action, and a "probably X" label is several times longer
than "Unknown" -- names in the mapping run to 122 characters. The guess
lives only in the form, where there is room for it.
The guess is evidence, never a mapping. It prefills a field that still
goes through the same wordpress.org verification, and can be changed or
cleared. Slugs under five characters are ignored because short tokens
collide with ordinary words, and the longest matching slug wins so
`elementor-pro` is preferred over `elementor`.
Bound to the trigger's click rather than the popover's `toggle` event,
which does not bubble and so is invisible to delegated handlers.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015kTB3EpJtSszyevnSWd3yB
---
js/admin-script.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++
readme.txt | 1 +
2 files changed, 73 insertions(+)
diff --git a/js/admin-script.js b/js/admin-script.js
index e1c769d..95844c0 100644
--- a/js/admin-script.js
+++ b/js/admin-script.js
@@ -477,6 +477,7 @@ jQuery( document ).ready( function () {
${ i18n.reportSlugOrUrlLabel }
@@ -507,6 +508,56 @@ jQuery( document ).ready( function () {
`;
}
+ /**
+ * 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 ) }
- ${ label }${
- aaaOptionOptimizer.i18n.reportOrigin
- } `;
+ ) }"${ 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 `
X
${ i18n.reportOriginOf } ${ optionName }
+ ${ reportedNote }
${ i18n.reportSlugOrUrlLabel }
@@ -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;