'mkb_glossar_sanitize']); // Rewrite-rule flushes and stored migration markers are privileged writes. // An editor visiting wp-admin must not trigger an upgrade/migration side effect. if (!current_user_can('manage_options')) return; // Refresh persisted rewrite rules once on upgrade. In WPML, admin-side // language discovery must be available when the rules are regenerated. $rewrite_version = '3.14.20'; if (get_option('mkb_glossar_rewrite_version') !== $rewrite_version || get_option('mkb_glossar_flush')) { mkb_glossar_register(); mkb_glossar_language_rewrites(); flush_rewrite_rules(); update_option('mkb_glossar_rewrite_version', $rewrite_version); delete_option('mkb_glossar_flush'); } }); /** Only families already declared in the active Elementor Kit are selectable. * No font library is fetched and no external font stylesheet is enqueued here. */ function mkb_glossar_kit_font_families() { if (!did_action('elementor/loaded') || !class_exists('\Elementor\Plugin')) return []; $kit = mkb_glossar_get_optional_kit(); if (empty($kit['available'])) return []; $families = []; foreach ((array) ($kit['tokens']['typography'] ?? []) as $token) { $family = trim((string) ($token['style']['font_family'] ?? '')); if ($family === '' || strlen($family) > 200 || !preg_match('/^[\p{L}\p{N} ._\-]+$/u', $family)) continue; $families[$family] = $family; } natcasesort($families); return array_values($families); } /** Load the currently selected kit font for the admin live preview through Elementor. * Elementor owns Google Fonts opt-in, font classification, caching and local/CDN policy. * The Glossar never builds Google Fonts URLs and never loads the entire font catalog. */ function mkb_glossar_enqueue_preview_font() { if (!did_action('elementor/loaded') || !class_exists('\Elementor\Fonts')) return; $google_font = '\Elementor\Core\Files\Fonts\Google_Font'; if (!class_exists($google_font) || !method_exists($google_font, 'enqueue')) return; $chosen = trim((string) (mkb_glossar_opts()['tooltip']['font_family'] ?? '')); if ($chosen === '' || $chosen === 'inherit') { $kit = mkb_glossar_get_optional_inheritance(); $chosen = trim((string) ($kit['values']['font_family'] ?? '')); } if ($chosen === '' || !in_array($chosen, mkb_glossar_kit_font_families(), true)) return; if (!\Elementor\Fonts::is_google_fonts_enabled()) return; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Required third-party WPML/Elementor API hook name; changing it would break integration. if (!apply_filters('elementor/frontend/print_google_fonts', true)) return; if (\Elementor\Fonts::get_font_type($chosen) !== \Elementor\Fonts::GOOGLE) return; try { $google_font::enqueue($chosen, $google_font::TYPE_DEFAULT); } catch (\Throwable $error) { // Font loading must not interrupt the Glossar settings screen. } } /** Resolve exactly one active-kit font through Elementor for the admin preview. */ add_action('wp_ajax_mkb_glossar_preview_font', function () { if (!current_user_can('manage_options')) wp_send_json_error(['message' => 'Forbidden'], 403); check_ajax_referer('mkb_glossar_preview_font', 'nonce'); if (!did_action('elementor/loaded') || !class_exists('\Elementor\Fonts')) wp_send_json_error(['message' => 'Elementor unavailable'], 400); $family = isset($_POST['family']) && is_string($_POST['family']) ? trim(sanitize_text_field(wp_unslash($_POST['family']))) : ''; if (!in_array($family, mkb_glossar_kit_font_families(), true)) wp_send_json_error(['message' => 'Font not in active kit'], 400); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Required third-party WPML/Elementor API hook name; changing it would break integration. if (!\Elementor\Fonts::is_google_fonts_enabled() || !apply_filters('elementor/frontend/print_google_fonts', true)) { wp_send_json_success(['url' => '', 'blocked' => true]); } if (\Elementor\Fonts::get_font_type($family) !== \Elementor\Fonts::GOOGLE) { wp_send_json_success(['url' => '', 'blocked' => false]); } $loader = '\Elementor\Core\Files\Fonts\Google_Font'; if (!class_exists($loader) || !method_exists($loader, 'enqueue')) wp_send_json_error(['message' => 'Font loader unavailable'], 400); try { $loader::enqueue($family, $loader::TYPE_DEFAULT); $handle = 'elementor-gf-local-' . sanitize_key($family); if (!wp_style_is($handle, 'enqueued')) $handle = 'elementor-gf-' . sanitize_key($family); $style = wp_styles()->registered[$handle] ?? null; if (!$style || empty($style->src)) wp_send_json_error(['message' => 'Font stylesheet unavailable'], 500); $url = $style->src; if (strpos($url, '//') === 0) $url = set_url_scheme($url); if (!preg_match('~^https?://~i', $url)) $url = site_url($url); // Elementor owns this URL; reject non-HTTP schemes and unexpected hosts. $host = wp_parse_url($url, PHP_URL_HOST); $local_host = wp_parse_url(site_url(), PHP_URL_HOST); if (!in_array($host, [$local_host, 'fonts.googleapis.com'], true)) wp_send_json_error(['message' => 'Unsupported font host'], 400); wp_send_json_success(['url' => esc_url_raw($url), 'blocked' => false]); } catch (\Throwable $error) { wp_send_json_error(['message' => 'Font stylesheet unavailable'], 500); } }); function mkb_glossar_sanitize($raw) { $old = mkb_glossar_opts(); if (!is_array($raw)) return $old; $out = $old; $section = mkb_glossar_request_key($raw,'_section'); // Setup owns only labels and one URL base, never language rewrites or other options. // Validate the complete request before mutating a single persisted setting. if ($section === 'setup_names') { $existing = get_option('mkb_glossar_options', []); if (!is_array($existing)) $existing = []; if (!current_user_can('manage_options')) return $existing; $values = []; foreach (['label', 'singular'] as $key) { if (!isset($raw[$key]) || !is_string($raw[$key])) return $existing; $value = trim(sanitize_text_field($raw[$key])); if ($value === '' || preg_match_all('/./us', $value) > 80) return $existing; $values[$key] = $value; } $base = mkb_glossar_url_base_candidate($raw['slug'] ?? null); if ($base === '' || (mkb_glossar_setup_state() === 'pending' || $base !== (string)($old['slug'] ?? '')) && mkb_glossar_url_base_problem($base) !== null) return $existing; foreach ($values as $key => $value) $existing[$key] = $value; if ($base !== (string)($old['slug'] ?? '')) { $existing['slug'] = $base; update_option('mkb_glossar_flush', 1); } return $existing; } // A settings tab must only save its own fields. Other tabs retain their values. if ($section === 'general') { $out['label'] = isset($raw['label']) && is_scalar($raw['label']) ? sanitize_text_field((string)$raw['label']) : $old['label']; $out['singular'] = isset($raw['singular']) && is_scalar($raw['singular']) ? sanitize_text_field((string)$raw['singular']) : $old['singular']; $requested_base = mkb_glossar_url_base_candidate($raw['slug'] ?? null); if ($requested_base === '') { add_settings_error('mkb_glossar_options', 'invalid_base', __('Enter a valid single-segment URL base. The previous address was kept.', 'mkb-glossar'), 'error'); return $old; // Reject the entire General form instead of a partial save. } elseif ($requested_base !== (string)$old['slug'] && ($problem = mkb_glossar_url_base_problem($requested_base)) !== null) { add_settings_error('mkb_glossar_options', 'url_base_conflict', $problem, 'error'); return $old; // No label or translated-URL changes on collision. } else { $out['slug'] = $requested_base; } // Language-specific bases are opt-in. Existing installations retain their current URLs. if (function_exists('mkb_glossar_url_languages')) { $submitted = isset($raw['language_slugs']) && is_array($raw['language_slugs']) ? $raw['language_slugs'] : []; $default_language = mkb_glossar_default_language(); // Migrate a former explicit default-language override only when it differs // from the old global base. This prevents silently changing existing URLs. $existing = (array)($old['language_slugs'] ?? []); if ($default_language !== '' && isset($existing[$default_language])) { $legacy_default = sanitize_title((string)$existing[$default_language]); if ($legacy_default !== '' && $legacy_default !== sanitize_title((string)$old['slug']) && $out['slug'] === $old['slug']) { $migration_problem = mkb_glossar_url_base_problem($legacy_default); if ($migration_problem === null) { $out['slug'] = $legacy_default; add_settings_error('mkb_glossar_options','default_slug_migrated',__('The former default-language URL base was transferred to the main URL base. Review existing permalinks.','mkb-glossar'),'warning'); } else { add_settings_error('mkb_glossar_options','default_slug_conflict', $migration_problem, 'error'); } } } $active_languages = mkb_glossar_url_languages(); $translated = $existing; // Never lose translated URL bases when provider is unavailable. foreach ($active_languages as $code) { if ($code === $default_language) continue; if (!array_key_exists($code, $submitted) || !is_string($submitted[$code])) continue; $raw_value = trim($submitted[$code]); if ($raw_value === '') { unset($translated[$code]); continue; } $value = mkb_glossar_url_base_candidate($raw_value); if ($value === '') { add_settings_error('mkb_glossar_options','invalid_language_base', __('An invalid translated URL base was ignored. Existing URLs were kept.', 'mkb-glossar'), 'error'); continue; } // These URLs are prefixed/owned by the language plugin. Static root // conflicts are not evidence of a conflict within that language. unset($translated[$code]); if ($value !== $out['slug']) $translated[$code] = $value; } // The default language uses the global base; secondary language bases // may coincide with it (the language prefix keeps URLs distinct). $out['language_slugs'] = $translated; if ($translated !== $existing || $out['slug'] !== $old['slug']) update_option('mkb_glossar_flush', 1); } // Elementor widgets are optional. Existing installations default to all-on. // A complete validated map is required: malformed/nested values preserve the // previous configuration, and the fields are absent when Elementor is inactive. if (mkb_glossar_elementor_available() && array_key_exists('elementor_widgets', $raw)) { $submitted = $raw['elementor_widgets']; $keys = mkb_glossar_elementor_widget_keys(); $valid = is_array($submitted) && count($submitted) === count($keys) && !array_diff(array_keys($submitted), $keys); if ($valid) { foreach ($keys as $key) { if (!isset($submitted[$key]) || !is_scalar($submitted[$key]) || !in_array((string)$submitted[$key], ['0', '1'], true)) { $valid = false; break; } } } if ($valid) { $out['elementor_widgets'] = array_fill_keys($keys, 1); foreach ($keys as $key) $out['elementor_widgets'][$key] = (int)$submitted[$key]; } else { add_settings_error('mkb_glossar_options', 'invalid_widget_settings', __('The Elementor widget selection was not saved because the submitted values were invalid.', 'mkb-glossar'), 'error'); } } // One centrally chosen Elementor Free template; never trust an arbitrary ID. if (mkb_glossar_elementor_available() && array_key_exists('elementor_details_template', $raw)) { $candidate = is_scalar($raw['elementor_details_template']) ? absint($raw['elementor_details_template']) : -1; if ($candidate === 0 || ($candidate > 0 && function_exists('mkb_glossar_shared_template_is_valid') && mkb_glossar_shared_template_is_valid($candidate))) { $out['elementor_details_template'] = $candidate; } else { add_settings_error('mkb_glossar_options', 'invalid_shared_template', __('The selected Elementor template is unavailable. The previous setting was kept.', 'mkb-glossar'), 'error'); } } if ($out['slug'] !== $old['slug']) { add_settings_error('mkb_glossar_options', 'slug_updated', __('URL base changed. Review existing URLs and redirects.', 'mkb-glossar'), 'warning'); update_option('mkb_glossar_flush', 1); } } elseif ($section === 'presentation') { // Save only this tab, including an explicit unchecked schema switch. $out['schema_enabled'] = isset($raw['schema_enabled']) && is_scalar($raw['schema_enabled']) && (string)$raw['schema_enabled'] === '1' ? 1 : 0; $out['overview_page_size'] = isset($raw['overview_page_size']) && is_scalar($raw['overview_page_size']) ? max(1, min(200, absint($raw['overview_page_size']))) : (int)$old['overview_page_size']; $out['details_design'] = mkb_glossar_details_design_settings($raw['details_design'] ?? []); } elseif ($section === 'linking') { $out['mark_external_links'] = isset($raw['mark_external_links']) && is_scalar($raw['mark_external_links']) && (string)$raw['mark_external_links']==='1' ? 1 : 0; // Keep the global icon choice even when external markers are turned off. $choice = $raw['external_icon_style'] ?? 'default'; $out['external_icon_style'] = is_string($choice) && in_array($choice, mkb_glossar_external_icon_styles(), true) ? $choice : 'default'; $candidate = $raw['external_icon_attachment_id'] ?? 0; if (is_scalar($candidate) && ctype_digit((string)$candidate)) { $candidate = absint($candidate); if ($candidate === 0) { $out['external_icon_attachment_id'] = 0; } elseif (mkb_glossar_external_icon_attachment_url($candidate) !== '' && current_user_can('edit_post', $candidate)) { $out['external_icon_attachment_id'] = $candidate; } else { add_settings_error('mkb_glossar_options', 'invalid_external_icon', __('The selected image cannot be used as a glossary icon. The previous image was kept.', 'mkb-glossar'), 'error'); } } $out['auto_link'] = !empty($raw['auto_link']) && is_scalar($raw['auto_link']) ? 1 : 0; $out['max_links'] = isset($raw['max_links']) && is_scalar($raw['max_links']) ? max(1, min(100, absint($raw['max_links']))) : $old['max_links']; $out['repeat_per_term'] = isset($raw['repeat_per_term']) && is_scalar($raw['repeat_per_term']) ? max(1, min(10, absint($raw['repeat_per_term']))) : $old['repeat_per_term']; $allowed = array_keys(get_post_types(['public' => true])); $selected = isset($raw['enabled_types']) && is_array($raw['enabled_types']) ? array_filter($raw['enabled_types'], 'is_scalar') : []; $out['enabled_types'] = array_values(array_intersect($allowed, array_map('sanitize_key', $selected))); } elseif ($section === 'exclusions') { // Per-post exclusions are managed by authenticated AJAX and post editor. $out['excluded_terms'] = []; foreach ((array) ($raw['excluded_terms'] ?? []) as $tax => $ids) { $tax = is_scalar($tax) ? sanitize_key((string)$tax) : ''; if (!$tax || !taxonomy_exists($tax) || !is_scalar($ids)) continue; $out['excluded_terms'][$tax] = array_values(array_unique(array_filter(array_map('absint', preg_split('/[\s,;]+/', (string) $ids))))); } } elseif ($section === 'tooltips') { $allowed_keys = ['background','color','border_color','font_family','font_size','font_weight','line_height','radius','padding','border_width','max_width']; $input = isset($raw['tooltip']) && is_array($raw['tooltip']) ? $raw['tooltip'] : []; $value = (array) ($old['tooltip'] ?? []); foreach ($allowed_keys as $key) { if (isset($input[$key]) && !is_scalar($input[$key])) continue; $entry = isset($input[$key]) ? trim((string) $input[$key]) : ''; if ($entry === '') { unset($value[$key]); if ($key === 'line_height') unset($value['line_height_unit']); continue; } // Empty = inherit. if (in_array($key, ['background','color','border_color'], true)) { $entry = sanitize_hex_color($entry); if (!$entry) { unset($value[$key]); continue; } } elseif ($key === 'font_family') { $entry = sanitize_text_field($entry); if (!in_array($entry, array_merge(['inherit','Arial, sans-serif','Georgia, serif','Verdana, sans-serif','Tahoma, sans-serif','Trebuchet MS, sans-serif','Times New Roman, serif','Courier New, monospace','system-ui, sans-serif'], mkb_glossar_kit_font_families(), [(string) ($old['tooltip']['font_family'] ?? '')]), true)) { unset($value[$key]); continue; } } else { $limits = ['font_size' => [10, 48], 'font_weight' => [100, 900], 'line_height' => [0, 500], 'radius' => [0, 80], 'padding' => [0, 60], 'border_width' => [0, 10], 'max_width' => [180, 700]]; if ($key === 'line_height') { $raw_unit = $input['line_height_unit'] ?? ($old['tooltip']['line_height_unit'] ?? 'percent'); $unit = is_scalar($raw_unit) ? sanitize_key((string)$raw_unit) : 'percent'; if (!in_array($unit, ['factor','px','percent'], true)) $unit = 'factor'; $number = is_numeric($entry) ? (float) $entry : 0; $entry = $unit === 'factor' ? min(3.5, max(0.8, $number)) : ($unit === 'px' ? min(96, max(8, $number)) : min(350, max(80, $number))); $value['line_height_unit'] = $unit; } else { $entry = max($limits[$key][0], min($limits[$key][1], absint($entry))); if ($key === 'font_weight') $entry = (int) (round($entry / 100) * 100); } } $value[$key] = $entry; } $out['tooltip'] = $value; } return $out; } function mkb_glossar_settings_page() { if (!current_user_can('manage_options')) return; $tabs = ['general' => __('General','mkb-glossar'), 'linking' => __('Linking','mkb-glossar'), 'exclusions' => __('Exclusions','mkb-glossar'), 'presentation' => __('Appearance & SEO','mkb-glossar'), 'tooltips' => __('Tooltips','mkb-glossar')]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only GET/navigation parameter; no state is changed and input is validated separately. $tab = mkb_glossar_request_key($_GET, 'tab', 'general'); if (!isset($tabs[$tab])) $tab = 'general'; $o = mkb_glossar_opts(); // Each tab opens at the instruction that explains its main controls. $help_topic = ['general' => 'start-step-1', 'linking' => 'linking-step-2', 'exclusions' => 'linking-step-4', 'presentation' => 'display-step-5', 'tooltips' => 'tooltips-step-4'][$tab]; echo '
' . esc_html(isset($_GET['setup_skipped']) ? __('Setup was skipped. All settings remain available; you can restart the assistant below.', 'mkb-glossar') : __('Setup completed. You can restart the assistant later under General settings.', 'mkb-glossar')) . '
' . esc_html__('Choose your names first, then review the other setup steps. You can reopen the assistant without resetting content or settings.', 'mkb-glossar') . '
'.esc_html(mkb_glossar_elementor_available() ? __('Build the overview with Elementor, Gutenberg or a shortcode.', 'mkb-glossar') : __('Place the overview on a page with Gutenberg or a shortcode.', 'mkb-glossar')).'