From f4f89fa6851db5d2b25c6bf81d971a7dc3399fb2 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 7 May 2026 17:02:20 +0200 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] Add Report origin button + popover for unknown options For rows where no plugin matched, the Source column becomes a button that opens a popover. User pastes a wp.org slug or URL; the plugin verifies it via api.wordpress.org and shows the official plugin name. On submit, the report is POSTed to the configurable submission endpoint (filterable via aaa_option_optimizer_report_url) for the maintainer to triage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + css/style.css | 25 +++ js/admin-script.js | 242 +++++++++++++++++++++++++++- src/class-admin-page.php | 23 +++ src/class-map-plugin-to-options.php | 32 +++- src/class-rest.php | 61 ++++--- 6 files changed, 349 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 92fecf3..7197ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /coverage/ .phpunit.result.cache node_modules +/submission-worker/ diff --git a/css/style.css b/css/style.css index 21e90b6..3b2d1ee 100644 --- a/css/style.css +++ b/css/style.css @@ -91,6 +91,31 @@ div.dt-container .dt-input { .aaa-option-optimizer-popover__close:hover { cursor: pointer; } +.aaa-report-trigger { + background: none; + border: 1px dashed #c3c4c7; + color: #2271b1; + padding: 2px 8px; + font: inherit; + cursor: pointer; + border-radius: 3px; +} +.aaa-report-trigger:hover { + background: #f6f7f7; + border-color: #2271b1; +} +.aaa-report-popover { + min-width: 380px; + min-height: auto; +} +.aaa-report-popover .aaa-report-input { + width: 100%; + margin-top: 4px; +} +.aaa-report-popover .aaa-report-status { + min-height: 1.2em; + margin: 8px 0; +} .aaa-option-optimizer-tabs { clear: both; display: flex; diff --git a/js/admin-script.js b/js/admin-script.js index 3531a73..4c49d0b 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -183,7 +183,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', searchable: false }, { name: 'autoload', @@ -204,7 +208,12 @@ jQuery( document ).ready( function () { if ( selector === '#requested_do_not_exist_table' ) { return [ { name: 'option', data: 'name' }, - { name: 'source', data: 'plugin', searchable: false }, + { + name: 'source', + data: 'plugin', + searchable: false, + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'calls', data: 'count', searchable: false }, { name: 'option_name', @@ -227,7 +236,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', searchable: false }, { name: 'autoload', @@ -257,7 +270,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', @@ -383,6 +400,220 @@ jQuery( document ).ready( function () { `; } + /** + * Escape HTML for safe insertion as text. + * + * @param {string} unsafe - The string to escape. + * @return {string} - The escaped string. + */ + function escapeHtml( unsafe ) { + return String( unsafe ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); + } + + /** + * Renders the Source column. For unknown sources, wraps the label + * in a button that opens a Report Origin popover. + * + * @param {Object} row - The row data. + * + * @return {string} - The HTML for the source column. + */ + function renderSourceColumn( row ) { + const label = escapeHtml( row.plugin ); + if ( row.plugin_known ) { + return label; + } + const popoverId = `report_${ row.name.replace( + /[^a-zA-Z0-9_-]/g, + '_' + ) }`; + return `${ renderReportPopover( row, popoverId ) } + `; + } + + /** + * Renders the Report Origin popover for an unknown row. + * + * @param {Object} row - The row data. + * @param {string} popoverId - The popover element id. + * + * @return {string} - The popover HTML. + */ + function renderReportPopover( row, popoverId ) { + const i18n = aaaOptionOptimizer.i18n; + const optionName = escapeHtml( row.name ); + return `
+ +

${ i18n.reportOriginOf } ${ optionName }

+

+ +

+

+

${ escapeHtml( i18n.reportPrivacyNote ) }

+

+ + +

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