'Version']); $a11ybridge_ver = !empty($a11ybridge_hdr['Version']) ? $a11ybridge_hdr['Version'] : '0.0.0'; } else { $a11ybridge_ver = '0.0.0'; } define('A11YBRIDGE_VERSION', $a11ybridge_ver); } // ----------------------------------------------------------------------------- // Versioning (single source of truth + context-specific aliases) // ----------------------------------------------------------------------------- // 1) Plugin release version (already defined by you from plugin header) if (!defined('A11YBRIDGE_VERSION')) { define('A11YBRIDGE_VERSION', '1.1.4'); // fallback, should never be used } // 2) Backward-compatible alias (some files still use this) if (!defined('A11YBRIDGE_PLUGIN_VERSION')) { define('A11YBRIDGE_PLUGIN_VERSION', A11YBRIDGE_VERSION); } // 3) Asset version for cache busting (CSS/JS). Keep equal to release version in production. if (!defined('A11YBRIDGE_ASSET_VERSION')) { define('A11YBRIDGE_ASSET_VERSION', A11YBRIDGE_VERSION); } // 4) Data/Schema version for plugin-side storage changes (options/custom tables). // Bump ONLY when you change stored data structures and need a migration. if (!defined('A11YBRIDGE_SCHEMA_VERSION')) { define('A11YBRIDGE_SCHEMA_VERSION', 1); } // 5) Protocol/API version between plugin <-> backend (only bump when you break payload/headers) if (!defined('A11YBRIDGE_API_VERSION')) { define('A11YBRIDGE_API_VERSION', 1); } // PHP-Version prüfen if (version_compare(PHP_VERSION, '7.4', '<')) { wp_die('This plugin demands at least PHP 7.4. Please update your PHP version.'); } require_once plugin_dir_path(__FILE__) . 'includes/catalog.php'; require_once plugin_dir_path(__FILE__) . 'includes/frontend-admin-snapshot.php'; /** * Debug logger (never logs personal data). * Enable with WP_DEBUG on your site. */ if (!function_exists('a11ybridge_debug_log')) { function a11ybridge_debug_log(string $message): void { if (defined('WP_DEBUG') && WP_DEBUG) { // Keep logs generic. Never append request payloads, emails, texts, audio, etc. // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Only logs when WP_DEBUG is enabled. error_log($message); } } } // Create a stable, pseudonymous install ID (used for free-tier quotas / abuse prevention). if (!function_exists('a11ybridge_get_install_id')) { function a11ybridge_get_install_id(): string { $id = (string) get_option('a11yb_install_id', ''); if (empty($id)) { $id = wp_generate_uuid4(); update_option('a11yb_install_id', $id, true); } return $id; } } /** * Cloud / external service consent (opt-in). * * WP.org policy: the plugin must not phone home or register domains automatically. * Cloud requests are only performed after explicit admin opt-in. * (Entering/activating a license is treated as an explicit admin action, but does not automatically enable cloud processing for visitors.) */ if (!function_exists('a11ybridge_get_cloud_settings')) { function a11ybridge_get_cloud_settings(): array { $opt = get_option('a11ybridge_cloud_settings', []); return is_array($opt) ? $opt : []; } } if (!function_exists('a11ybridge_cloud_enabled')) { function a11ybridge_cloud_enabled(): bool { $opt = a11ybridge_get_cloud_settings(); return !empty($opt['enabled']); } } if (!function_exists('a11ybridge_any_ai_feature_enabled')) { function a11ybridge_any_ai_feature_enabled(): bool { $ai = get_option('a11ybridge_ai_settings', []); $simpl = get_option('a11ybridge_simplification_settings', []); if (!is_array($ai)) { $ai = []; } if (!is_array($simpl)) { $simpl = []; } // WP.org build: MVP cloud features. return (!empty($ai['enable_ai_image_description']) || !empty($simpl['enable_text_simplification'])); } } if (!function_exists('a11ybridge_text_simplification_admin_enabled')) { function a11ybridge_text_simplification_admin_enabled(): bool { $simpl = get_option('a11ybridge_simplification_settings', []); if (!is_array($simpl)) { $simpl = []; } return !empty($simpl['enable_text_simplification']); } } if (!function_exists('a11ybridge_text_simplification_allowed')) { function a11ybridge_text_simplification_allowed(): bool { // Requirement: admin must explicitly enable cloud services AND the feature. return a11ybridge_cloud_enabled() && a11ybridge_text_simplification_admin_enabled(); } } if (!function_exists('a11ybridge_ai_image_description_admin_enabled')) { function a11ybridge_ai_image_description_admin_enabled(): bool { $ai = get_option('a11ybridge_ai_settings', []); if (!is_array($ai)) { $ai = []; } return !empty($ai['enable_ai_image_description']); } } if (!function_exists('a11ybridge_ai_image_description_allowed')) { function a11ybridge_ai_image_description_allowed(): bool { // Requirement: admin must explicitly enable cloud services AND the feature. return a11ybridge_cloud_enabled() && a11ybridge_ai_image_description_admin_enabled(); } } if (!function_exists('a11ybridge_has_paid_license')) { function a11ybridge_has_paid_license(): bool { $pro = get_option('a11ybridge_pro_license_settings', []); if (!is_array($pro)) { $pro = []; } $license_hash = !empty($pro['license_hash']) ? (string) $pro['license_hash'] : ''; if ($license_hash === '') { $license_hash = (string) get_option('a11yb_license_key_hash', ''); } $plan = !empty($pro['plan']) ? strtolower(trim((string) $pro['plan'])) : ''; return ($license_hash !== '' && $plan !== 'free'); } } if (!function_exists('a11ybridge_cloud_opted_in')) { function a11ybridge_cloud_opted_in(): bool { // Explicit opt-in only (WP.org compliance): do not treat feature toggles as consent. return a11ybridge_cloud_enabled(); } } /** * Adds suggested privacy policy text to the site's Privacy Policy page (WP "Privacy Policy Guide"). * Site owners remain responsible for their own GDPR/ePrivacy compliance. */ add_action('admin_init', 'a11ybridge_add_privacy_policy_content'); function a11ybridge_add_privacy_policy_content(): void { if (!function_exists('wp_add_privacy_policy_content')) { return; } $content = '
A11yBridge is an accessibility plugin. Some optional backend features may send content to A11yBridge servers in Germany (https://a11ybridge.de) for processing (for example: AI text simplification), and return the result to the site visitor. Backend features are disabled by default and are only used after the site owner enables them.
'; $content .= 'When a backend feature is used, the selected text and the chosen settings (e.g. language and simplification level) are transmitted for processing.
'; $content .= 'A11yBridge does not use analytics cookies. The plugin may store accessibility preferences locally (e.g. browser storage) and may set a session cookie for guest users if needed to persist settings between requests.
'; $content .= 'As with any HTTPS request, IP addresses and request metadata may be processed in server logs for security and troubleshooting.
'; $content .= 'More information: https://a11ybridge.de/en/datenschutzerklaerung/
'; wp_add_privacy_policy_content('A11yBridge', wp_kses_post($content)); } /* if (!is_admin()) { } */ function a11ybridge_should_show_toolbar(): bool { $raw = get_option('a11ybridge_plugin_general_settings', []); if (!is_array($raw)) return true; $v = $raw['show_toolbar'] ?? 1; // Dein Setting ist aktuell "0"/"1" als String -> das passt so: if (is_array($v)) return !empty($v['enabled']); return !in_array((string)$v, ['0', 'false', ''], true); } function a11ybridge_should_show_credit_link(): bool { $raw = get_option('a11ybridge_plugin_general_settings', []); if (!is_array($raw)) return false; // Guideline: front-end credits must be opt-in; default OFF $v = $raw['show_credit_link'] ?? 0; return (int)$v === 1; } // ----------------------------------------------------------------------------- // Front-end design helpers (affects only public site, never wp-admin) // ----------------------------------------------------------------------------- if (!function_exists('a11ybridge_hex_to_rgb')) { function a11ybridge_hex_to_rgb(string $hex): ?array { $hex = trim($hex); if ($hex === '') return null; if ($hex[0] === '#') $hex = substr($hex, 1); if (strlen($hex) === 3) { $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; } if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) return null; return [ hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2)), ]; } } if (!function_exists('a11ybridge_get_frontend_design_settings')) { /** * Compute safe, bounded design values for the public toolbar UI. * All settings are stored locally in WordPress options. */ function a11ybridge_get_frontend_design_settings(): array { $gs = get_option('a11ybridge_plugin_general_settings', []); if (!is_array($gs)) { $gs = []; } $accent = isset($gs['frontend_accent_color']) ? (string)$gs['frontend_accent_color'] : '#0073aa'; $accent = preg_match('/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/', $accent) ? $accent : '#0073aa'; $icon_size = isset($gs['frontend_icon_size']) ? (int)$gs['frontend_icon_size'] : 56; $icon_size = max(40, min(96, $icon_size)); $offset_x = isset($gs['frontend_icon_offset_x']) ? (int)$gs['frontend_icon_offset_x'] : 20; $offset_y = isset($gs['frontend_icon_offset_y']) ? (int)$gs['frontend_icon_offset_y'] : 20; $offset_x = max(0, min(200, $offset_x)); $offset_y = max(0, min(200, $offset_y)); $panel_width = isset($gs['frontend_panel_width']) ? (int)$gs['frontend_panel_width'] : 320; $panel_width = max(240, min(520, $panel_width)); $shape = isset($gs['frontend_icon_shape']) ? (string)$gs['frontend_icon_shape'] : 'circle'; if (!in_array($shape, ['circle','rounded','square'], true)) { $shape = 'circle'; } $style = isset($gs['frontend_icon_style']) ? (string)$gs['frontend_icon_style'] : 'gradient'; if (!in_array($style, ['gradient','solid'], true)) { $style = 'gradient'; } $symbol = isset($gs['frontend_icon_symbol_color']) ? (string)$gs['frontend_icon_symbol_color'] : 'auto'; if (!in_array($symbol, ['auto','white','black'], true)) { $symbol = 'auto'; } $radius = '999px'; if ($shape === 'rounded') { $radius = '14px'; } if ($shape === 'square') { $radius = '6px'; } $rgb = a11ybridge_hex_to_rgb($accent) ?: [0, 115, 170]; $r = (int)$rgb[0]; $g = (int)$rgb[1]; $b = (int)$rgb[2]; // A slightly lighter accent for gradients $lr = min(255, (int) round($r + (255 - $r) * 0.35)); $lg = min(255, (int) round($g + (255 - $g) * 0.35)); $lb = min(255, (int) round($b + (255 - $b) * 0.35)); // Decide icon symbol color (auto chooses black/white based on accent brightness) $icon_fg = '#ffffff'; if ($symbol === 'white') { $icon_fg = '#ffffff'; } elseif ($symbol === 'black') { $icon_fg = '#000000'; } else { // Relative luminance approximation (sRGB, enough for black/white decision) $lum = 0.2126 * $r + 0.7152 * $g + 0.0722 * $b; $icon_fg = ($lum >= 160) ? '#000000' : '#ffffff'; } if ($style === 'solid') { $bg = sprintf('rgba(%d,%d,%d,0.92)', $r, $g, $b); $bg_active = sprintf('rgba(%d,%d,%d,1)', $r, $g, $b); } else { $bg = sprintf('radial-gradient(circle at 30%% 30%%, rgba(%d,%d,%d,0.95), rgba(%d,%d,%d,0.95))', $lr, $lg, $lb, $r, $g, $b); $bg_active = sprintf('radial-gradient(circle at 30%% 30%%, rgba(%d,%d,%d,1), rgba(%d,%d,%d,1))', $lr, $lg, $lb, $r, $g, $b); } return [ 'accent' => $accent, 'icon_size' => $icon_size, 'offset_x' => $offset_x, 'offset_y' => $offset_y, 'panel_width' => $panel_width, 'radius' => $radius, 'bg' => $bg, 'bg_active' => $bg_active, 'icon_fg' => $icon_fg, ]; } } if (!function_exists('a11ybridge_get_toolbar_position')) { function a11ybridge_get_toolbar_position(): string { $gs = get_option('a11ybridge_plugin_general_settings', []); if (!is_array($gs)) { $gs = []; } $pos = isset($gs['toolbar_position']) ? (string)$gs['toolbar_position'] : 'top-right'; $allowed = ['top-right','top-left','bottom-right','bottom-left']; return in_array($pos, $allowed, true) ? $pos : 'top-right'; } } // Add a body class so the front-end icon/panel can follow the configured position. add_filter('body_class', function(array $classes): array { if (is_admin()) return $classes; $pos = a11ybridge_get_toolbar_position(); $classes[] = 'a11yb-toolbar-pos-' . sanitize_key($pos); return $classes; }); function a11ybridge_admin_enqueue_settings_assets($hook) { // Nur auf deiner Settings-Seite laden if ($hook !== 'toplevel_page_a11ybridge-settings') { return; } // Logger init (wie im Frontend) wp_register_script( 'a11ybridge-logger-init', plugins_url('frontend/assets/js/logger-init.js', __FILE__), [], A11YBRIDGE_VERSION, false ); // Catalog (optional, aber sauber) if (function_exists('a11ybridge_load_catalog')) { $catalog = a11ybridge_load_catalog(); wp_add_inline_script( 'a11ybridge-logger-init', 'window.A11YBRIDGE_CATALOG = ' . wp_json_encode($catalog) . ';', 'before' ); } // Debug-Flag (optional) $debug = (defined('WP_DEBUG') && WP_DEBUG) ? 'true' : 'false'; wp_add_inline_script( 'a11ybridge-logger-init', "globalThis.A11YBRIDGE_DEBUG = {$debug};", 'before' ); // Fallback: damit Admin-UI nie crasht, selbst wenn logger-init aus irgendeinem Grund nicht greift wp_add_inline_script( 'a11ybridge-logger-init', 'globalThis.A11yBridgeLogger = globalThis.A11yBridgeLogger || {debug(){},info(){},warn(){},error(){},setLevel(){}};', 'before' ); wp_enqueue_script('a11ybridge-logger-init'); // ------------------------------------------------------------ // Admin inline assets (WP.org compliance: no script/style tags in templates) // ------------------------------------------------------------ // Inline styles handle wp_register_style('a11ybridge-admin-inline', false, [], A11YBRIDGE_VERSION); wp_enqueue_style('a11ybridge-admin-inline'); // Admin CSS (migrated from old template style blocks; WP.org compliant) $__a11ybridge_admin_css = ''; ob_start(); ?> /* WordPress admin dashboard compatible styles */ .a11y-admin-wrapper { background: #fff; margin: 20px 0; padding: 0; } .a11y-admin-wrapper .nav-tab-wrapper { border-bottom: 1px solid #c3c4c7; margin: 0; padding: 0 20px; background: #f6f7f7; } .a11y-admin-wrapper .nav-tab { background: #f6f7f7; border: 1px solid #c3c4c7; color: #50575e; text-decoration: none; padding: 12px 16px; margin: 0 4px -1px 0; font-size: 14px; font-weight: 600; line-height: 1.4; display: inline-block; border-radius: 4px 4px 0 0; } .a11y-admin-wrapper .nav-tab:hover { background: #fff; color: #0073aa; } .a11y-admin-wrapper .nav-tab-active { background: #fff; border-bottom: 1px solid #fff; color: #000; } .a11y-tab-content { padding: 20px; background: #fff; min-height: 600px; } .a11y-settings-section { background: #fff; border: 1px solid #c3c4c7; border-radius: 6px; margin-bottom: 20px; padding: 20px; box-shadow: 0 1px 1px rgba(0,0,0,.04); } .a11y-settings-section h3 { margin-top: 0; color: #1d2327; border-bottom: 1px solid #e0e0e0; padding-bottom: 10px; font-size: 16px; font-weight: 600; } .a11y-field-group { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; padding: 10px; background: #f6f7f7; border-radius: 4px; border: 1px solid #dcdcde; } .a11y-field-group:hover { background: #f0f6fc; border-color: #0073aa; } .a11y-field-group input[type="checkbox"] { margin: 0; transform: scale(1.1); } .a11y-field-group label { flex: 1; margin: 0; font-weight: 500; color: #1d2327; } .a11y-field-group select, .a11y-field-group input[type="text"], .a11y-field-group input[type="number"] { min-width: 180px; padding: 6px 8px; border: 1px solid #8c8f94; border-radius: 3px; font-size: 14px; } .a11y-tooltip { cursor: help; color: #0073aa; font-size: 16px; font-weight: bold; width: 20px; height: 20px; text-align: center; border-radius: 50%; border: 1px solid #0073aa; display: inline-flex; align-items: center; justify-content: center; background: #f0f6fc; } .a11y-tooltip:hover { background: #0073aa; color: white; } .a11y-action-section { background: #f0f6fc; border: 1px solid #0073aa; border-radius: 6px; padding: 20px; margin-top: 20px; } .a11y-button { background: #0073aa !important; color: white !important; border: none !important; padding: 10px 20px !important; border-radius: 4px !important; cursor: pointer; font-size: 14px !important; margin-right: 10px !important; text-decoration: none !important; display: inline-block; font-weight: 500; } .a11y-button:hover { background: #005a87 !important; color: white !important; } .a11y-button:disabled { background: #c3c4c7 !important; cursor: not-allowed; } .a11y-button.secondary { background: #6c757d !important; } .a11y-button.secondary:hover { background: #5a6268 !important; } .a11y-status { margin-top: 15px; padding: 10px; border-radius: 4px; display: none; } .a11y-status.success { background: #d1edff; border: 1px solid #0073aa; color: #0073aa; } .a11y-status.error { background: #fcf2f2; border: 1px solid #d63638; color: #d63638; } .a11y-admin-header { background: linear-gradient(135deg, #0073aa 0%, #005a87 100%); color: white; padding: 20px; margin: 0 0 20px 0; border-radius: 6px; } .a11y-admin-header h1 { margin: 0; color: white; font-size: 24px; font-weight: 600; } .a11y-admin-header p { margin: 8px 0 0 0; color: rgba(255,255,255,0.9); font-size: 14px; } .a11y-legal-badge { display:inline-block; margin-left:8px; padding:2px 6px; border-radius:6px; background:#eef; font-size:11px; } .a11y-legal-badge.accent { background:#efe; } .a11y-legal-badge.is-unavailable { background:#f6f6f6; color:#888; border:1px dashed #ccc; } .a11y-legal-privacy { display:inline-block; margin-left:6px; font-size:11px; color:#666; } .a11yb-card { margin-top: 18px; } .a11yb-stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; margin: 10px 0 14px; } .a11yb-stat { background:#fff; border:1px solid #e5e5e5; border-radius:6px; padding:10px 12px; } .a11yb-stat-label { font-size:12px; color:#666; margin-bottom:4px; } .a11yb-stat-value { font-size:16px; font-weight:600; } .a11yb-progress { background:#f1f1f1; border-radius:999px; height:10px; overflow:hidden; margin-top:6px; } .a11yb-progress-bar { background:#2271b1; height:10px; } .a11yb-progress-meta { margin-top:8px; color:#333; } .a11yb-inline-msg { margin-left:8px; } .a11y-disabled { opacity:.55; pointer-events:none; } .a11y-disabled-badge{ display:inline-block; margin-left:8px; padding:2px 8px; font-size:12px; border-radius:12px; background:#edf2f7; color:#2d3748; border:1px solid #cbd5e0; vertical-align:middle; } .a11y-disabled-wrap{ position:relative; } .a11y-disabled-wrap .a11y-disabled-overlay{ position:absolute; inset:0; background:transparent; cursor:not-allowed; } /* AI Alt-Text browser panel */ .a11yb-alttext-panel .widefat td, .a11yb-alttext-panel .widefat th { vertical-align: middle; } .a11yb-alttext-panel img { border-radius: 4px; border: 1px solid #dcdcde; } .a11yb-inline-msg{ display:inline-block; padding:6px 10px; background:#f6f7f7; border:1px solid #dcdcde; border-radius:4px; color:#1d2327; } __('Unsaved changes', 'a11ybridge'), 'message' => __('You have unsaved changes. Save before leaving this page?', 'a11ybridge'), 'save' => __('Save changes', 'a11ybridge'), 'discard' => __('Discard changes', 'a11ybridge'), 'cancel' => __('Cancel', 'a11ybridge'), ]) . ';', 'before' ); // Admin config (AJAX + nonces) wp_add_inline_script( 'a11ybridge-admin-inline', 'window.A11YBRIDGE_ADMIN = ' . wp_json_encode([ 'ajaxUrl' => admin_url('admin-ajax.php'), 'noncePro' => wp_create_nonce('a11ybridge_pro_nonce'), 'nonceScanner' => wp_create_nonce('a11ybridge_scanner_nonce'), 'nonceUser' => wp_create_nonce('a11ybridge_user_nonce'), 'nonceTestAjax'=> wp_create_nonce('a11ybridge_test_ajax'), 'restUrl' => rest_url(), 'wpRestNonce' => wp_create_nonce('wp_rest'), ]) . ';', 'before' ); // Frontend design reset helper (admin settings page only) wp_add_inline_script( 'a11ybridge-admin-inline', "(function(){\n" . " const btn = document.getElementById('a11yb-frontend-design-reset');\n" . " if (!btn) return;\n" . " btn.addEventListener('click', function(){\n" . " if (!confirm('Restore default frontend design settings?')) return;\n" . " const set = function(id,val){\n" . " const el = document.getElementById(id);\n" . " if(!el) return;\n" . " el.value = val;\n" . " try { el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e) {}\n" . " };\n" . " set('frontend_accent_color', '#0073aa');\n" . " set('frontend_icon_symbol_color', 'auto');\n" . " set('frontend_icon_style', 'gradient');\n" . " set('frontend_icon_shape', 'circle');\n" . " set('frontend_icon_size', '56');\n" . " set('frontend_icon_offset_x', '20');\n" . " set('frontend_icon_offset_y', '20');\n" . " set('frontend_panel_width', '320');\n" . " });\n" . "})();", 'after' ); } add_action('admin_enqueue_scripts', 'a11ybridge_admin_enqueue_settings_assets', 20); function a11ybridge_enqueue_frontend_assets() { // Guard: Toolbar komplett aus? Dann nichts laden. if (!a11ybridge_should_show_toolbar()) { // später durch Logger ersetzen (PHP-Logger), aber ok fürs Debug a11ybridge_debug_log('A11yBridge: Toolbar disabled, skipping asset enqueue.'); return; } // ------------------------------------------------------------ // 0) Styles // ------------------------------------------------------------ wp_enqueue_style( 'a11ybridge-styles', A11YBRIDGE_URL . 'frontend/assets/css/styles.css', [], A11YBRIDGE_VERSION ); // Apply admin-defined front-end design variables (icon size, colors, offsets, panel width) $design = a11ybridge_get_frontend_design_settings(); $design_css = ":root{\n"; $design_css .= "--a11yb-accent: {$design['accent']};\n"; $design_css .= "--a11yb-icon-size: {$design['icon_size']}px;\n"; $design_css .= "--a11yb-icon-offset-x: {$design['offset_x']}px;\n"; $design_css .= "--a11yb-icon-offset-y: {$design['offset_y']}px;\n"; $design_css .= "--a11yb-panel-width: {$design['panel_width']}px;\n"; $design_css .= "--a11yb-icon-radius: {$design['radius']};\n"; $design_css .= "--a11yb-icon-bg: {$design['bg']};\n"; $design_css .= "--a11yb-icon-bg-active: {$design['bg_active']};\n"; $design_css .= "--a11yb-icon-fg: {$design['icon_fg']};\n"; $design_css .= "}\n"; wp_add_inline_style('a11ybridge-styles', $design_css); // ------------------------------------------------------------ // 1) Logger init (muss vor allem anderen kommen) // ------------------------------------------------------------ wp_register_script( 'a11ybridge-logger-init', plugins_url('frontend/assets/js/logger-init.js', __FILE__), [], A11YBRIDGE_VERSION, false ); // Catalog inline BEFORE everything else $catalog = a11ybridge_load_catalog(); wp_add_inline_script( 'a11ybridge-logger-init', 'window.A11YBRIDGE_CATALOG = ' . wp_json_encode($catalog) . ';', 'before' ); // Debug-Flag VOR logger-init setzen (damit logger-init direkt den Level wählen kann) $debug = (defined('WP_DEBUG') && WP_DEBUG) ? 'true' : 'false'; wp_add_inline_script( 'a11ybridge-logger-init', "globalThis.A11YBRIDGE_DEBUG = {$debug};", 'before' ); // Fallback: falls irgendwas schief läuft, crasht nix wegen fehlendem Logger wp_add_inline_script( 'a11ybridge-logger-init', 'globalThis.A11yBridgeLogger = globalThis.A11yBridgeLogger || {debug(){},info(){},warn(){},error(){},setLevel(){}};', 'before' ); // 2) Settings handler (klassisch, NICHT module!) wp_register_script( 'a11ybridge-settings-handler', plugins_url('frontend/assets/js/settings-handler.js', __FILE__), ['a11ybridge-logger-init'], A11YBRIDGE_VERSION, true ); // a11ybridgePlugin config dahin, wo settings-handler es garantiert sieht $a11ybridge_gs = get_option('a11ybridge_plugin_general_settings', []); $a11ybridge_show_credit = (is_array($a11ybridge_gs) && !empty($a11ybridge_gs['show_credit_link'])) ? 1 : 0; wp_localize_script('a11ybridge-settings-handler', 'a11ybridgePlugin', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('a11ybridge_settings_nonce'), 'isUserLoggedIn' => is_user_logged_in(), 'cloudEnabled' => (int) a11ybridge_cloud_enabled(), 'adminTextSimplificationEnabled' => (int) a11ybridge_text_simplification_admin_enabled(), 'adminTextSimplificationShowButton' => (int) a11ybridge_text_simplification_show_toolbar_button(), 'simplifyProxyUrl' => esc_url_raw( rest_url('a11ybridge/v1/ai/simplify') ), 'aiTokenUrl' => esc_url_raw( rest_url('a11ybridge/v1/ai/token') ), 'showCreditLink' => (int) $a11ybridge_show_credit, 'brandUrl' => apply_filters('a11ybridge_brand_url', 'https://a11ybridge.de/'), 'brandText' => apply_filters('a11ybridge_brand_text', 'A11yBridge®'), ]); // Frontend admin snapshot for Settings Resolver (consent TTL, defaults, etc.) $admin_snapshot = function_exists('a11ybridge_get_frontend_admin_snapshot') ? a11ybridge_get_frontend_admin_snapshot() : []; wp_add_inline_script( 'a11ybridge-settings-handler', 'window.A11YBRIDGE_ADMIN = ' . wp_json_encode([ 'settings' => $admin_snapshot ]) . ';', 'before' ); // TTS (falls toolbar.js window.textToSpeechManager nutzt) wp_register_script( 'a11ybridge-text-to-speech', plugins_url('frontend/assets/js/text-to-speech.js', __FILE__), ['a11ybridge-logger-init', 'a11ybridge-settings-handler'], A11YBRIDGE_VERSION, true ); // Text Simplification (falls du externes Script hast) wp_register_script( 'a11ybridge-text-simplification', plugins_url('frontend/assets/js/text-simplification.js', __FILE__), ['a11ybridge-logger-init', 'a11ybridge-settings-handler'], A11YBRIDGE_VERSION, true ); wp_script_add_data('a11ybridge-text-to-speech', 'type', 'module'); wp_script_add_data('a11ybridge-text-simplification', 'type', 'module'); // toolbar deps erweitern (settings-handler MUSS vor toolbar laufen) $toolbar_deps = ['a11ybridge-logger-init', 'a11ybridge-settings-handler', 'a11ybridge-text-to-speech', 'a11ybridge-text-simplification']; // ------------------------------------------------------------ // (ES Module) hängt von Logger + Settings + Features ab // ------------------------------------------------------------ $toolbar_deps = [ 'a11ybridge-logger-init', 'a11ybridge-settings-handler', 'a11ybridge-text-to-speech', 'a11ybridge-text-simplification', ];wp_register_script( 'a11ybridge-toolbar', plugins_url('frontend/assets/js/toolbar.js', __FILE__), $toolbar_deps, A11YBRIDGE_VERSION, true ); // Toolbar ist ES-Module: wp_script_add_data('a11ybridge-toolbar', 'type', 'module'); // ------------------------------------------------------------ // 6) Enqueue nur den Einstiegspunkt. Der Rest kommt über deps. // ------------------------------------------------------------ wp_enqueue_script('a11ybridge-toolbar'); } add_action('wp_enqueue_scripts', 'a11ybridge_enqueue_frontend_assets', 20); // **Textdomain für Übersetzungen laden** // Zentrale Registrierung für Voice Recognition // (WP.org build) Voice recognition registration removed in MVP. // Frontend + Admin sollen die Registrierung kennen // ✅ WICHTIG: User Settings Manager IMMER laden (sowohl Frontend als auch Admin für AJAX) require_once plugin_dir_path(__FILE__) . 'includes/user-settings-manager.php'; new A11yBridge_User_Settings_Manager(); // AJAX-Hooks registrieren // ✅ AJAX-Handler DIREKT hier registrieren (nicht in toolbar.php) add_action('wp_ajax_a11ybridge_test_ajax', 'a11ybridge_test_ajax_handler'); function a11ybridge_test_ajax_handler() { // Admin-only debug endpoint if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'forbidden'], 403); } // Optional nonce check (supports GET/POST) $nonce = isset($_REQUEST['_wpnonce']) ? sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])) : ''; if ($nonce && !wp_verify_nonce($nonce, 'a11ybridge_test_ajax')) { wp_send_json_error(['message' => 'bad_nonce'], 403); } a11ybridge_debug_log('[A11yBridge] a11ybridge_test_ajax called'); wp_send_json_success(['message' => 'OK']); } // **Admin-Einstellungen einbinden** if( is_admin() ) { require_once plugin_dir_path(__FILE__) . 'admin/settings.php'; } /** * AI proxy helpers (public visitors). * * The AI simplification feature uses a public REST proxy endpoint. * To reduce trivial unauthenticated abuse (quota/costs), we require a short-lived token. * * Token flow: * - Frontend requests /ai/token (GET) to receive a short-lived token. * - Frontend calls /ai/simplify (POST) with header X-A11YB-Token. * - Server validates token (and binds it to the requester IP) before proxying upstream. */ function a11ybridge_ai_proxy_token_key( string $token ): string { return 'a11yb_ai_tok_' . hash_hmac( 'sha256', $token, wp_salt( 'auth' ) ); } function a11ybridge_ai_proxy_issue_token(): string { try { $token = bin2hex( random_bytes( 16 ) ); // 32 hex chars } catch ( Exception $e ) { $token = wp_generate_password( 32, false, false ); } $ip_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip = rest_is_ip_address( $ip_raw ) ? $ip_raw : '0.0.0.0'; $ip_sig = hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ); // Token valid for 10 minutes. set_transient( a11ybridge_ai_proxy_token_key( $token ), $ip_sig, 10 * MINUTE_IN_SECONDS ); return $token; } function a11ybridge_ai_proxy_check_rate_limit( string $bucket, int $limit, int $window_seconds ): bool { $ip_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip = rest_is_ip_address( $ip_raw ) ? $ip_raw : '0.0.0.0'; $key = 'a11yb_rl_' . sanitize_key( $bucket ) . '_' . hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return false; } set_transient( $key, $count + 1, $window_seconds ); return true; } function a11ybridge_rest_ai_token( WP_REST_Request $req ) { // Public endpoint, but rate-limit to avoid being used as a token vending machine. if ( ! a11ybridge_ai_proxy_check_rate_limit( 'ai_token', 30, MINUTE_IN_SECONDS ) ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'rate_limited' ], 429 ); } // If cloud services are not enabled, do not issue tokens (no AI without explicit opt-in). if (function_exists('a11ybridge_text_simplification_allowed') && !a11ybridge_text_simplification_allowed()) { return new WP_REST_Response([ 'ok' => false, 'error' => 'cloud_or_feature_disabled' ], 403); } $token = a11ybridge_ai_proxy_issue_token(); $resp = new WP_REST_Response( [ 'ok' => true, 'token' => $token ], 200 ); $resp->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' ); $resp->header( 'Pragma', 'no-cache' ); return $resp; } function a11ybridge_rest_ai_simplify_permission( WP_REST_Request $req ) { // Admin must explicitly enable backend services AND the feature (double opt-in). if (function_exists('a11ybridge_text_simplification_allowed') && !a11ybridge_text_simplification_allowed()) { return new WP_Error('a11ybridge_cloud_disabled', 'Backend services are disabled. Enable the backend connection in the plugin settings first.', [ 'status' => 403 ]); } $simpl = get_option('a11ybridge_simplification_settings', []); if (!is_array($simpl) || empty($simpl['enable_text_simplification'])) { return new WP_Error('a11ybridge_feature_disabled', 'AI text simplification is disabled by the site administrator', [ 'status' => 403 ]); } $token = sanitize_text_field( (string) $req->get_header( 'x-a11yb-token' ) ); if ( $token === '' ) { return new WP_Error( 'a11ybridge_missing_token', 'Missing AI token', [ 'status' => 403 ] ); } $key = a11ybridge_ai_proxy_token_key( $token ); $ip_sig_expected = (string) get_transient( $key ); if ( $ip_sig_expected === '' ) { return new WP_Error( 'a11ybridge_invalid_token', 'Invalid or expired AI token', [ 'status' => 403 ] ); } $ip_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip = rest_is_ip_address( $ip_raw ) ? $ip_raw : '0.0.0.0'; $ip_sig = hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ); if ( ! hash_equals( $ip_sig_expected, $ip_sig ) ) { return new WP_Error( 'a11ybridge_token_mismatch', 'Token mismatch', [ 'status' => 403 ] ); } // One-time token: consume it. delete_transient( $key ); return true; } /** * Admin-only REST endpoints for AI Alt-Text generation (media library). * * Why REST + nonce, not the public token flow: * - This writes to your database (_wp_attachment_image_alt) and should never be callable by anonymous visitors. * - We rely on WordPress cookie auth + X-WP-Nonce (wp_rest) and require manage_options. */ function a11ybridge_rest_admin_images_permission( WP_REST_Request $req ) { return current_user_can( 'manage_options' ); } function a11ybridge_rest_admin_alt_text_permission( WP_REST_Request $req ) { return current_user_can( 'manage_options' ); } function a11ybridge_rest_admin_alt_text_update( WP_REST_Request $req ) { $json = $req->get_json_params(); if ( ! is_array( $json ) ) { $json = []; } $attachment_id = absint( $json['attachment_id'] ?? 0 ); if ( ! $attachment_id ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'missing_attachment_id' ], 400 ); } $post = get_post( $attachment_id ); if ( ! $post || $post->post_type !== 'attachment' ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_attachment' ], 404 ); } $mime = (string) get_post_mime_type( $attachment_id ); if ( strpos( $mime, 'image/' ) !== 0 ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'not_an_image' ], 400 ); } $alt_raw = isset( $json['alt_text'] ) ? (string) $json['alt_text'] : ''; // Alt-text must be plain text (no HTML). Empty string is allowed (decorative images). $alt = wp_strip_all_tags( $alt_raw, true ); $alt = sanitize_text_field( $alt ); update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); return new WP_REST_Response( [ 'ok' => true, 'attachment_id' => $attachment_id, 'altText' => $alt, ], 200 ); } function a11ybridge_alttext_filesize_meta_key(): string { return '_a11ybridge_filesize'; } /** * Compute file size and store in attachment postmeta to enable fast sorting/filtering. * Returns bytes (0 if unknown). */ function a11ybridge_alttext_ensure_filesize_meta( int $attachment_id ): int { $key = a11ybridge_alttext_filesize_meta_key(); $existing = get_post_meta( $attachment_id, $key, true ); if ( $existing !== '' && $existing !== null ) { $n = (int) $existing; if ( $n >= 0 ) { return $n; } } $file = get_attached_file( $attachment_id ); if ( ! $file || ! is_string( $file ) ) { return 0; } // Avoid warnings on missing files if ( ! file_exists( $file ) ) { return 0; } $bytes = @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $bytes === false ) { return 0; } $bytes = max( 0, (int) $bytes ); update_post_meta( $attachment_id, $key, $bytes ); return $bytes; } /** * Best-effort indexing for older images: fill missing filesize meta in small batches. */ function a11ybridge_alttext_index_missing_filesize_meta( int $limit = 200 ): void { $limit = min( 1000, max( 1, $limit ) ); $key = a11ybridge_alttext_filesize_meta_key(); $q = new WP_Query( [ 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'image', 'fields' => 'ids', 'posts_per_page' => $limit, 'meta_query' => [ [ 'key' => $key, 'compare' => 'NOT EXISTS', ], ], 'no_found_rows' => true, ] ); foreach ( $q->posts as $id ) { a11ybridge_alttext_ensure_filesize_meta( (int) $id ); } } add_action( 'add_attachment', function ( $post_id ) { $id = (int) $post_id; if ( $id <= 0 ) { return; } $mime = (string) get_post_mime_type( $id ); if ( strpos( $mime, 'image/' ) !== 0 ) { return; } a11ybridge_alttext_ensure_filesize_meta( $id ); }, 10, 1 ); function a11ybridge_rest_admin_images( WP_REST_Request $req ) { $page = max( 1, (int) $req->get_param( 'page' ) ); $per_page = (int) $req->get_param( 'per_page' ); if ( $per_page <= 0 ) { $per_page = 50; } $per_page = min( 500, max( 1, $per_page ) ); $order = strtolower( (string) $req->get_param( 'order' ) ) === 'desc' ? 'DESC' : 'ASC'; $orderby = sanitize_key( (string) $req->get_param( 'orderby' ) ); if ( ! $orderby ) { $orderby = 'date'; } $allowed_orderby = [ 'date', 'filesize', 'title', 'alt_text' ]; if ( ! in_array( $orderby, $allowed_orderby, true ) ) { $orderby = 'date'; } // Backwards compatible: ?missing=1 === has_alt=no $missing = (int) $req->get_param( 'missing' ) === 1; $has_alt = sanitize_key( (string) $req->get_param( 'has_alt' ) ); if ( ! $has_alt ) { $has_alt = $missing ? 'no' : 'any'; } $allowed_has = [ 'any', 'yes', 'no' ]; if ( ! in_array( $has_alt, $allowed_has, true ) ) { $has_alt = 'any'; } $date_after = sanitize_text_field( (string) $req->get_param( 'date_after' ) ); $date_before = sanitize_text_field( (string) $req->get_param( 'date_before' ) ); $size_gt = (int) $req->get_param( 'size_gt' ); $size_lt = (int) $req->get_param( 'size_lt' ); $size_gt = max( 0, $size_gt ); $size_lt = max( 0, $size_lt ); // Optional payload optimization for bulk ops $ids_only = (int) $req->get_param( 'ids_only' ) === 1; if ( $orderby === 'filesize' || $size_gt > 0 || $size_lt > 0 ) { // Fill missing metas in small batches; keeps sorting/filtering consistent over time. a11ybridge_alttext_index_missing_filesize_meta( 250 ); } global $wpdb; $posts = $wpdb->posts; $pm = $wpdb->postmeta; $alt_key = '_wp_attachment_image_alt'; $size_key = a11ybridge_alttext_filesize_meta_key(); $joins = $wpdb->prepare( " LEFT JOIN {$pm} alt ON (alt.post_id = p.ID AND alt.meta_key = %s) LEFT JOIN {$pm} fs ON (fs.post_id = p.ID AND fs.meta_key = %s) ", $alt_key, $size_key ); $where = " WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.post_mime_type LIKE 'image/%' "; $params = []; // Alt-text filter if ( $has_alt === 'yes' ) { $where .= " AND (alt.meta_value IS NOT NULL AND alt.meta_value <> '') "; } elseif ( $has_alt === 'no' ) { $where .= " AND (alt.meta_value IS NULL OR alt.meta_value = '') "; } // Date filters (YYYY-MM-DD) if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_after ) ) { $where .= " AND p.post_date >= %s "; $params[] = $date_after . ' 00:00:00'; } if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_before ) ) { $where .= " AND p.post_date <= %s "; $params[] = $date_before . ' 23:59:59'; } // Filesize filters (bytes). Missing meta -> treated as 0. if ( $size_gt > 0 ) { $where .= " AND COALESCE(CAST(fs.meta_value AS UNSIGNED), 0) >= %d "; $params[] = $size_gt; } if ( $size_lt > 0 ) { $where .= " AND COALESCE(CAST(fs.meta_value AS UNSIGNED), 0) <= %d "; $params[] = $size_lt; } // Sorting $order_sql = $order === 'DESC' ? 'DESC' : 'ASC'; $orderby_sql = "p.post_date {$order_sql}, p.ID {$order_sql}"; if ( $orderby === 'title' ) { $orderby_sql = "p.post_title {$order_sql}, p.post_date {$order_sql}, p.ID {$order_sql}"; } elseif ( $orderby === 'filesize' ) { $orderby_sql = "COALESCE(CAST(fs.meta_value AS UNSIGNED), 0) {$order_sql}, p.post_date {$order_sql}, p.ID {$order_sql}"; } elseif ( $orderby === 'alt_text' ) { // Deterministic: treat NULL as empty string $orderby_sql = "COALESCE(alt.meta_value, '') {$order_sql}, p.post_date {$order_sql}, p.ID {$order_sql}"; } $offset = ( $page - 1 ) * $per_page; // Count (cached for large media libraries) $count_sql = "SELECT COUNT(1) FROM {$posts} p {$joins} {$where}"; $count_cache_key = 'a11yb_altimg_cnt_' . md5( wp_json_encode( [ 'orderby' => $orderby, 'order' => $order_sql, 'has_alt' => $has_alt, 'date_after' => $date_after, 'date_before'=> $date_before, 'size_gt' => $size_gt, 'size_lt' => $size_lt, ] ) ); $total = get_transient( $count_cache_key ); if ( $total === false ) { $total = $params ? (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $params ) ) : (int) $wpdb->get_var( $count_sql ); set_transient( $count_cache_key, $total, 60 ); } else { $total = (int) $total; } // IDs page $sql = "SELECT p.ID FROM {$posts} p {$joins} {$where} ORDER BY {$orderby_sql} LIMIT %d OFFSET %d"; $params_page = array_merge( $params, [ $per_page, $offset ] ); $ids = $wpdb->get_col( $wpdb->prepare( $sql, $params_page ) ); if ( ! is_array( $ids ) ) { $ids = []; } $items = []; foreach ( $ids as $id ) { $id = (int) $id; if ( $ids_only ) { $items[] = $id; continue; } $alt = (string) get_post_meta( $id, $alt_key, true ); $bytes = a11ybridge_alttext_ensure_filesize_meta( $id ); $meta = wp_get_attachment_metadata( $id ); if ( ! is_array( $meta ) ) { $meta = []; } $items[] = [ 'id' => $id, 'title' => (string) get_the_title( $id ), 'date' => (string) get_the_date( 'Y-m-d H:i', $id ), 'alt' => $alt, 'thumb_url' => (string) wp_get_attachment_image_url( $id, 'thumbnail' ), 'edit_url' => (string) get_edit_post_link( $id, '' ), 'filesize' => $bytes, 'filesize_h' => $bytes > 0 ? (string) size_format( $bytes, 1 ) : '', 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : 0, 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : 0, ]; } return new WP_REST_Response( [ 'ok' => true, 'page' => $page, 'per_page' => $per_page, 'total' => $total, 'total_pages' => (int) ceil( $total / max( 1, $per_page ) ), 'missing' => ( $has_alt === 'no' ), 'ids_only' => $ids_only, 'items' => $items, ], 200 ); } function a11ybridge_ai_alt_text_truncate( string $s, int $max_chars ): string { $s = trim( $s ); if ( $max_chars <= 0 ) { return $s; } if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) { if ( mb_strlen( $s, 'UTF-8' ) > $max_chars ) { return rtrim( mb_substr( $s, 0, $max_chars, 'UTF-8' ) ); } return $s; } if ( strlen( $s ) > $max_chars ) { return rtrim( substr( $s, 0, $max_chars ) ); } return $s; } /** * Reduce upstream payloads to a safe, small debug object (no base64 leaks). */ function a11ybridge_ai_alt_text_safe_upstream_detail( $data ) { if ( ! is_array( $data ) ) { return null; } // Remove known large/sensitive fields if ( isset( $data['image_base64'] ) ) { $data['image_base64'] = '[removed]'; } if ( isset( $data['data'] ) && is_array( $data['data'] ) ) { if ( isset( $data['data']['image_base64'] ) ) { $data['data']['image_base64'] = '[removed]'; } // Avoid echoing huge payloads foreach ( ['image','file','bytes'] as $k ) { if ( isset( $data['data'][$k] ) ) { $data['data'][$k] = '[removed]'; } } } // Keep only relevant keys when possible $keep = ['ok','error','code','message','detail','status','description','alt_text','data','http_code']; $out = []; foreach ( $keep as $k ) { if ( array_key_exists( $k, $data ) ) { $out[$k] = $data[$k]; } } if ( empty( $out ) ) { $out = $data; } // Truncate any long strings array_walk_recursive($out, function (&$v) { if ( is_string($v) && strlen($v) > 800 ) { $v = substr($v, 0, 800) . '…'; } }); return $out; } /** * Convert an attachment image into a size-limited base64 payload. * Tries to downscale large images to keep API payloads reasonable. */ function a11ybridge_ai_alt_text_attachment_to_base64( int $attachment_id ) { $file = get_attached_file( $attachment_id ); if ( ! $file || ! is_string( $file ) || ! file_exists( $file ) ) { return new WP_Error( 'a11ybridge_missing_file', 'Attachment file not found', [ 'status' => 404 ] ); } // Target payload size (raw bytes BEFORE base64). $max_bytes = (int) apply_filters( 'a11ybridge_ai_alttext_max_bytes', 2_500_000 ); // ~2.5 MB if ( $max_bytes < 200_000 ) { $max_bytes = 200_000; } // Absolute cap for input files (prevents PHP memory blow-ups on gigantic originals). $max_input_file_bytes = (int) apply_filters( 'a11ybridge_ai_alttext_max_input_file_bytes', 25_000_000 ); // 25 MB if ( $max_input_file_bytes < 5_000_000 ) { $max_input_file_bytes = 5_000_000; } $file_size = @filesize( $file ); if ( $file_size !== false ) { $file_size = (int) $file_size; if ( $file_size <= 0 ) { $file_size = false; } } if ( $file_size !== false && $file_size > $max_input_file_bytes ) { return new WP_Error( 'a11ybridge_image_too_large', 'Image file is too large to process safely. Please downscale it first.', [ 'status' => 413, 'bytes' => $file_size ] ); } $bytes = false; // If the file is larger than our target payload, resize first WITHOUT loading the whole file into a PHP string. if ( $file_size !== false && $file_size > $max_bytes ) { $editor = wp_get_image_editor( $file ); if ( is_wp_error( $editor ) ) { // Can't resize safely; avoid loading huge files into memory. return new WP_Error( 'a11ybridge_image_editor_unavailable', 'Image is too large and cannot be resized on this server (missing GD/Imagick).', [ 'status' => 413 ] ); } $max_dim = (int) apply_filters( 'a11ybridge_ai_alttext_max_dim', 1024 ); if ( $max_dim < 256 ) { $max_dim = 256; } $resized = $editor->resize( $max_dim, $max_dim, false ); if ( is_wp_error( $resized ) ) { return new WP_Error( 'a11ybridge_resize_failed', 'Failed to resize image for AI processing', [ 'status' => 500 ] ); } if ( method_exists( $editor, 'set_quality' ) ) { $q = (int) apply_filters( 'a11ybridge_ai_alttext_jpeg_quality', 82 ); if ( $q < 40 ) { $q = 40; } if ( $q > 95 ) { $q = 95; } $editor->set_quality( $q ); } $tmp = wp_tempnam( 'a11yb_alt_' ); if ( ! $tmp ) { return new WP_Error( 'a11ybridge_tmp_failed', 'Failed to create temp file for image resize', [ 'status' => 500 ] ); } // Save as JPEG for predictable size. $saved = $editor->save( $tmp, 'image/jpeg' ); $tmp_path = is_array( $saved ) && ! empty( $saved['path'] ) ? (string) $saved['path'] : (string) $tmp; if ( is_wp_error( $saved ) || ! $tmp_path || ! file_exists( $tmp_path ) ) { @unlink( $tmp ); return new WP_Error( 'a11ybridge_save_failed', 'Failed to re-encode image for AI processing', [ 'status' => 500 ] ); } $bytes = @file_get_contents( $tmp_path ); @unlink( $tmp_path ); if ( $bytes === false ) { return new WP_Error( 'a11ybridge_read_failed', 'Failed to read resized image', [ 'status' => 500 ] ); } } else { // Small enough, safe to load. $bytes = @file_get_contents( $file ); if ( $bytes === false ) { return new WP_Error( 'a11ybridge_read_failed', 'Failed to read attachment file', [ 'status' => 500 ] ); } // If size was unknown and we still exceed target payload, attempt resize. if ( strlen( $bytes ) > $max_bytes ) { $editor = wp_get_image_editor( $file ); if ( ! is_wp_error( $editor ) ) { $max_dim = (int) apply_filters( 'a11ybridge_ai_alttext_max_dim', 1024 ); if ( $max_dim < 256 ) { $max_dim = 256; } $resized = $editor->resize( $max_dim, $max_dim, false ); if ( ! is_wp_error( $resized ) ) { if ( method_exists( $editor, 'set_quality' ) ) { $q = (int) apply_filters( 'a11ybridge_ai_alttext_jpeg_quality', 82 ); if ( $q < 40 ) { $q = 40; } if ( $q > 95 ) { $q = 95; } $editor->set_quality( $q ); } $tmp = wp_tempnam( 'a11yb_alt_' ); if ( $tmp ) { $saved = $editor->save( $tmp, 'image/jpeg' ); $tmp_path = is_array( $saved ) && ! empty( $saved['path'] ) ? (string) $saved['path'] : (string) $tmp; if ( ! is_wp_error( $saved ) && $tmp_path && file_exists( $tmp_path ) ) { $bytes2 = @file_get_contents( $tmp_path ); @unlink( $tmp_path ); if ( $bytes2 !== false && strlen( $bytes2 ) < strlen( $bytes ) ) { $bytes = $bytes2; } } else { @unlink( $tmp ); } } } } } } // Hard safety cap (base64 expands by ~33%) if ( strlen( $bytes ) > 6_000_000 ) { return new WP_Error( 'a11ybridge_image_too_large', 'Image is too large to process (payload cap)', [ 'status' => 413 ] ); } return base64_encode( $bytes ); } function a11ybridge_rest_ai_alt_text_permission( WP_REST_Request $req ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'a11ybridge_forbidden', 'Forbidden', [ 'status' => 403 ] ); } if ( function_exists( 'a11ybridge_ai_image_description_allowed' ) && ! a11ybridge_ai_image_description_allowed() ) { return new WP_Error( 'a11ybridge_cloud_disabled', 'Backend services or the AI image description feature are disabled. Enable them in the plugin settings first.', [ 'status' => 403 ] ); } return true; } function a11ybridge_rest_ai_alt_text( WP_REST_Request $req ) { $json = $req->get_json_params(); if ( ! is_array( $json ) ) { $json = []; } $attachment_id = absint( $json['attachment_id'] ?? 0 ); if ( ! $attachment_id ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'missing_attachment_id' ], 400 ); } $force = ! empty( $json['force'] ); $post = get_post( $attachment_id ); if ( ! $post || $post->post_type !== 'attachment' ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_attachment' ], 404 ); } $mime = (string) get_post_mime_type( $attachment_id ); if ( strpos( $mime, 'image/' ) !== 0 ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'not_an_image' ], 400 ); } // If alt-text already exists and caller didn't request regeneration, return early. $existing_alt = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); if ( ! $force && trim( $existing_alt ) !== '' ) { return new WP_REST_Response( [ 'ok' => true, 'attachment_id' => $attachment_id, 'altText' => $existing_alt, 'skipped' => true, ], 200 ); } try { $image_base64 = a11ybridge_ai_alt_text_attachment_to_base64( $attachment_id ); } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[A11yBridge] AltText fatal during image load: ' . $e->getMessage() ); } return new WP_REST_Response( [ 'ok' => false, 'error' => 'internal_error' ], 500 ); } if ( is_wp_error( $image_base64 ) ) { $data = $image_base64->get_error_data(); $status = 500; if ( is_array( $data ) && isset( $data['status'] ) ) { $status = (int) $data['status']; } return new WP_REST_Response( [ 'ok' => false, 'error' => $image_base64->get_error_code(), 'detail' => $image_base64->get_error_message() ], $status ); } $ai = get_option( 'a11ybridge_ai_settings', [] ); if ( ! is_array( $ai ) ) { $ai = []; } $description_level = isset( $ai['description_level'] ) ? (string) $ai['description_level'] : 'basic'; $target_language = isset( $ai['target_language'] ) ? (string) $ai['target_language'] : 'original'; // --- License hash server-side (NICHT im JS) --- $pro = get_option( 'a11ybridge_pro_license_settings', [] ); if ( ! is_array( $pro ) ) { $pro = []; } $plan = ''; if ( ! empty( $pro['plan'] ) ) { $plan = strtolower( trim( (string) $pro['plan'] ) ); } $license_hash = ''; if ( ! empty( $pro['license_hash'] ) ) { $license_hash = (string) $pro['license_hash']; } if ( ! $license_hash ) { $license_hash = (string) get_option( 'a11yb_license_key_hash', '' ); } $is_paid = ( ! empty( $license_hash ) && $plan !== 'free' ); $install_id = $is_paid ? '' : a11ybridge_get_install_id(); // Optional: rate limit per IP (protects your quota + my server bill) $ip_raw = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip = rest_is_ip_address( $ip_raw ) ? $ip_raw : '0.0.0.0'; $rl_key = 'a11yb_rl_alt_' . hash_hmac( 'sha256', (string) $ip, wp_salt( 'auth' ) ); $count = (int) get_transient( $rl_key ); if ( $count > 120 ) { // 120/h should be plenty for admin clicks return new WP_REST_Response( [ 'ok' => false, 'error' => 'rate_limited' ], 429 ); } set_transient( $rl_key, $count + 1, HOUR_IN_SECONDS ); $payload = [ 'image_base64' => (string) $image_base64, 'description_level' => $description_level, 'target_language' => $target_language, 'meta' => [], ]; if ( ! $is_paid && $install_id ) { $payload['meta']['installation_id'] = $install_id; } $headers = [ 'Content-Type' => 'application/json; charset=utf-8', 'X-AB-Origin' => home_url( '/' ), 'X-AB-Client' => 'a11ybridge-plugin', 'X-AB-Client-Version' => (string) A11YBRIDGE_VERSION, ]; if ( $is_paid ) { $headers['X-A11Y-License-Hash'] = $license_hash; } else { $headers['X-AB-Installation-Id'] = $install_id; } $timeout = (int) apply_filters( 'a11ybridge_ai_alttext_timeout', 180 ); $endpoint = function_exists( 'a11ybridge_backend_rest_url' ) ? a11ybridge_backend_rest_url( '/image/describe' ) : 'https://a11ybridge.de/wp-json/a11ybridge/v1/image/describe'; $remote = wp_remote_post( $endpoint, [ 'timeout' => $timeout, 'headers' => $headers, 'body' => wp_json_encode( $payload ), ] ); if ( is_wp_error( $remote ) ) { return new WP_REST_Response( [ 'ok' => false, 'error' => 'upstream_error', 'detail' => $remote->get_error_message() ], 502 ); } $code = (int) wp_remote_retrieve_response_code( $remote ); $body = (string) wp_remote_retrieve_body( $remote ); $data = json_decode( $body, true ); if ( ! is_array( $data ) ) { $excerpt = trim( wp_strip_all_tags( (string) $body ) ); if ( strlen( $excerpt ) > 800 ) { $excerpt = substr( $excerpt, 0, 800 ) . '...'; } if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[A11yBridge] AltText upstream returned non-JSON. http=' . $code . ' url=' . ( isset( $endpoint ) ? $endpoint : 'n/a' ) . ' body=' . $excerpt ); } return new WP_REST_Response( [ 'ok' => false, 'error' => 'invalid_upstream_response', 'upstream_http_code' => $code, 'upstream_body_excerpt' => $excerpt, ], 502 ); } // Be tolerant: upstream might return {description:"..."} or {ok:true, description:"..."} etc. $desc = ''; if ( isset( $data['description'] ) ) { $desc = (string) $data['description']; } elseif ( isset( $data['alt_text'] ) ) { $desc = (string) $data['alt_text']; } elseif ( isset( $data['data']['description'] ) ) { $desc = (string) $data['data']['description']; } $desc = trim( wp_strip_all_tags( $desc ) ); $desc = a11ybridge_ai_alt_text_truncate( $desc, 500 ); if ( $code >= 400 || $desc === '' ) { $err = isset( $data['error'] ) ? (string) $data['error'] : ''; if ( $err === '' && isset( $data['code'] ) ) { $err = (string) $data['code']; } if ( $err === '' ) { $err = 'upstream_failed'; } $msg = isset( $data['message'] ) ? (string) $data['message'] : ''; // Heuristic: some misconfigurations echo the full request instead of returning a description. if ( $desc === '' && $code < 400 && isset( $data['status'] ) && (string) $data['status'] === 'received' ) { $err = 'misconfigured_image_endpoint'; if ( $msg === '' ) { $msg = 'Upstream returned an echo payload (status=received) instead of a description. Check that FastAPI gateway /image/ converts base64 to multipart and that the backend points to /image/ (not /image/describe).'; } } $safe = function_exists( 'a11ybridge_ai_alt_text_safe_upstream_detail' ) ? a11ybridge_ai_alt_text_safe_upstream_detail( $data ) : null; if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[A11yBridge] AltText upstream failed. http=' . $code . ' url=' . ( isset( $endpoint ) ? $endpoint : 'n/a' ) . ' err=' . $err . ' msg=' . $msg ); } return new WP_REST_Response( [ 'ok' => false, 'error' => $err, 'message' => $msg, 'upstream_http_code' => $code, 'upstream_detail' => $safe, ], $code >= 400 ? $code : 502 ); } update_post_meta( $attachment_id, '_wp_attachment_image_alt', $desc ); return new WP_REST_Response( [ 'ok' => true, 'attachment_id'=> $attachment_id, 'altText' => $desc, 'skipped' => false, ], 200 ); } add_action('rest_api_init', function () { register_rest_route('a11ybridge/v1', '/ai/token', [ 'methods' => WP_REST_Server::READABLE, // GET 'permission_callback' => '__return_true', 'callback' => 'a11ybridge_rest_ai_token', ]); register_rest_route('a11ybridge/v1', '/ai/simplify', [ 'methods' => WP_REST_Server::CREATABLE, // POST 'permission_callback' => 'a11ybridge_rest_ai_simplify_permission', 'callback' => function (WP_REST_Request $req) { // JSON body $json = $req->get_json_params(); if (!is_array($json)) $json = []; $text = trim((string)($json['text'] ?? '')); if ($text === '') { return new WP_REST_Response(['ok'=>false,'error'=>'missing_text'], 400); } // --- License hash server-side (NICHT im Frontend) --- $pro = get_option('a11ybridge_pro_license_settings', []); if (!is_array($pro)) $pro = []; $plan = ''; if (!empty($pro['plan'])) { $plan = strtolower(trim((string)$pro['plan'])); } $license_hash = ''; if (!empty($pro['license_hash'])) { $license_hash = (string)$pro['license_hash']; } if (!$license_hash) { $license_hash = (string)get_option('a11yb_license_key_hash', ''); } // Harden paid/free detection: ignore polluted free settings with fake hashes. $is_paid = (!empty($license_hash) && $plan !== 'free'); $install_id = ''; if (!$is_paid) { // Free: stable Installation-ID (no fake license hash) $install_id = a11ybridge_get_install_id(); } // Optional: Minimales Rate-Limit pro IP (MVP-Schutz) $ip_raw = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $ip = rest_is_ip_address($ip_raw) ? $ip_raw : '0.0.0.0'; $rl_key = 'a11yb_rl_' . hash_hmac('sha256', (string)$ip, wp_salt('auth')); $count = (int) get_transient($rl_key); if ($count > 60) { // z.B. 60/h return new WP_REST_Response(['ok'=>false,'error'=>'rate_limited'], 429); } set_transient($rl_key, $count + 1, HOUR_IN_SECONDS); // Payload an Upstream (a11ybridge.de) weiterreichen $payload = [ 'text' => $text, 'promptKey' => (string)($json['promptKey'] ?? ''), 'level' => (string)($json['level'] ?? 'basic'), 'targetLanguage' => (string)($json['targetLanguage'] ?? 'original'), // 'meta' => is_array($json['meta'] ?? null) ? $json['meta'] : [], 'meta' => [], // minimized (do not forward client metadata) ]; if (!$is_paid && $install_id) { $payload['meta']['installation_id'] = $install_id; } // Normalize legacy values (frontend/admin might still send aliases) $payload['level'] = a11ybridge_catalog_canonical('textSimplification.level', (string)$payload['level'], 'basic'); $payload['targetLanguage'] = a11ybridge_catalog_canonical('textSimplification.targetLanguage', (string)$payload['targetLanguage'], 'original'); $timeout = (int) apply_filters('a11ybridge_ai_simplify_timeout', 120); $headers = [ 'Content-Type' => 'application/json; charset=utf-8', 'X-AB-Origin' => home_url('/'), ]; if ($is_paid) { $headers['X-A11Y-License-Hash'] = $license_hash; } else { $headers['X-AB-Installation-Id'] = $install_id; } $endpoint = function_exists( 'a11ybridge_backend_rest_url' ) ? a11ybridge_backend_rest_url( '/chat/simplify' ) : 'https://a11ybridge.de/wp-json/a11ybridge/v1/chat/simplify'; $remote = wp_remote_post( $endpoint, [ 'timeout' => 120, 'headers' => $headers, 'body' => wp_json_encode($payload), ]); if (is_wp_error($remote)) { return new WP_REST_Response(['ok'=>false,'error'=>'upstream_error','detail'=>$remote->get_error_message()], 502); } $code = (int) wp_remote_retrieve_response_code($remote); $body = (string) wp_remote_retrieve_body($remote); $decoded = json_decode($body, true); if (is_array($decoded)) { return new WP_REST_Response($decoded, $code ?: 200); } return new WP_REST_Response(['ok'=>false,'error'=>'bad_upstream_response','raw'=>$body], 502); }, ]); // Admin: list image attachments for the Alt-Text browser register_rest_route('a11ybridge/v1', '/admin/images', [ 'methods' => WP_REST_Server::READABLE, // GET 'permission_callback' => 'a11ybridge_rest_admin_images_permission', 'callback' => 'a11ybridge_rest_admin_images', ]); // Admin: update attachment alt-text manually register_rest_route('a11ybridge/v1', '/admin/alt-text', [ 'methods' => WP_REST_Server::CREATABLE, // POST 'permission_callback' => 'a11ybridge_rest_admin_alt_text_permission', 'callback' => 'a11ybridge_rest_admin_alt_text_update', ]); // Admin: generate/update attachment alt-text via cloud AI register_rest_route('a11ybridge/v1', '/ai/alt-text', [ 'methods' => WP_REST_Server::CREATABLE, // POST 'permission_callback' => 'a11ybridge_rest_ai_alt_text_permission', 'callback' => 'a11ybridge_rest_ai_alt_text', ]); }); /** * Frontend Toolbar Markup (WP.org compliant: markup only; assets are enqueued via wp_enqueue_scripts). * toolbar.js expects #a11ybridge-icon and #a11ybridge-settings-list to exist. */ function a11ybridge_render_frontend_toolbar_markup() { static $rendered = false; if ($rendered) { return; } $rendered = true; if (is_admin()) { return; } if (wp_doing_ajax()) { return; } if (!a11ybridge_should_show_toolbar()) { return; } // Load settings definitions (titles/icons/scripts) $settings_fields = []; $settings_file = A11YBRIDGE_DIR . 'includes/settings-fields.php'; if (file_exists($settings_file)) { $settings_fields = require $settings_file; } // Admin-enabled basic features (checkboxes in Dashboard → Basics) $general_settings = get_option('a11ybridge_plugin_general_settings', []); if (!is_array($general_settings)) { $general_settings = []; } // Settings list panel (content is static markup; interactions handled by toolbar.js) echo ''; // Floating button (icon rendered via CSS ::before; keep markup empty to avoid double icons) echo ''; } add_action('wp_body_open', 'a11ybridge_render_frontend_toolbar_markup', 20); add_action('wp_footer', 'a11ybridge_render_frontend_toolbar_markup', 20); // Frontend toolbar is injected by frontend/assets/js/toolbar.js via a11ybridge_enqueue_frontend_assets(). // Legacy wp_footer toolbar hook removed (WP.org compliance + avoids missing legacy functions). /** * Liefert die aktivierten Voice-Command-Keys für den aktuellen Nutzer, * unter Berücksichtigung von Service-Status und Defaults. * * @return string[] Liste von Command-Keys (z. B. ['scroll_down','go_back', ...]) */ // (WP.org build) Voice command helpers removed in MVP. // Nonce für Frontend bereitstellen function a11ybridge_enqueue_scripts() { if (!a11ybridge_should_show_toolbar()) { return; } wp_enqueue_script('a11ybridge-settings', A11YBRIDGE_URL . 'frontend/assets/js/settings-handler.js', [], A11YBRIDGE_ASSET_VERSION, true); wp_localize_script('a11ybridge-settings-handler', 'a11ybridgePlugin', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('a11ybridge_settings_nonce'), 'isUserLoggedIn' => is_user_logged_in(), 'cloudEnabled' => (int) a11ybridge_cloud_enabled(), 'adminTextSimplificationEnabled' => (int) a11ybridge_text_simplification_admin_enabled(), 'adminTextSimplificationShowButton' => (int) a11ybridge_text_simplification_show_toolbar_button(), 'simplifyProxyUrl' => esc_url_raw( rest_url('a11ybridge/v1/ai/simplify') ), 'aiTokenUrl' => esc_url_raw( rest_url('a11ybridge/v1/ai/token') ), ]); wp_enqueue_script('a11ybridge-voice-recognition'); /* */ } // **REST-API-Routen registrieren** // === REST API routes (ersetzen): a11ybridge/v1 === add_action('rest_api_init', function () { // 1) Öffentliche READ-Route (GET) -> jeder darf lesen register_rest_route('a11ybridge/v1', '/settings', [ 'methods' => WP_REST_Server::READABLE, 'permission_callback' => '__return_true', 'callback' => 'a11ybridge_rest_get_settings', ]); // 2) Admin-only WRITE-Route (POST/PATCH) -> nur Admins register_rest_route('a11ybridge/v1', '/settings', [ 'methods' => WP_REST_Server::EDITABLE, 'permission_callback' => function () { return current_user_can('manage_options'); }, 'callback' => 'a11ybridge_rest_update_settings', 'args' => [ 'settings' => [ 'required' => true, 'type' => 'array', ], ], ]); }); function a11ybridge_rest_get_settings( WP_REST_Request $req ) { // TODO: aus DB/Optionen lesen – hier Dummy: return new WP_REST_Response([ ['name'=>'Schriftgröße','description'=>'Ändern der Schriftgröße','enabled'=>true], ['name'=>'Farbschema','description'=>'Farbschemata wechseln','enabled'=>true], ['name'=>'Navigation','description'=>'Navigationseinstellungen','enabled'=>false], ], 200); } function a11ybridge_rest_update_settings( WP_REST_Request $req ) { $settings = $req->get_param('settings'); // TODO: validieren & speichern return new WP_REST_Response(['ok'=>true,'saved'=>$settings], 200); } // **Plugin-Aktivierungs- und Deaktivierungs-Hooks** /** * Seed defaults on first activation. * * Goal: New users should see the core features enabled immediately, * while anything that requires explicit consent (cloud) stays OFF. * * We only fill missing options. We never overwrite existing admin choices. */ function a11ybridge_seed_default_settings_on_activation(): void { // ---- Basics / local features ---- $general = get_option( 'a11ybridge_plugin_general_settings', null ); $general_is_empty = ( $general === null || $general === false || ! is_array( $general ) || $general === [] ); if ( $general_is_empty ) { $defs = []; $settings_file = A11YBRIDGE_DIR . 'includes/settings-fields.php'; if ( file_exists( $settings_file ) ) { $fields = require $settings_file; if ( is_array( $fields ) ) { foreach ( $fields as $key => $data ) { if ( ! is_string( $key ) || $key === '' ) { continue; } if ( ! is_array( $data ) ) { continue; } $available = ! empty( $data['available'] ); $client_visible = array_key_exists( 'client_visible', $data ) ? (bool) $data['client_visible'] : true; if ( ! $available || ! $client_visible ) { continue; } $defs[ $key ] = ! empty( $data['enabled'] ) ? 1 : 0; } } } // Toolbar should be visible by default. $defs['show_toolbar'] = 1; // Credit link must remain optional and OFF by default (WP.org compliance). $defs['show_credit_link'] = 0; if ( ! empty( $defs ) ) { update_option( 'a11ybridge_plugin_general_settings', $defs, false ); } } // ---- AI features toggles (UI visible) ---- $ai = get_option( 'a11ybridge_ai_settings', [] ); if ( ! is_array( $ai ) ) { $ai = []; } $ai_defaults = [ // Visible by default, but still blocked until Cloud opt-in is enabled. 'enable_ai_image_description' => 1, ]; $ai_changed = false; foreach ( $ai_defaults as $k => $v ) { if ( ! array_key_exists( $k, $ai ) ) { $ai[ $k ] = $v; $ai_changed = true; } } if ( $ai_changed ) { update_option( 'a11ybridge_ai_settings', $ai, false ); } // ---- Text simplification ---- $simpl = get_option( 'a11ybridge_simplification_settings', [] ); if ( ! is_array( $simpl ) ) { $simpl = []; } $simpl_defaults = [ // Enable the feature UI by default (but keep Cloud opt-in OFF). 'enable_text_simplification' => 1, // Show the "simplify selected text" action by default. 'show_toolbar_button' => 1, ]; $simpl_changed = false; foreach ( $simpl_defaults as $k => $v ) { if ( ! array_key_exists( $k, $simpl ) ) { $simpl[ $k ] = $v; $simpl_changed = true; } } if ( $simpl_changed ) { update_option( 'a11ybridge_simplification_settings', $simpl, false ); } } /** * Text simplification: should we show the "Simplify selected text" button? * Default: ON (if not explicitly saved). */ function a11ybridge_text_simplification_show_toolbar_button(): bool { $simpl = get_option( 'a11ybridge_simplification_settings', [] ); if ( ! is_array( $simpl ) ) { $simpl = []; } // Default true if never saved. if ( ! array_key_exists( 'show_toolbar_button', $simpl ) ) { return true; } return ! empty( $simpl['show_toolbar_button'] ); } function a11ybridge_plugin_activate() { // Aktionen beim Aktivieren des Plugins a11ybridge_debug_log('[A11yBridge] activated'); // Ensure stable installation id exists. if ( function_exists( 'a11ybridge_get_install_id' ) ) { a11ybridge_get_install_id(); } // Seed default feature toggles for first-time installs. if ( function_exists( 'a11ybridge_seed_default_settings_on_activation' ) ) { a11ybridge_seed_default_settings_on_activation(); } // Schedule/unschedule cron jobs depending on current settings. if ( function_exists( 'a11ybridge_alttext_maybe_schedule_daily_scan' ) ) { a11ybridge_alttext_maybe_schedule_daily_scan(); } } register_activation_hook(__FILE__, 'a11ybridge_plugin_activate'); // ----------------------------------------------------------------------------- // AI Alt-Text Automation (upload/update + daily scan) — admin-opt-in only // ----------------------------------------------------------------------------- if ( ! function_exists( 'a11ybridge_alttext_ai_settings' ) ) { function a11ybridge_alttext_ai_settings(): array { $ai = get_option( 'a11ybridge_ai_settings', [] ); return is_array( $ai ) ? $ai : []; } } if ( ! function_exists( 'a11ybridge_alttext_feature_enabled' ) ) { function a11ybridge_alttext_feature_enabled(): bool { // Feature must be enabled AND external backend must be opted in. if ( function_exists( 'a11ybridge_ai_image_description_allowed' ) ) { return a11ybridge_ai_image_description_allowed(); } return false; } } if ( ! function_exists( 'a11ybridge_alttext_auto_on_upload_enabled' ) ) { function a11ybridge_alttext_auto_on_upload_enabled(): bool { $ai = a11ybridge_alttext_ai_settings(); return ! empty( $ai['auto_describe_on_upload'] ); } } if ( ! function_exists( 'a11ybridge_alttext_daily_scan_enabled' ) ) { function a11ybridge_alttext_daily_scan_enabled(): bool { $ai = a11ybridge_alttext_ai_settings(); return ! empty( $ai['daily_cron_check'] ); } } if ( ! function_exists( 'a11ybridge_alttext_maybe_schedule_daily_scan' ) ) { function a11ybridge_alttext_maybe_schedule_daily_scan(): void { $hook = 'a11ybridge_alttext_daily_scan'; // Always clear duplicates first (WP can leave stale schedules after option changes). $next = wp_next_scheduled( $hook ); if ( $next ) { // Only keep if enabled; otherwise clear. if ( ! a11ybridge_alttext_daily_scan_enabled() || ! a11ybridge_alttext_feature_enabled() ) { wp_clear_scheduled_hook( $hook ); return; } // Already scheduled and enabled return; } if ( a11ybridge_alttext_daily_scan_enabled() && a11ybridge_alttext_feature_enabled() ) { // Run once per day. Use a small random offset to avoid stampeding if many sites use this. $start = time() + (int) wp_rand( 60, 15 * 60 ); wp_schedule_event( $start, 'daily', $hook ); } } } // Reschedule when AI settings change add_action( 'update_option_a11ybridge_ai_settings', function () { if ( function_exists( 'a11ybridge_alttext_maybe_schedule_daily_scan' ) ) { a11ybridge_alttext_maybe_schedule_daily_scan(); } }, 10, 0 ); if ( ! function_exists( 'a11ybridge_ai_alttext_generate_internal' ) ) { /** * Generate alt-text for one attachment (server-side, used by cron/automation). * * Returns array on success: ['ok'=>true,'altText'=>string,'skipped'=>bool] * Returns WP_Error on failure with ['status'=>int] in error_data. */ function a11ybridge_ai_alttext_generate_internal( int $attachment_id, bool $force = false, string $context = 'auto' ) { if ( ! a11ybridge_alttext_feature_enabled() ) { return new WP_Error( 'cloud_disabled', 'Backend services or AI image description feature are disabled.', [ 'status' => 403 ] ); } $post = get_post( $attachment_id ); if ( ! $post || $post->post_type !== 'attachment' ) { return new WP_Error( 'invalid_attachment', 'Invalid attachment.', [ 'status' => 404 ] ); } $mime = (string) get_post_mime_type( $attachment_id ); if ( strpos( $mime, 'image/' ) !== 0 ) { return new WP_Error( 'not_an_image', 'Attachment is not an image.', [ 'status' => 400 ] ); } // If alt-text already exists and caller didn't request regeneration, return early. $existing_alt = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); if ( ! $force && trim( $existing_alt ) !== '' ) { return [ 'ok' => true, 'attachment_id' => $attachment_id, 'altText' => $existing_alt, 'skipped' => true, ]; } // Ensure filesize meta exists for sorting/filtering at scale. if ( function_exists( 'a11ybridge_alttext_ensure_filesize_meta' ) ) { a11ybridge_alttext_ensure_filesize_meta( $attachment_id ); } try { $image_base64 = a11ybridge_ai_alt_text_attachment_to_base64( $attachment_id ); } catch ( Throwable $e ) { return new WP_Error( 'internal_error', 'Internal error during image load.', [ 'status' => 500 ] ); } if ( is_wp_error( $image_base64 ) ) { $data = $image_base64->get_error_data(); $status = ( is_array( $data ) && isset( $data['status'] ) ) ? (int) $data['status'] : 500; return new WP_Error( $image_base64->get_error_code(), $image_base64->get_error_message(), [ 'status' => $status ] ); } $ai = a11ybridge_alttext_ai_settings(); $description_level = isset( $ai['description_level'] ) ? (string) $ai['description_level'] : 'basic'; $target_language = isset( $ai['target_language'] ) ? (string) $ai['target_language'] : 'original'; // --- License hash server-side --- $pro = get_option( 'a11ybridge_pro_license_settings', [] ); if ( ! is_array( $pro ) ) { $pro = []; } $plan = ''; if ( ! empty( $pro['plan'] ) ) { $plan = strtolower( trim( (string) $pro['plan'] ) ); } $license_hash = ''; if ( ! empty( $pro['license_hash'] ) ) { $license_hash = (string) $pro['license_hash']; } if ( ! $license_hash ) { $license_hash = (string) get_option( 'a11yb_license_key_hash', '' ); } $is_paid = ( ! empty( $license_hash ) && $plan !== 'free' ); $install_id = $is_paid ? '' : a11ybridge_get_install_id(); // Lightweight rate limit to protect your backend in automation modes (per site). $rl_key = 'a11yb_rl_alt_auto_' . hash_hmac( 'sha256', home_url('/') . '|' . $context, wp_salt( 'auth' ) ); $count = (int) get_transient( $rl_key ); if ( $count > 800 ) { // per hour return new WP_Error( 'rate_limited', 'Rate limited.', [ 'status' => 429 ] ); } set_transient( $rl_key, $count + 1, HOUR_IN_SECONDS ); $payload = [ 'image_base64' => (string) $image_base64, 'description_level' => $description_level, 'target_language' => $target_language, 'meta' => [ 'context' => $context, ], ]; if ( ! $is_paid && $install_id ) { $payload['meta']['installation_id'] = $install_id; } $headers = [ 'Content-Type' => 'application/json; charset=utf-8', 'X-AB-Origin' => home_url( '/' ), 'X-AB-Client' => 'a11ybridge-plugin', 'X-AB-Client-Version' => (string) A11YBRIDGE_VERSION, ]; if ( $license_hash ) { $headers['X-AB-License'] = $license_hash; } $timeout = (int) apply_filters( 'a11ybridge_ai_alttext_timeout', 180 ); $endpoint = function_exists( 'a11ybridge_backend_rest_url' ) ? a11ybridge_backend_rest_url( '/image/describe' ) : home_url( '/wp-json/a11ybridge/v1/image/describe' ); $remote = wp_remote_post( $endpoint, [ 'timeout' => $timeout, 'headers' => $headers, 'body' => wp_json_encode( $payload ), ] ); if ( is_wp_error( $remote ) ) { return new WP_Error( 'upstream_error', $remote->get_error_message(), [ 'status' => 502 ] ); } $code = (int) wp_remote_retrieve_response_code( $remote ); $body = (string) wp_remote_retrieve_body( $remote ); $data = json_decode( $body, true ); if ( ! is_array( $data ) ) { $excerpt = trim( wp_strip_all_tags( (string) $body ) ); if ( strlen( $excerpt ) > 800 ) { $excerpt = substr( $excerpt, 0, 800 ) . '...'; } return new WP_Error( 'invalid_upstream_response', 'Upstream returned non-JSON.', [ 'status' => 502, 'upstream_http_code' => $code, 'upstream_body_excerpt' => $excerpt, ] ); } // Be tolerant: upstream might return {description:"..."} or {ok:true, description:"..."} etc. $desc = ''; if ( isset( $data['description'] ) ) { $desc = (string) $data['description']; } elseif ( isset( $data['alt_text'] ) ) { $desc = (string) $data['alt_text']; } elseif ( isset( $data['data']['description'] ) ) { $desc = (string) $data['data']['description']; } $desc = trim( wp_strip_all_tags( $desc ) ); if ( function_exists( 'a11ybridge_ai_alt_text_truncate' ) ) { $desc = a11ybridge_ai_alt_text_truncate( $desc, 500 ); } if ( $code >= 400 || $desc === '' ) { $err = isset( $data['error'] ) ? (string) $data['error'] : ''; if ( $err === '' && isset( $data['code'] ) ) { $err = (string) $data['code']; } if ( $err === '' ) { $err = 'upstream_failed'; } $msg = isset( $data['message'] ) ? (string) $data['message'] : ''; $safe = function_exists( 'a11ybridge_ai_alt_text_safe_upstream_detail' ) ? a11ybridge_ai_alt_text_safe_upstream_detail( $data ) : null; return new WP_Error( $err, $msg !== '' ? $msg : 'Upstream error', [ 'status' => $code >= 400 ? $code : 502, 'upstream_http_code' => $code, 'upstream_detail' => $safe, ] ); } update_post_meta( $attachment_id, '_wp_attachment_image_alt', $desc ); return [ 'ok' => true, 'attachment_id' => $attachment_id, 'altText' => $desc, 'skipped' => false, ]; } } /** * Queue async alt-text generation for one attachment. */ if ( ! function_exists( 'a11ybridge_alttext_queue_generate' ) ) { function a11ybridge_alttext_queue_generate( int $attachment_id, string $reason = 'auto' ): void { if ( ! a11ybridge_alttext_feature_enabled() ) { return; } if ( ! a11ybridge_alttext_auto_on_upload_enabled() ) { return; } // Avoid duplicate schedules $hook = 'a11ybridge_alttext_generate_one'; $args = [ $attachment_id, $reason ]; if ( wp_next_scheduled( $hook, $args ) ) { return; } wp_schedule_single_event( time() + 10, $hook, $args ); } } // Trigger on upload and on attachment updates (file replace, etc.) add_action( 'add_attachment', function ( $post_id ) { $post_id = (int) $post_id; $mime = (string) get_post_mime_type( $post_id ); if ( strpos( $mime, 'image/' ) !== 0 ) { return; } // Only generate if missing alt-text (do not overwrite manual alt). $existing_alt = (string) get_post_meta( $post_id, '_wp_attachment_image_alt', true ); if ( trim( $existing_alt ) !== '' ) { // Still ensure filesize meta for sorting/filtering if ( function_exists( 'a11ybridge_alttext_ensure_filesize_meta' ) ) { a11ybridge_alttext_ensure_filesize_meta( $post_id ); } return; } a11ybridge_alttext_queue_generate( $post_id, 'upload' ); }, 10, 1 ); add_action( 'attachment_updated', function ( $post_id, $after, $before ) { $post_id = (int) $post_id; $mime = (string) get_post_mime_type( $post_id ); if ( strpos( $mime, 'image/' ) !== 0 ) { return; } // Ensure filesize meta for sorting/filtering if ( function_exists( 'a11ybridge_alttext_ensure_filesize_meta' ) ) { a11ybridge_alttext_ensure_filesize_meta( $post_id ); } // Only generate if missing alt-text (safe default). $existing_alt = (string) get_post_meta( $post_id, '_wp_attachment_image_alt', true ); if ( trim( $existing_alt ) !== '' ) { return; } a11ybridge_alttext_queue_generate( $post_id, 'update' ); }, 10, 3 ); // Async single generation handler add_action( 'a11ybridge_alttext_generate_one', function ( $attachment_id, $reason = 'auto' ) { $attachment_id = (int) $attachment_id; if ( $attachment_id <= 0 ) { return; } $res = a11ybridge_ai_alttext_generate_internal( $attachment_id, false, (string) $reason ); // No noisy logging in production. In debug mode, log generic. if ( is_wp_error( $res ) && defined('WP_DEBUG') && WP_DEBUG ) { error_log('[A11yBridge] AltText auto generate failed. id=' . $attachment_id . ' reason=' . $reason . ' err=' . $res->get_error_code()); } }, 10, 2 ); // Daily scan handler (processes a small batch; remembers progress by last ID) add_action( 'a11ybridge_alttext_daily_scan', function () { if ( ! a11ybridge_alttext_feature_enabled() ) { return; } if ( ! a11ybridge_alttext_daily_scan_enabled() ) { return; } global $wpdb; $posts = $wpdb->posts; $pm = $wpdb->postmeta; $alt_key = '_wp_attachment_image_alt'; $last_id = (int) get_option( 'a11yb_alttext_daily_last_id', 0 ); $batch_size = (int) apply_filters( 'a11ybridge_alttext_daily_batch_size', 50 ); $batch_size = max( 1, min( 200, $batch_size ) ); $run_started = time(); $max_runtime = (int) apply_filters( 'a11ybridge_alttext_daily_max_runtime', 80 ); // seconds $max_runtime = max( 10, min( 240, $max_runtime ) ); $fetch_ids = function ( int $after_id ) use ( $wpdb, $posts, $pm, $alt_key, $batch_size ): array { $sql = $wpdb->prepare( "SELECT p.ID FROM {$posts} p LEFT JOIN {$pm} alt ON (alt.post_id = p.ID AND alt.meta_key = %s) WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND p.post_mime_type LIKE 'image/%%' AND p.ID > %d AND (alt.meta_value IS NULL OR alt.meta_value = '') ORDER BY p.ID ASC LIMIT %d", $alt_key, $after_id, $batch_size ); $ids = $wpdb->get_col( $sql ); if ( ! is_array( $ids ) ) { return []; } return array_map( 'intval', $ids ); }; // First try from last_id, then wrap once $ids = $fetch_ids( $last_id ); if ( ! $ids && $last_id > 0 ) { $last_id = 0; $ids = $fetch_ids( 0 ); } if ( ! $ids ) { update_option( 'a11yb_alttext_daily_last_id', 0, false ); return; } $new_last = $last_id; foreach ( $ids as $id ) { if ( time() - $run_started > $max_runtime ) { break; } $res = a11ybridge_ai_alttext_generate_internal( (int) $id, false, 'daily_scan' ); $new_last = max( $new_last, (int) $id ); if ( is_wp_error( $res ) ) { // If rate limited or backend unavailable, stop early to avoid hammering. $status = (int) ( $res->get_error_data()['status'] ?? 0 ); if ( in_array( $status, [ 429, 502, 503, 504 ], true ) ) { break; } } } update_option( 'a11yb_alttext_daily_last_id', (int) $new_last, false ); }, 10, 0 ); function a11ybridge_plugin_deactivate() { // Clear cron jobs created by this plugin. wp_clear_scheduled_hook( 'a11ybridge_alttext_daily_scan' ); // Clear queued single events (best effort) // Note: WP core has no wildcard clear for args; this clears all without args. wp_clear_scheduled_hook( 'a11ybridge_alttext_generate_one' ); a11ybridge_debug_log('[A11yBridge] deactivated'); } register_deactivation_hook(__FILE__, 'a11ybridge_plugin_deactivate'); add_filter('script_loader_tag', 'a11ybridge_force_module_type', 10, 3); add_filter('a11ybridge_ai_simplify_timeout', function($t) { return 120; }); function a11ybridge_force_module_type($tag, $handle, $src) { // Nur die Handles, die WIRKLICH Module sind $module_handles = [ 'a11ybridge-toolbar', 'a11ybridge-text-to-speech', 'a11ybridge-text-simplification', // 'a11ybridge-logger-init', // nur wenn logger-init.js wirklich import/export nutzt ]; if (!in_array($handle, $module_handles, true)) { return $tag; } // Wenn schon type="..." existiert -> ersetzen if (preg_match('/\btype=(["\']).*?\1/i', $tag)) { return preg_replace('/\btype=(["\']).*?\1/i', 'type="module"', $tag, 1); } // Sonst type="module" einfügen return str_replace('script ', 'script type="module" ', $tag); }