From 372a03963f5349775cf62c1850f0f055de21079d Mon Sep 17 00:00:00 2001 From: William Patton Date: Sun, 28 Jun 2026 01:24:47 +0100 Subject: [PATCH 01/10] Add free-plugin foundation for manual issues feature - Bump DB to 1.0.9; add source text NULL column and backfill migration setting all existing rows to 'automated' - Grant edac_create_manual_issues, edac_edit_manual_issues, edac_delete_manual_issues capabilities to administrator and editor on activation - Protect rescan queries in edac_remove_corrected_posts() and Insert_Rule_Data::insert() from touching manual rows via source = 'automated' filter - Insert_Rule_Data always writes source = 'automated' on INSERT and UPDATE - Summary_Generator excludes manual issues from error/warning counts; adds _edac_summary_manual_issues post meta; manual issues contribute to density score - Add edac_frontend_highlighter_app_data PHP filter so pro plugin can inject capability flags - Expose source and extra_data fields in the frontend highlighter AJAX response - Add @wordpress/hooks JS extension points: edac.highlighter.init, edac.highlighter.menuItems, edac.highlighter.issueGroups, edac.highlighter.issueDetail - Add wp-hooks script dependency to the frontend highlighter bundle Co-Authored-By: Claude Sonnet 4.6 --- accessibility-checker.php | 2 +- admin/class-frontend-highlight.php | 4 +- admin/class-insert-rule-data.php | 5 +- admin/class-update-database.php | 30 ++++++++++++ includes/activation.php | 15 ++++++ includes/classes/class-enqueue-frontend.php | 21 +++++++-- includes/classes/class-summary-generator.php | 37 +++++++++++++-- includes/helper-functions.php | 4 +- src/frontendHighlighterApp/index.js | 49 +++++++++++++++++++- webpack.config.js | 1 + 10 files changed, 154 insertions(+), 14 deletions(-) diff --git a/accessibility-checker.php b/accessibility-checker.php index 66fec4a7e..064875a45 100755 --- a/accessibility-checker.php +++ b/accessibility-checker.php @@ -41,7 +41,7 @@ // Current database version. if ( ! defined( 'EDAC_DB_VERSION' ) ) { - define( 'EDAC_DB_VERSION', '1.0.8' ); + define( 'EDAC_DB_VERSION', '1.0.9' ); } // Plugin Folder Path. diff --git a/admin/class-frontend-highlight.php b/admin/class-frontend-highlight.php index 3478437e1..99bf56e29 100644 --- a/admin/class-frontend-highlight.php +++ b/admin/class-frontend-highlight.php @@ -65,7 +65,7 @@ public function get_issues( $post_id ) { $table_name = $wpdb->prefix . 'accessibility_checker'; $post_id = (int) $post_id; $siteid = get_current_blog_id(); - $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, rule, ignre, object, ruletype, selector, ancestry, xpath, landmark, landmark_selector FROM %i where postid = %d and siteid = %d', $table_name, $post_id, $siteid ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name. + $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, rule, ignre, object, ruletype, selector, ancestry, xpath, landmark, landmark_selector, source, extra_data FROM %i where postid = %d and siteid = %d', $table_name, $post_id, $siteid ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name. if ( ! $results ) { return null; } @@ -149,6 +149,8 @@ public function ajax() { $array['severity'] = $rule[0]['severity'] ?? ''; $array['landmark'] = $result['landmark'] ?? ''; $array['landmark_selector'] = $result['landmark_selector'] ?? ''; + $array['source'] = $result['source'] ?? 'automated'; + $array['extra_data'] = isset( $result['extra_data'] ) ? json_decode( $result['extra_data'], true ) : null; $issues[] = $array; diff --git a/admin/class-insert-rule-data.php b/admin/class-insert-rule-data.php index a3c34cb17..09d5f4ca9 100644 --- a/admin/class-insert-rule-data.php +++ b/admin/class-insert-rule-data.php @@ -68,6 +68,7 @@ public function insert( object $post, string $rule, string $ruletype, string $ru 'ruletype' => $ruletype, 'object' => esc_attr( $rule_obj ), 'extra_data' => $extra_data, + 'source' => 'automated', 'recordcheck' => 1, 'user' => get_current_user_id(), 'ignre' => 0, @@ -88,7 +89,7 @@ public function insert( object $post, string $rule, string $ruletype, string $ru // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for adding data to database, caching not required for one time operation. $results = $wpdb->get_results( $wpdb->prepare( - 'SELECT postid, ignre FROM %i where type = %s and postid = %d and rule = %s and selector = %s and siteid = %d', + "SELECT postid, ignre FROM %i WHERE type = %s AND postid = %d AND rule = %s AND selector = %s AND siteid = %d AND (source = 'automated' OR source IS NULL)", $table_name, $rule_data['type'], $rule_data['postid'], @@ -124,6 +125,7 @@ public function insert( object $post, string $rule, string $ruletype, string $ru 'xpath' => $rule_data['xpath'], 'ignre' => $rule_data['ignre'], 'extra_data' => self::encode_extra_data( is_array( $rule_data['extra_data'] ) ? $rule_data['extra_data'] : null ), + 'source' => 'automated', ], [ 'siteid' => $rule_data['siteid'], @@ -174,6 +176,7 @@ public function insert( object $post, string $rule, string $ruletype, string $ru 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ), 'object' => esc_attr( $rule_data['object'] ), 'extra_data' => self::encode_extra_data( is_array( $extra_data_raw ) ? $extra_data_raw : null ), + 'source' => 'automated', 'recordcheck' => absint( $rule_data['recordcheck'] ), 'user' => absint( $rule_data['user'] ), 'ignre' => absint( $rule_data['ignre'] ), diff --git a/admin/class-update-database.php b/admin/class-update-database.php index 41b6c6fba..30b6015c9 100644 --- a/admin/class-update-database.php +++ b/admin/class-update-database.php @@ -64,6 +64,7 @@ public function edac_update_database() { ruletype text NOT NULL, object mediumtext NOT NULL, extra_data text NULL, + source text NULL, recordcheck mediumint(9) NOT NULL, created timestamp NOT NULL default CURRENT_TIMESTAMP, user bigint(20) NOT NULL, @@ -92,6 +93,12 @@ public function edac_update_database() { // 1.0.8: Added extra_data column. dbDelta() handles ADD COLUMN automatically // when the column appears in the CREATE TABLE DDL above; no data migration required. + + // 1.0.9: Added source column. Backfill existing rows to 'automated' since text + // columns cannot carry a DB-level DEFAULT in MySQL 5.7. + if ( version_compare( $db_version, '1.0.9', '<' ) ) { + $this->migrate_source_column( $table_name ); + } } // Update database version option. @@ -122,6 +129,29 @@ private function migrate_license_key_to_shared_option() { delete_option( 'edac_license_key' ); } + /** + * Backfill existing rows so every row has source = 'automated'. + * + * The source column is declared NULL in the CREATE TABLE DDL so dbDelta can add it + * to existing tables without a DEFAULT. PHP always writes the value explicitly on + * insert, but rows created before 1.0.9 need a one-time backfill. + * + * @since 1.0.9 + * @param string $table_name The full table name including prefix. + * @return void + */ + private function migrate_source_column( string $table_name ): void { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration query. + $wpdb->query( + $wpdb->prepare( + 'UPDATE %i SET source = %s WHERE source IS NULL', + $table_name, + 'automated' + ) + ); + } + /** * Migrate existing records to use selector-based unique identifiers. * diff --git a/includes/activation.php b/includes/activation.php index 85022bccb..9039ff380 100644 --- a/includes/activation.php +++ b/includes/activation.php @@ -34,4 +34,19 @@ function edac_activation() { // Set transient to trigger redirect to welcome page. // This will be checked on admin_init and deleted after redirect. set_transient( 'edac_activation_redirect', true, 60 ); + + // Grant manual issues capabilities to administrator and editor. + $manual_caps = [ + 'edac_create_manual_issues', + 'edac_edit_manual_issues', + 'edac_delete_manual_issues', + ]; + foreach ( [ 'administrator', 'editor' ] as $role_name ) { + $role = get_role( $role_name ); + if ( $role ) { + foreach ( $manual_caps as $cap ) { + $role->add_cap( $cap ); + } + } + } } diff --git a/includes/classes/class-enqueue-frontend.php b/includes/classes/class-enqueue-frontend.php index ace4787f3..aac7b2519 100644 --- a/includes/classes/class-enqueue-frontend.php +++ b/includes/classes/class-enqueue-frontend.php @@ -143,11 +143,20 @@ public static function maybe_enqueue_frontend_highlighter() { wp_enqueue_style( 'edac-frontend-highlighter-app', plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/css/frontendHighlighterApp.css', false, EDAC_VERSION, 'all' ); - wp_enqueue_script( 'edac-frontend-highlighter-app', plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/frontendHighlighterApp.bundle.js', false, EDAC_VERSION, false ); - - wp_localize_script( - 'edac-frontend-highlighter-app', - 'edacFrontendHighlighterApp', + wp_enqueue_script( 'edac-frontend-highlighter-app', plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/frontendHighlighterApp.bundle.js', [ 'wp-hooks' ], EDAC_VERSION, false ); + + /** + * Filter the data passed to the frontend highlighter JavaScript app. + * + * Pro plugin hooks in to add capability flags such as + * `canCreateManualIssues`, `canEditManualIssues`, and `canDeleteManualIssues`. + * + * @since 1.0.9 + * + * @param array $app_data The data array passed to wp_localize_script. + */ + $app_data = apply_filters( + 'edac_frontend_highlighter_app_data', [ 'postID' => $post_id, 'nonce' => wp_create_nonce( 'frontend-highlighter' ), @@ -169,6 +178,8 @@ public static function maybe_enqueue_frontend_highlighter() { ] ); + wp_localize_script( 'edac-frontend-highlighter-app', 'edacFrontendHighlighterApp', $app_data ); + wp_set_script_translations( 'edac-frontend-highlighter-app', 'accessibility-checker', plugin_dir_path( EDAC_PLUGIN_FILE ) . 'languages' ); } diff --git a/includes/classes/class-summary-generator.php b/includes/classes/class-summary-generator.php index ac64a4d7e..83e7e05cf 100644 --- a/includes/classes/class-summary-generator.php +++ b/includes/classes/class-summary-generator.php @@ -77,6 +77,7 @@ public function generate_summary() { $summary['ignored'] = $this->count_ignored(); $summary['contrast_errors'] = $this->count_contrast_errors(); $summary['errors'] -= $summary['contrast_errors']; + $summary['manual_issues'] = $this->count_manual_issues(); $summary['content_grade'] = $this->calculate_content_grade(); $summary['readability'] = $this->get_readability( $summary ); $summary['simplified_summary'] = (bool) ( get_post_meta( $this->post_id, '_edac_simplified_summary', true ) ); @@ -141,7 +142,7 @@ private function count_errors() { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for interacting with custom database, safe variable used for table name, caching not required for one time operation. $errors_count = $wpdb->get_var( $wpdb->prepare( - 'SELECT count(*) FROM %i where siteid = %d and postid = %d and ruletype = %s and ignre = %d', + "SELECT count(*) FROM %i WHERE siteid = %d AND postid = %d AND ruletype = %s AND ignre = %d AND (source = 'automated' OR source IS NULL)", $wpdb->prefix . 'accessibility_checker', $this->site_id, $this->post_id, @@ -165,7 +166,7 @@ private function count_warnings() { global $wpdb; $warnings_parameters = [ get_current_blog_id(), $this->post_id, 'warning', 0 ]; - $warnings_where = 'WHERE siteid = %d and postid = %d and ruletype = %s and ignre = %d'; + $warnings_where = "WHERE siteid = %d and postid = %d and ruletype = %s and ignre = %d AND (source = 'automated' OR source IS NULL)"; if ( defined( 'ANWW_VERSION' ) ) { array_push( $warnings_parameters, 'link_blank' ); $warnings_where .= ' and rule != %s'; @@ -238,6 +239,34 @@ private function count_contrast_errors() { return (int) $contrast_errors_count; } + /** + * Counts the number of active manual issues for the current post. + * + * Manual issues have source = 'manual' and are not dismissed (ignre = 0). + * Pro plugin creates these via the REST API; the count is stored here so the + * pro UI can display it without an extra DB query. + * + * @return int The count of active manual issues. + * + * @since 1.0.9 + */ + private function count_manual_issues() { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for interacting with custom database, safe variable used for table name, caching not required for one time operation. + $count = $wpdb->get_var( + $wpdb->prepare( + "SELECT count(*) FROM %i WHERE siteid = %d AND postid = %d AND source = 'manual' AND ignre = %d", + $wpdb->prefix . 'accessibility_checker', + $this->site_id, + $this->post_id, + 0 + ) + ); + + return (int) $count; + } + /** * Updates the issue density metadata for the current post. * This method calculates and updates the issue density based on the summary of accessibility issues @@ -259,7 +288,7 @@ private function update_issue_density( $summary ) { count( $issue_density_array[0] ) > 0 ) ) { - $issue_count = $summary['warnings'] + $summary['errors'] + $summary['contrast_errors']; + $issue_count = $summary['warnings'] + $summary['errors'] + $summary['contrast_errors'] + $summary['manual_issues']; $element_count = $issue_density_array[0][0]; $content_length = $issue_density_array[0][1]; $issue_density = edac_get_issue_density( $issue_count, $element_count, $content_length ); @@ -330,6 +359,7 @@ private function save_summary_meta_data( $summary ) { update_post_meta( $this->post_id, '_edac_summary_warnings', absint( $summary['warnings'] ) ); update_post_meta( $this->post_id, '_edac_summary_ignored', absint( $summary['ignored'] ) ); update_post_meta( $this->post_id, '_edac_summary_contrast_errors', absint( $summary['contrast_errors'] ) ); + update_post_meta( $this->post_id, '_edac_summary_manual_issues', absint( $summary['manual_issues'] ) ); } /** @@ -348,6 +378,7 @@ private function sanitize_summary_meta_data( array $summary ): array { 'warnings' => absint( $summary['warnings'] ?? 0 ), 'ignored' => absint( $summary['ignored'] ?? 0 ), 'contrast_errors' => absint( $summary['contrast_errors'] ?? 0 ), + 'manual_issues' => absint( $summary['manual_issues'] ?? 0 ), 'content_grade' => absint( $summary['content_grade'] ?? 0 ), 'readability' => sanitize_text_field( $summary['readability'] ?? '' ), 'simplified_summary' => filter_var( $summary['simplified_summary'] ?? false, FILTER_VALIDATE_BOOLEAN ), diff --git a/includes/helper-functions.php b/includes/helper-functions.php index 8651fe305..5a6aa502a 100644 --- a/includes/helper-functions.php +++ b/includes/helper-functions.php @@ -808,8 +808,8 @@ function edac_remove_corrected_posts( $post_ID, $type, $pre = 1, $ruleset = 'php } $sql = 1 === $pre - ? "UPDATE {$wpdb->prefix}accessibility_checker SET recordcheck = %d WHERE siteid = %d AND postid = %d AND type = %s" - : "DELETE FROM {$wpdb->prefix}accessibility_checker WHERE recordcheck = %d AND siteid = %d AND postid = %d AND type = %s"; + ? "UPDATE {$wpdb->prefix}accessibility_checker SET recordcheck = %d WHERE siteid = %d AND postid = %d AND type = %s AND (source = 'automated' OR source IS NULL)" + : "DELETE FROM {$wpdb->prefix}accessibility_checker WHERE recordcheck = %d AND siteid = %d AND postid = %d AND type = %s AND (source = 'automated' OR source IS NULL)"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for adding data to database, caching not required for one time operation. $wpdb->query( diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index effb62af0..18068d77a 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -5,6 +5,7 @@ import { computePosition, autoUpdate } from '@floating-ui/dom'; import { createFocusTrap } from 'focus-trap'; import { isFocusable } from 'tabbable'; import { __, _n, sprintf } from '@wordpress/i18n'; +import { doAction, applyFilters } from '@wordpress/hooks'; import { saveFixSettings } from '../common/saveFixSettingsRest'; import { fillFixesModal, fixSettingsModalInit, openFixesModal } from './fixesModal'; import { getLandmarkType as getLandmarkTypeUtil } from './getLandmarkType'; @@ -184,6 +185,17 @@ class AccessibilityCheckerHighlight { // Docked panel restored on page load — fetch issue data so the panel isn't empty. this.panelOpen(); } + + /** + * Fires after the highlighter has finished initialising. + * + * Pro plugin uses this to register its manual-issues JS module. + * + * @since 1.0.9 + * + * @param {AccessibilityCheckerHighlight} highlighter The highlighter instance. + */ + doAction( 'edac.highlighter.init', this ); } toggleMenu() { @@ -641,6 +653,17 @@ class AccessibilityCheckerHighlight { document.body.insertAdjacentHTML( 'afterbegin', newElement ); const panel = document.getElementById( 'edac-highlight-panel' ); + /** + * Fires after the highlighter menu is added to the DOM. + * + * Pro plugin uses this to append additional menu items such as "Add Manual Issue". + * + * @since 1.0.9 + * + * @param {HTMLElement} menuEl The `