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-issues-query.php b/admin/class-issues-query.php index e442399c0..f60442541 100644 --- a/admin/class-issues-query.php +++ b/admin/class-issues-query.php @@ -94,6 +94,10 @@ public function __construct( $filter = [], $record_limit = 100000, $flags = self $this->query['where_base'] = $wpdb->prepare( 'WHERE siteid=%d and ignre=%d and ignre_global=%d ', [ $siteid, 0, 0 ] ); } + // Exclude manual issues from all scanner counts; source IS NULL covers rows + // inserted before the 1.0.9 migration backfill has run. + $this->query['where_base'] .= " AND (source = 'automated' OR source IS NULL)"; + $filter_defaults = [ 'post_types' => [], 'rule_types' => [], diff --git a/admin/class-update-database.php b/admin/class-update-database.php index 41b6c6fba..79753c656 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,15 @@ 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. Also grant plugin + // capabilities here so existing users who update (rather than reactivate) + // receive them — register_activation_hook does not fire on plugin updates. + if ( version_compare( $db_version, '1.0.9', '<' ) ) { + $this->migrate_source_column( $table_name ); + $this->grant_plugin_capabilities(); + } } // Update database version option. @@ -122,6 +132,52 @@ 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 x.x.x + * @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' + ) + ); + } + + /** + * Grant plugin capabilities to their default roles. + * + * Called during the 1.0.9 migration so that users who update the plugin + * (rather than deactivate and reactivate) also receive the capabilities. + * Uses edac_get_plugin_capabilities() so that the pro plugin's caps — registered + * via the edac_plugin_capabilities filter at plugins_loaded — are included when + * this migration runs on admin_init. + * + * @since x.x.x + * @return void + */ + private function grant_plugin_capabilities(): void { + foreach ( edac_get_plugin_capabilities() as $cap => $roles ) { + foreach ( $roles as $role_name ) { + $role = get_role( $role_name ); + if ( $role ) { + $role->add_cap( $cap ); + } + } + } + } + /** * Migrate existing records to use selector-based unique identifiers. * diff --git a/includes/activation.php b/includes/activation.php index 85022bccb..710a6de94 100644 --- a/includes/activation.php +++ b/includes/activation.php @@ -34,4 +34,14 @@ 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 plugin capabilities to their default roles. + foreach ( edac_get_plugin_capabilities() as $cap => $roles ) { + foreach ( $roles as $role_name ) { + $role = get_role( $role_name ); + if ( $role ) { + $role->add_cap( $cap ); + } + } + } } diff --git a/includes/classes/class-enqueue-frontend.php b/includes/classes/class-enqueue-frontend.php index ace4787f3..b099c83af 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 x.x.x + * + * @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..7e7628f1e 100644 --- a/includes/classes/class-summary-generator.php +++ b/includes/classes/class-summary-generator.php @@ -141,7 +141,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 +165,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'; diff --git a/includes/deactivation.php b/includes/deactivation.php index 030381949..855240051 100644 --- a/includes/deactivation.php +++ b/includes/deactivation.php @@ -24,4 +24,14 @@ function edac_deactivation() { // Unschedule the daily license check cron event. wp_clear_scheduled_hook( 'edac_check_license_hook' ); + + // Remove plugin capabilities added on activation. + foreach ( edac_get_plugin_capabilities() as $cap => $roles ) { + foreach ( $roles as $role_name ) { + $role = get_role( $role_name ); + if ( $role ) { + $role->remove_cap( $cap ); + } + } + } } diff --git a/includes/helper-functions.php b/includes/helper-functions.php index 8651fe305..98010bce9 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( @@ -1177,3 +1177,28 @@ function edac_icon( string $name = 'check', string $type = '', bool $aria_hidden . $svgs[ $name ] . ''; } + +/** + * Returns the map of plugin capabilities to their default WordPress roles. + * + * Each key is a capability slug; each value is an array of role names that + * receive the capability on activation and lose it on deactivation. + * + * Filterable so the pro plugin (and any third-party add-on) can register + * additional capabilities through the same activate/deactivate system + * without modifying the free plugin. + * + * @since x.x.x + * + * @return array Capability slug => role names. + */ +function edac_get_plugin_capabilities(): array { + /** + * Filters the plugin capability map used during activation and deactivation. + * + * @since x.x.x + * + * @param array $caps Capability slug => array of role names. + */ + return apply_filters( 'edac_plugin_capabilities', [] ); +} diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index effb62af0..b176e6633 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 x.x.x + * + * @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 x.x.x + * + * @param {HTMLElement} menuEl The `