PluginProbe
eRecht24 Legal Texts / trunk
eRecht24 Legal Texts vtrunk
4.1.0 4.0.5 4.0.4 4.0.3 trunk 4.0.0 4.0.1 4.0.2
erecht24 / src / Admin / Admin_Page.php

Admin_Page.php in eRecht24 Legal Texts trunk, at src/Admin/Admin_Page.php

1,410 lines 49.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin settings page.
4 *
5 * @package ERecht24LegalText
6 */
7
8 namespace ERecht24LegalText\Admin;
9
10 use ERecht24LegalText\Api\Client;
11 use ERecht24LegalText\Plugin;
12 use ERecht24LegalText\Settings;
13
14 defined( 'ABSPATH' ) || exit;
15
16 /**
17 * Renders and handles the plugin admin page.
18 */
19 final class Admin_Page {
20
21
22
23
24 private const MENU_SLUG = 'erecht24';
25 private const HELP_DOCUMENTATION_FILE = 'src/Admin/docs/documentation.php';
26
27 /**
28 * Settings service.
29 *
30 * @var Settings
31 */
32 private $settings;
33
34 /**
35 * API client.
36 *
37 * @var Client
38 */
39 private $api_client;
40
41 /**
42 * Constructor.
43 *
44 * @param Settings $settings Settings service.
45 * @param Client $api_client API client.
46 */
47 public function __construct( Settings $settings, Client $api_client ) {
48 $this->settings = $settings;
49 $this->api_client = $api_client;
50 }
51
52 /**
53 * Register admin hooks.
54 */
55 public function register_hooks(): void {
56 add_action( 'admin_menu', array( $this, 'add_page' ) );
57 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
58 add_action( 'admin_post_erecht24_legal_text_save', array( $this, 'handle_save' ) );
59 add_action( 'admin_post_erecht24_legal_text_sync', array( $this, 'handle_sync' ) );
60 add_action( 'admin_post_erecht24_legal_text_push_test', array( $this, 'handle_push_test' ) );
61 add_action( 'admin_post_erecht24_legal_text_force_reregister', array( $this, 'handle_force_reregister' ) );
62 add_action( 'admin_notices', array( $this, 'render_notice' ) );
63 add_action( 'admin_notices', array( $this, 'render_push_orphaned_notice' ) );
64 add_filter( 'plugin_action_links_' . ERECHT24_LEGAL_TEXT_BASENAME, array( $this, 'add_action_link' ) );
65 }
66
67 /**
68 * Add settings page.
69 */
70 public function add_page(): void {
71 add_options_page(
72 __( 'eRecht24 Rechtstexte', 'erecht24' ),
73 __( 'eRecht24 Rechtstexte', 'erecht24' ),
74 'manage_options',
75 self::MENU_SLUG,
76 array( $this, 'render_page' )
77 );
78 }
79
80 /**
81 * Enqueue admin assets only on this settings page.
82 *
83 * @param string $hook_suffix Admin hook suffix.
84 */
85 public function enqueue_assets( string $hook_suffix ): void {
86 if ( 'plugins.php' === $hook_suffix ) {
87 add_thickbox();
88 return;
89 }
90
91 if ( 'settings_page_' . self::MENU_SLUG !== $hook_suffix ) {
92 return;
93 }
94
95 static $admin_script_version = null;
96
97 if ( null === $admin_script_version ) {
98 $admin_script_file = ERECHT24_LEGAL_TEXT_PATH . 'assets/js/admin.js';
99 $admin_script_version = is_readable( $admin_script_file )
100 ? (string) filemtime( $admin_script_file )
101 : ERECHT24_LEGAL_TEXT_VERSION;
102 }
103
104 wp_enqueue_style(
105 'dashicons'
106 );
107
108 wp_enqueue_style(
109 'erecht24-legal-texts-admin',
110 ERECHT24_LEGAL_TEXT_URL . 'assets/css/admin.css',
111 array(),
112 ERECHT24_LEGAL_TEXT_VERSION
113 );
114
115 wp_enqueue_script(
116 'erecht24-legal-texts-admin',
117 ERECHT24_LEGAL_TEXT_URL . 'assets/js/admin.js',
118 array( 'wp-i18n' ),
119 $admin_script_version,
120 true
121 );
122
123 wp_set_script_translations( 'erecht24-legal-texts-admin', 'erecht24', ERECHT24_LEGAL_TEXT_PATH . 'languages' );
124 }
125
126 /**
127 * Add settings link in plugins table.
128 *
129 * @param array<int,string> $links Existing links.
130 *
131 * @return array<int,string>
132 */
133 public function add_action_link( array $links ): array {
134 array_unshift(
135 $links,
136 sprintf(
137 '<a href="%1$s">%2$s</a>',
138 esc_url( $this->page_url() ),
139 esc_html__( 'Einstellungen', 'erecht24' )
140 )
141 );
142
143 return $links;
144 }
145
146 /**
147 * Render admin notices.
148 */
149 public function render_notice(): void {
150 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display filter, no state change
151 if ( ! isset( $_GET['page'] ) || self::MENU_SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) {
152 return;
153 }
154
155 $notice_key = $this->notice_key();
156 $notice = get_transient( $notice_key );
157
158 if ( ! is_array( $notice ) || empty( $notice['message'] ) ) {
159 return;
160 }
161
162 delete_transient( $notice_key );
163
164 $type = in_array( $notice['type'] ?? '', array( 'success', 'warning', 'error' ), true ) ? $notice['type'] : 'success';
165 $class = 'error' === $type ? 'notice-error' : ( 'warning' === $type ? 'notice-warning' : 'notice-success' );
166
167 printf(
168 '<div class="notice %1$s erecht24-prominent-notice is-dismissible"><p>%2$s</p></div>',
169 esc_attr( $class ),
170 wp_kses_post( (string) $notice['message'] )
171 );
172 }
173
174 /**
175 * Warn when an API key is stored but no push client is registered.
176 */
177 public function render_push_orphaned_notice(): void {
178 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display filter, no state change
179 if ( ! isset( $_GET['page'] ) || self::MENU_SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) {
180 return;
181 }
182
183 if ( '' === $this->settings->get_api_key() ) {
184 return;
185 }
186
187 $secret_missing = '' === $this->settings->get_client_secret();
188
189 if ( ! $secret_missing && ! $this->settings->get_push_test_failed() ) {
190 return;
191 }
192
193 if ( $secret_missing ) {
194 /* translators: %s: URL to the plugin's Status tab. */
195 $message = __( 'Ein API-Schlüssel ist gespeichert, aber Push-Updates sind nicht registriert. Gehen Sie zum <a href="%s">Status-Tab</a> und klicken Sie auf „Push-Client jetzt neu registrieren".', 'erecht24' );
196 } else {
197 /* translators: %s: URL to the plugin's Status tab. */
198 $message = __( 'Ein Remote-Push-Test ist zuletzt fehlgeschlagen. Gehen Sie zum <a href="%s">Status-Tab</a> und klicken Sie auf „Push-Client jetzt neu registrieren".', 'erecht24' );
199 }
200
201 printf(
202 '<div class="notice notice-warning erecht24-prominent-notice"><p>%s</p></div>',
203 wp_kses_post(
204 sprintf(
205 $message,
206 esc_url( $this->page_url( 'status' ) )
207 )
208 )
209 );
210 }
211
212 /**
213 * Render settings page.
214 */
215 public function render_page(): void {
216 if ( ! current_user_can( 'manage_options' ) ) {
217 wp_die( esc_html__( 'Sie haben keine Berechtigung für diese Seite.', 'erecht24' ) );
218 }
219
220 $tab = $this->get_active_tab();
221
222 echo '<div class="wrap erecht24-legal-texts-admin">';
223 echo '<header class="erecht24-admin-header">';
224 echo '<div><h1>' . esc_html__( 'eRecht24 Rechtstexte', 'erecht24' ) . '</h1>';
225 echo '<p class="description">' . esc_html__( 'API-Schlüssel verwalten, Rechtstexte abrufen und per Shortcode oder Gutenberg-Block ausgeben.', 'erecht24' ) . '</p></div>';
226 echo '<img class="erecht24-admin-logo" src="' . esc_url( ERECHT24_LEGAL_TEXT_URL . 'assets/logo-erecht24-long-72-rgb.png' ) . '" alt="eRecht24">';
227 echo '</header>';
228
229 $this->render_tabs( $tab );
230
231 if ( 'settings' === $tab ) {
232 $this->render_settings_tab();
233 } elseif ( 'google_analytics' === $tab ) {
234 $this->render_google_analytics_tab();
235 } elseif ( 'status' === $tab ) {
236 $this->render_status_tab();
237 } elseif ( 'help' === $tab ) {
238 $this->render_help_tab();
239 } else {
240 $this->render_document_tab( $tab );
241 }
242
243 echo '</div>';
244 }
245
246 /**
247 * Handle settings save.
248 */
249 public function handle_save(): void {
250 if ( ! current_user_can( 'manage_options' ) ) {
251 wp_die( esc_html__( 'Sie haben keine Berechtigung für diese Aktion.', 'erecht24' ) );
252 }
253
254 check_admin_referer( 'erecht24_legal_text_save' );
255
256 $tab = isset( $_POST['tab'] ) ? sanitize_key( wp_unslash( $_POST['tab'] ) ) : 'settings';
257 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized field-by-field in Settings
258 $data = isset( $_POST['erecht24'] ) && is_array( $_POST['erecht24'] ) ? wp_unslash( $_POST['erecht24'] ) : array();
259
260 if ( ! empty( $data['delete_api_key'] ) ) {
261 $delete_result = $this->api_client->delete_client( $this->settings->get_api_key(), $this->settings->get_client_id() );
262 $this->settings->clear_api_connection();
263
264 if ( is_wp_error( $delete_result ) ) {
265 $this->redirect_with_notice(
266 __( 'Der lokale API-Schlüssel wurde entfernt. Der API-Client konnte bei eRecht24 nicht gelöscht werden.', 'erecht24' ),
267 'warning',
268 'settings'
269 );
270 }
271
272 $this->redirect_with_notice( __( 'Der API-Schlüssel wurde entfernt.', 'erecht24' ), 'success', 'settings' );
273 }
274
275 if ( isset( $data['api_key'] ) && '' !== trim( (string) $data['api_key'] ) ) {
276 $new_api_key = Settings::sanitize_api_key( $data['api_key'] );
277 $validation = $this->api_client->validate_key( $new_api_key );
278
279 if ( is_wp_error( $validation ) ) {
280 $this->redirect_with_notice( wp_strip_all_tags( $validation->get_error_message() ), 'error', 'settings' );
281 }
282
283 $registration = $this->api_client->register_client( $new_api_key );
284
285 if ( is_wp_error( $registration ) ) {
286 $this->settings->save_api_connection( $new_api_key );
287 $this->redirect_with_notice(
288 __( 'Der API-Schlüssel wurde gespeichert. Push-Updates konnten nicht registriert werden; manuelle Synchronisierung funktioniert weiterhin.', 'erecht24' ),
289 'warning',
290 'settings'
291 );
292 }
293
294 $this->settings->save_api_connection(
295 $new_api_key,
296 absint( $registration['client_id'] ?? 0 ),
297 (string) ( $registration['secret'] ?? '' )
298 );
299
300 $this->redirect_with_notice( __( 'Der API-Schlüssel wurde gespeichert und der API-Client wurde registriert.', 'erecht24' ), 'success', 'settings' );
301 }
302
303 if ( ! empty( $data['documents'] ) && is_array( $data['documents'] ) ) {
304 if ( ! empty( $data['sync_document'] ) ) {
305 // Stage form data in cache only; save_remote_document() writes everything in one go.
306 $this->settings->stage_documents_from_request( $data['documents'] );
307 $this->sync_document_and_redirect( sanitize_key( wp_unslash( $data['sync_document'] ) ) );
308 }
309
310 $this->settings->save_documents_from_request( $data['documents'] );
311
312 if ( ! empty( $data['copy_remote_to_local_document'] ) ) {
313 $this->copy_remote_document_to_local_and_redirect( sanitize_key( wp_unslash( $data['copy_remote_to_local_document'] ) ) );
314 }
315
316 $this->redirect_with_notice( __( 'Die Rechtstext-Einstellungen wurden gespeichert.', 'erecht24' ), 'success', $tab );
317 }
318
319 if ( ! empty( $data['google_analytics'] ) && is_array( $data['google_analytics'] ) ) {
320 $this->settings->save_google_analytics_from_request( $data['google_analytics'] );
321 $this->redirect_with_notice( __( 'Die Google-Analytics-Einstellungen wurden gespeichert.', 'erecht24' ), 'success', 'google_analytics' );
322 }
323
324 $this->redirect_with_notice( __( 'Es wurden keine Änderungen erkannt.', 'erecht24' ), 'warning', $tab );
325 }
326
327 /**
328 * Handle manual document synchronization.
329 */
330 public function handle_sync(): void {
331 if ( ! current_user_can( 'manage_options' ) ) {
332 wp_die( esc_html__( 'Sie haben keine Berechtigung für diese Aktion.', 'erecht24' ) );
333 }
334
335 check_admin_referer( 'erecht24_legal_text_sync' );
336
337 $document = isset( $_POST['document'] ) ? sanitize_key( wp_unslash( $_POST['document'] ) ) : 'all';
338 $types = 'all' === $document ? Settings::DOCUMENT_TYPES : array( Settings::normalize_document_type( $document ) );
339 $failed = array();
340 $success = array();
341 $labels = Settings::document_labels();
342
343 foreach ( $types as $type ) {
344 if ( '' === $type ) {
345 continue;
346 }
347
348 $response = $this->api_client->fetch_document( $type );
349
350 if ( is_wp_error( $response ) ) {
351 $failed[] = sprintf(
352 '%1$s: %2$s',
353 esc_html( $labels[ $type ] ?? $type ),
354 esc_html( $response->get_error_message() )
355 );
356 continue;
357 }
358
359 $this->settings->save_remote_document( $type, $response );
360 $success[] = esc_html( $labels[ $type ] ?? $type );
361 }
362
363 if ( ! empty( $failed ) ) {
364 $message = __( 'Einige Rechtstexte konnten nicht synchronisiert werden:', 'erecht24' ) . '<br>' . implode( '<br>', $failed );
365 $this->redirect_with_notice( $message, 'error', 'all' === $document ? 'settings' : $document );
366 }
367
368 if ( ! empty( $success ) ) {
369 $message = sprintf(
370 /* translators: %s: List of document labels. */
371 __( 'Synchronisiert: %s', 'erecht24' ),
372 implode( ', ', $success )
373 );
374 $this->redirect_with_notice( $message, 'success', 'all' === $document ? 'settings' : $document );
375 }
376
377 $this->redirect_with_notice( __( 'Es wurde kein gültiger Rechtstext-Typ übergeben.', 'erecht24' ), 'error', 'settings' );
378 }
379
380 /**
381 * Run the remote push reachability test after an explicit admin action.
382 */
383 public function handle_push_test(): void {
384 if ( ! current_user_can( 'manage_options' ) ) {
385 wp_die( esc_html__( 'Sie haben keine Berechtigung für diese Aktion.', 'erecht24' ) );
386 }
387
388 check_admin_referer( 'erecht24_legal_text_push_test' );
389
390 if ( ! $this->settings->can_render_documents() || ! $this->settings->get_client_id() ) {
391 $this->redirect_with_notice(
392 __( 'Push-Test übersprungen: kein gültiger API-Schlüssel oder kein registrierter Client.', 'erecht24' ),
393 'warning',
394 'status'
395 );
396 }
397
398 $push_response = $this->api_client->test_push_ping();
399
400 if ( is_wp_error( $push_response ) ) {
401 $message = $push_response->get_error_message();
402 $client_not_found = false !== strpos( $message, 'kein Client mit der übergebenen client_id gefunden' );
403
404 if ( $client_not_found ) {
405 $this->settings->add_log( 'Remote push test: client_id unknown at eRecht24. Clearing local registration and re-registering.' );
406 $this->settings->save_api_connection( $this->settings->get_api_key(), 0, '' );
407 delete_transient( 'erecht24_push_reregister_attempted' );
408 $succeeded = Plugin::instance()->maybe_reregister_push_client();
409
410 if ( $succeeded ) {
411 $this->redirect_with_notice(
412 __( 'Der registrierte Client war bei eRecht24 nicht mehr bekannt (z. B. durch eine parallel installierte weitere Plugin-Version entfernt). Der Push-Client wurde automatisch neu registriert.', 'erecht24' ),
413 'success',
414 'status'
415 );
416 }
417
418 $this->settings->set_push_test_failed( true );
419 $this->redirect_with_notice(
420 __( 'Der registrierte Client war bei eRecht24 nicht mehr bekannt. Die automatische Neu-Registrierung ist fehlgeschlagen. Bitte über den Button „Push-Client jetzt neu registrieren" erneut versuchen.', 'erecht24' ),
421 'error',
422 'status'
423 );
424 }
425
426 $this->settings->set_push_test_failed( true );
427 $this->redirect_with_notice( wp_strip_all_tags( $push_response->get_error_message() ), 'error', 'status' );
428 }
429
430 $this->settings->set_push_test_failed( false );
431
432 $removed_duplicates = Plugin::instance()->cleanup_duplicate_clients( $this->settings->get_api_key(), $this->settings->get_client_id() );
433
434 if ( $removed_duplicates > 0 ) {
435 $this->redirect_with_notice(
436 __( 'Der eRecht24 Server kann den WordPress-Push-Endpoint erreichen. Zusätzlich gefundene, doppelte Client-Registrierungen für diese Website wurden automatisch entfernt.', 'erecht24' ),
437 'success',
438 'status'
439 );
440 }
441
442 $this->redirect_with_notice(
443 __( 'Der eRecht24 Server kann den WordPress-Push-Endpoint erreichen.', 'erecht24' ),
444 'success',
445 'status'
446 );
447 }
448
449 /**
450 * Bypass the rate limit and immediately retry push client re-registration
451 * after an explicit admin action (e.g. for staging systems where waiting
452 * for the automatic retry window is impractical).
453 */
454 public function handle_force_reregister(): void {
455 if ( ! current_user_can( 'manage_options' ) ) {
456 wp_die( esc_html__( 'Sie haben keine Berechtigung für diese Aktion.', 'erecht24' ) );
457 }
458
459 check_admin_referer( 'erecht24_legal_text_force_reregister' );
460
461 delete_transient( 'erecht24_push_reregister_attempted' );
462 $succeeded = Plugin::instance()->maybe_reregister_push_client( true );
463
464 if ( $succeeded ) {
465 $this->redirect_with_notice( __( 'Push-Client wurde erfolgreich neu registriert.', 'erecht24' ), 'success', 'status' );
466 }
467
468 $this->redirect_with_notice(
469 __( 'Die Neu-Registrierung ist fehlgeschlagen. Details siehe Log im Status-Bereich.', 'erecht24' ),
470 'error',
471 'status'
472 );
473 }
474
475 /**
476 * Render tab navigation.
477 *
478 * @param string $active_tab Active tab.
479 */
480 private function render_tabs( string $active_tab ): void {
481 $tabs = array(
482 'settings' => array(
483 'label' => __( 'API-Schlüssel', 'erecht24' ),
484 'icon' => 'dashicons-admin-network',
485 ),
486 );
487
488 foreach ( Settings::document_labels() as $tab => $label ) {
489 $tabs[ $tab ] = array(
490 'label' => $label,
491 'icon' => 'imprint' === $tab ? 'dashicons-media-text' : ( 'privacy_policy' === $tab ? 'dashicons-shield-alt' : 'dashicons-share' ),
492 );
493 }
494
495 $tabs['google_analytics'] = array(
496 'label' => __( 'Google Analytics', 'erecht24' ),
497 'icon' => 'dashicons-chart-line',
498 );
499 $tabs['status'] = array(
500 'label' => __( 'Status', 'erecht24' ),
501 'icon' => 'dashicons-clipboard',
502 );
503 $tabs['help'] = array(
504 'label' => __( 'Hilfe', 'erecht24' ),
505 'icon' => 'dashicons-editor-help',
506 );
507
508 echo '<nav class="nav-tab-wrapper">';
509
510 foreach ( $tabs as $tab => $tab_data ) {
511 printf(
512 '<a class="nav-tab %1$s" href="%2$s"><span class="dashicons %3$s"></span> %4$s</a>',
513 $active_tab === $tab ? 'nav-tab-active' : '',
514 esc_url( $this->page_url( $tab ) ),
515 esc_attr( $tab_data['icon'] ),
516 esc_html( $tab_data['label'] )
517 );
518 }
519
520 echo '</nav>';
521 }
522
523 /**
524 * Render API settings tab.
525 */
526 private function render_settings_tab(): void {
527 $settings = $this->settings->get_all();
528 $has_api_key = '' !== $this->settings->get_api_key();
529 $status = $this->settings->get_api_key_status();
530 $client_id = absint( $settings['client_id'] );
531 $push_enabled = '' !== (string) $settings['client_secret'];
532
533 echo '<section class="erecht24-panel">';
534 echo '<h2>' . esc_html__( 'API-Schlüssel', 'erecht24' ) . '</h2>';
535 echo '<p>' . esc_html__( 'Externe API-Requests werden nur durch Speichern des API-Schlüssels, manuelle Synchronisierung oder einen autorisierten eRecht24-Push ausgelöst.', 'erecht24' ) . '</p>';
536
537 echo '<form id="erecht24-save-form" method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
538 wp_nonce_field( 'erecht24_legal_text_save' );
539 echo '<input type="hidden" name="action" value="erecht24_legal_text_save">';
540 echo '<input type="hidden" name="tab" value="settings">';
541
542 echo '<table class="form-table" role="presentation"><tbody>';
543 echo '<tr><th scope="row"><label for="erecht24-api-key">' . esc_html__( 'API-Schlüssel', 'erecht24' ) . '</label></th><td>';
544 printf(
545 '<input id="erecht24-api-key" class="regular-text" type="password" name="erecht24[api_key]" value="" autocomplete="off" placeholder="%1$s">',
546 esc_attr( $has_api_key ? __( 'Gespeicherter API-Schlüssel unverändert', 'erecht24' ) : __( 'API-Schlüssel eintragen', 'erecht24' ) )
547 );
548
549 if ( $has_api_key ) {
550 echo '<p class="description">' . esc_html__( 'Gespeichert:', 'erecht24' ) . ' <code>' . esc_html( Settings::mask_secret( $this->settings->get_api_key() ) ) . '</code></p>';
551 echo '<input type="checkbox" id="erecht24-delete-api-key" name="erecht24[delete_api_key]" value="1" class="erecht24-hidden">';
552 echo '<button type="button" class="button erecht24-delete-api-key-btn" id="erecht24-delete-api-key-btn">' . esc_html__( 'API-Schlüssel entfernen', 'erecht24' ) . '</button>';
553 } else {
554 echo '<p class="description">' . wp_kses_post(
555 sprintf(
556 /* translators: %s: eRecht24 project manager URL. */
557 __( 'Geben Sie hier Ihren API-Schlüssel ein, welchen Sie für Ihre Website im <a href="%s" target="_blank" rel="noopener noreferrer">eRecht24 Projekt Manager für Websites</a> erzeugt haben.', 'erecht24' ),
558 esc_url( 'https://www.e-recht24.de/mitglieder/tools/projekt-manager/' )
559 )
560 ) . '</p>';
561 }
562
563 echo '</td></tr>';
564 echo '<tr><th scope="row">' . esc_html__( 'Status', 'erecht24' ) . '</th><td>';
565 echo '<span class="erecht24-status-pill erecht24-status-' . esc_attr( $status ) . '">' . esc_html( $this->get_api_status_label( $status ) ) . '</span>';
566 if ( 'invalid' === $status ) {
567 echo '<p class="description erecht24-danger-text">' . esc_html__( 'Remote-Synchronisierung ist deaktiviert, bis ein gültiger API-Schlüssel gespeichert wurde. Bereits gespeicherte Rechtstexte werden weiterhin ausgegeben.', 'erecht24' ) . '</p>';
568 }
569 echo '</td></tr>';
570 echo '<tr><th scope="row">' . esc_html__( 'Push-Endpoint', 'erecht24' ) . '</th><td>';
571 echo '<code>' . esc_html( rest_url( 'erecht24/v1/push' ) ) . '</code>';
572 echo '<p class="description">' . esc_html( $push_enabled ? __( 'Push-Updates sind registriert.', 'erecht24' ) : __( 'Push-Updates sind nicht registriert. Manuelle Synchronisierung ist davon unabhängig.', 'erecht24' ) ) . '</p>';
573 if ( $client_id ) {
574 echo '<p class="description">' . esc_html__( 'Client-ID:', 'erecht24' ) . ' ' . esc_html( (string) $client_id ) . '</p>';
575 }
576 echo '</td></tr>';
577 echo '</tbody></table>';
578 echo '</form>';
579
580 echo '<div class="erecht24-form-actions">';
581 printf(
582 '<button type="submit" form="erecht24-save-form" class="button button-primary">%s</button>',
583 esc_html__( 'API-Einstellungen speichern', 'erecht24' )
584 );
585
586 if ( $has_api_key ) {
587 echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
588 wp_nonce_field( 'erecht24_legal_text_sync' );
589 echo '<input type="hidden" name="action" value="erecht24_legal_text_sync">';
590 echo '<input type="hidden" name="document" value="all">';
591 printf(
592 '<button type="submit" class="button button-secondary">%s</button>',
593 esc_html__( 'Alle Rechtstexte synchronisieren', 'erecht24' )
594 );
595 echo '</form>';
596 }
597 echo '</div>';
598
599 echo '</section>';
600 }
601
602 /**
603 * Render one document tab.
604 *
605 * @param string $type Document type.
606 */
607 private function render_document_tab( string $type ): void {
608 $type = Settings::normalize_document_type( $type );
609 $labels = Settings::document_labels();
610 $document = $this->settings->get_document( $type );
611 $descriptions = Settings::document_labels_description();
612 $description = $descriptions[ $type ];
613 $can_render = $this->settings->can_render_documents();
614 $selected_source = 'local' === ( $document['source'] ?? '' ) ? 'local' : 'remote';
615 $sync_hidden_class = 'remote' === $selected_source ? '' : ' erecht24-hidden';
616 $copy_hidden_class = 'local' === $selected_source ? '' : ' erecht24-hidden';
617
618 if ( '' === $type || empty( $document ) ) {
619 echo '<p>' . esc_html__( 'Unbekannter Rechtstext.', 'erecht24' ) . '</p>';
620 return;
621 }
622
623 echo '<section class="erecht24-panel">';
624 echo '<h2>' . esc_html( $labels[ $type ] ) . '</h2>';
625 if ( ! empty( $description ) ) {
626 echo '<p class="description">' . esc_html( $description ) . '</p>';
627 }
628
629 $this->render_shortcode_examples( $type );
630
631 if ( ! $can_render ) {
632 echo '<div class="erecht24-admin-warning">' . esc_html__( 'Kein gültiger API-Schlüssel aktiv. Remote-Synchronisierung ist deaktiviert. Bereits synchronisierte und lokale Rechtstexte werden weiterhin ausgegeben.', 'erecht24' ) . '</div>';
633 }
634
635 echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" class="erecht24-document-form">';
636 wp_nonce_field( 'erecht24_legal_text_save' );
637 echo '<input type="hidden" name="action" value="erecht24_legal_text_save">';
638 echo '<input type="hidden" name="tab" value="' . esc_attr( $type ) . '">';
639
640 echo '<table class="form-table" role="presentation"><tbody>';
641 echo '<tr><th scope="row">' . esc_html__( 'Datenquelle', 'erecht24' ) . '</th><td>';
642 $this->render_source_toggle( $type, $selected_source );
643 echo '</td></tr>';
644
645 $this->render_textarea_row( $type, 'remote', 'de', __( 'HTML-Code (DE)', 'erecht24' ), (string) $document['remote']['de'], true );
646 $this->render_textarea_row( $type, 'remote', 'en', __( 'HTML-Code (EN)', 'erecht24' ), (string) $document['remote']['en'], true );
647 $this->render_textarea_row( $type, 'local', 'de', __( 'Lokales HTML (DE)', 'erecht24' ), (string) $document['local']['de'], false );
648 $this->render_textarea_row( $type, 'local', 'en', __( 'Lokales HTML (EN)', 'erecht24' ), (string) $document['local']['en'], false );
649
650 echo '<tr data-erecht24-source-row="remote"><th scope="row">' . esc_html__( 'Letzte Änderung im eRecht24 Projekt Manager', 'erecht24' ) . '</th><td><code>' . esc_html( (string) $document['remote']['modified'] ) . '</code></td></tr>';
651 echo '<tr data-erecht24-source-row="local"><th scope="row">' . esc_html__( 'Letzte lokale Änderung', 'erecht24' ) . '</th><td><code>' . esc_html( (string) $document['local']['modified'] ) . '</code></td></tr>';
652 echo '</tbody></table>';
653
654 echo '<p class="submit">';
655 echo '<button type="submit" class="button button-primary">' . esc_html__( 'Rechtstext-Einstellungen speichern', 'erecht24' ) . '</button> ';
656 printf(
657 '<button type="submit" class="button button-secondary%2$s" name="erecht24[sync_document]" value="%1$s" data-erecht24-source-action="remote" %3$s>%4$s</button>',
658 esc_attr( $type ),
659 esc_attr( $sync_hidden_class ),
660 disabled( ! $can_render, true, false ),
661 esc_html__( 'Diesen Rechtstext synchronisieren und speichern', 'erecht24' )
662 );
663 echo ' ';
664 printf(
665 '<button type="submit" class="button button-secondary erecht24-copy-remote-to-local%2$s" name="erecht24[copy_remote_to_local_document]" value="%1$s" data-erecht24-source-action="local">%3$s</button>',
666 esc_attr( $type ),
667 esc_attr( $copy_hidden_class ),
668 esc_html__( 'Lokale Daten mit den zuletzt synchronisierten Texten überschreiben', 'erecht24' )
669 );
670 echo '</p>';
671 echo '</form>';
672 echo '</section>';
673 }
674
675 /**
676 * Render help tab.
677 */
678 private function render_help_tab(): void {
679 echo '<section class="erecht24-panel erecht24-help-content">';
680
681 $documentation = $this->get_help_documentation_html();
682
683 if ( '' !== $documentation ) {
684 $result = $this->build_help_toc( $documentation );
685
686 if ( ! empty( $result['toc'] ) ) {
687 echo '<nav class="erecht24-help-toc" aria-label="' . esc_attr__( 'Seitennavigation', 'erecht24' ) . '">';
688 echo '<strong class="erecht24-help-toc-title">' . esc_html_x( 'Inhalt', 'Überschrift des Inhaltsverzeichnisses in der Hilfe', 'erecht24' ) . '</strong>';
689 echo '<ol class="erecht24-help-toc-list">';
690 foreach ( $result['toc'] as $item ) {
691 printf(
692 '<li class="erecht24-help-toc-h%1$d"><a href="#%2$s">%3$s</a></li>',
693 (int) $item['level'],
694 esc_attr( (string) $item['id'] ),
695 esc_html( (string) $item['text'] )
696 );
697 }
698 echo '</ol></nav>';
699 }
700
701 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_kses_post() ist die Escape-Funktion; Inhalt stammt aus lokaler Markdown-Datei
702 echo wp_kses_post( $result['html'] );
703 } else {
704 echo '<h2>' . esc_html__( 'Hilfe', 'erecht24' ) . '</h2>';
705 echo '<p>' . esc_html__( 'Die Hilfedatei konnte nicht geladen werden.', 'erecht24' ) . '</p>';
706 }
707
708 echo '</section>';
709 }
710
711 /**
712 * Inject id attributes into h2/h3 headings and collect TOC items.
713 *
714 * @param string $html Rendered help HTML.
715 * @return array{toc: array<int, array{level: int, id: string, text: string}>, html: string}
716 */
717 private function build_help_toc( string $html ): array {
718 $toc_items = array();
719 $seen = array();
720
721 $body = (string) preg_replace_callback(
722 '/<(h[23])>(.*?)<\/h[23]>/si',
723 function ( array $m ) use ( &$toc_items, &$seen ): string {
724 $tag = $m[1];
725 $inner = $m[2];
726 $level = (int) substr( $tag, 1 );
727 $text = wp_strip_all_tags( $inner );
728 $base = 'help-' . sanitize_title( $text );
729 $id = $base;
730
731 if ( isset( $seen[ $base ] ) ) {
732 ++$seen[ $base ];
733 $id = $base . '-' . $seen[ $base ];
734 } else {
735 $seen[ $base ] = 0;
736 }
737
738 $toc_items[] = array(
739 'level' => $level,
740 'id' => $id,
741 'text' => $text,
742 );
743
744 return sprintf( '<%1$s id="%2$s">%3$s</%1$s>', $tag, esc_attr( $id ), $inner );
745 },
746 $html
747 );
748
749 return array(
750 'toc' => $toc_items,
751 'html' => $body,
752 );
753 }
754
755 /**
756 * Load and convert the bundled Markdown documentation.
757 */
758 private function get_help_documentation_html(): string {
759 $file = ERECHT24_LEGAL_TEXT_PATH . self::HELP_DOCUMENTATION_FILE;
760 $mtime = is_readable( $file ) ? filemtime( $file ) : false;
761
762 if ( false === $mtime ) {
763 return '';
764 }
765
766 $cache_key = 'erecht24_help_html_' . ERECHT24_LEGAL_TEXT_VERSION . '_' . $mtime;
767 $cached = get_transient( $cache_key );
768
769 if ( false !== $cached ) {
770 return (string) $cached;
771 }
772
773 // phpcs:ignore WordPress.Security.EscapeOutput -- static local file path built from plugin constant
774 $content = include $file;
775 $html = is_string( $content ) ? $this->markdown_to_html( $content ) : '';
776
777 set_transient( $cache_key, $html, DAY_IN_SECONDS );
778
779 return $html;
780 }
781
782 /**
783 * Convert the static help Markdown into a safe HTML subset.
784 *
785 * @param string $markdown Markdown content.
786 */
787 private function markdown_to_html( string $markdown ): string {
788 $lines = preg_split( "/\r\n|\n|\r/", str_replace( "\t", ' ', $markdown ) );
789
790 if ( ! is_array( $lines ) ) {
791 return '';
792 }
793
794 $html = array();
795 $paragraph = array();
796 $list_type = '';
797 $list_open = false;
798 $line_count = count( $lines );
799
800 $flush_paragraph = function () use ( &$html, &$paragraph ): void {
801 // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- phpstan annotation only
802 /** @phpstan-ignore empty.variable ($paragraph wird via use-by-reference von außen befüllt) */
803 if ( empty( $paragraph ) ) {
804 return;
805 }
806
807 // @phpstan-ignore deadCode.unreachable
808 $html[] = '<p>' . $this->markdown_inline_to_html( implode( ' ', $paragraph ) ) . '</p>';
809 $paragraph = array();
810 };
811
812 $close_list = function () use ( &$html, &$list_type, &$list_open ): void {
813 // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- phpstan annotation only
814 /** @phpstan-ignore booleanNot.alwaysTrue ($list_open wird via use-by-reference von außen gesetzt) */
815 if ( ! $list_open ) {
816 return;
817 }
818
819 // @phpstan-ignore deadCode.unreachable
820 $html[] = '</li></' . $list_type . '>';
821 $list_type = '';
822 $list_open = false;
823 };
824
825 for ( $index = 0; $index < $line_count; $index++ ) {
826 $line = rtrim( (string) $lines[ $index ] );
827 $trimmed = trim( $line );
828
829 if ( '' === $trimmed ) {
830 $flush_paragraph();
831 $close_list();
832 continue;
833 }
834
835 $next_line = $index + 1 < $line_count ? trim( (string) $lines[ $index + 1 ] ) : '';
836
837 if ( '' !== $next_line && preg_match( '/^(=+|-+)$/', $next_line, $setext_matches ) ) {
838 $flush_paragraph();
839 $close_list();
840
841 $level = '=' === $setext_matches[1][0] ? 2 : 3;
842 $html[] = sprintf(
843 '<h%1$d>%2$s</h%1$d>',
844 $level,
845 $this->markdown_inline_to_html( $trimmed )
846 );
847 ++$index;
848 continue;
849 }
850
851 if ( preg_match( '/^(#{1,6})\s+(.+)$/', $trimmed, $heading_matches ) ) {
852 $flush_paragraph();
853 $close_list();
854
855 $level = min( 6, strlen( $heading_matches[1] ) + 1 );
856 $html[] = sprintf(
857 '<h%1$d>%2$s</h%1$d>',
858 $level,
859 $this->markdown_inline_to_html( trim( $heading_matches[2], " \t#" ) )
860 );
861 continue;
862 }
863
864 if ( preg_match( '/^\s*(\d+)\.\s+(.+)$/', $line, $ordered_matches ) ) {
865 $flush_paragraph();
866
867 if ( 'ol' !== $list_type ) {
868 $close_list();
869 $html[] = '<ol>';
870 $list_type = 'ol';
871 $list_open = true;
872 } else {
873 $html[] = '</li>';
874 }
875
876 $html[] = '<li>' . $this->markdown_inline_to_html( trim( $ordered_matches[2] ) );
877 continue;
878 }
879
880 if ( preg_match( '/^\s*[*+-]\s+(.+)$/', $line, $unordered_matches ) ) {
881 $flush_paragraph();
882
883 if ( 'ul' !== $list_type ) {
884 $close_list();
885 $html[] = '<ul>';
886 $list_type = 'ul';
887 $list_open = true;
888 } else {
889 $html[] = '</li>';
890 }
891
892 $html[] = '<li>' . $this->markdown_inline_to_html( trim( $unordered_matches[1] ) );
893 continue;
894 }
895
896 if ( $list_open && preg_match( '/^\s{2,}(.+)$/', $line, $continuation_matches ) ) {
897 $html[] = '<br>' . $this->markdown_inline_to_html( trim( $continuation_matches[1] ) );
898 continue;
899 }
900
901 $close_list();
902 $paragraph[] = $trimmed;
903 }
904
905 $flush_paragraph();
906 $close_list();
907
908 return wp_kses_post( implode( "\n", $html ) );
909 }
910
911 /**
912 * Convert inline Markdown syntax into escaped HTML.
913 *
914 * @param string $text Inline Markdown text.
915 */
916 private function markdown_inline_to_html( string $text ): string {
917 $tokens = array();
918 $store = static function ( string $html ) use ( &$tokens ): string {
919 $token = '%%ERECHT24MD' . count( $tokens ) . '%%';
920 $tokens[ $token ] = $html;
921
922 return $token;
923 };
924
925 foreach (
926 array(
927 '\_' => '_',
928 '\*' => '*',
929 '\-' => '-',
930 '\[' => '[',
931 '\]' => ']',
932 '\(' => '(',
933 '\)' => ')',
934 '\.' => '.',
935 ) as $escaped => $literal
936 ) {
937 $text = str_replace( $escaped, $store( esc_html( $literal ) ), $text );
938 }
939
940 $text = preg_replace_callback(
941 '/`([^`]+)`/u',
942 static function ( array $matches ) use ( $store ): string {
943 return $store( '<code>' . esc_html( $matches[1] ) . '</code>' );
944 },
945 $text
946 );
947
948 if ( null === $text ) {
949 return '';
950 }
951
952 $text = preg_replace_callback(
953 '/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/u',
954 function ( array $matches ) use ( $store ): string {
955 $label = esc_html( $this->markdown_unescape( $matches[1] ) );
956 $url = esc_url( $this->markdown_unescape( $matches[2] ) );
957
958 if ( '' === $url ) {
959 return $label;
960 }
961
962 return $store(
963 sprintf(
964 '<a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>',
965 $url,
966 $label
967 )
968 );
969 },
970 $text
971 );
972
973 if ( null === $text ) {
974 return '';
975 }
976
977 $html = esc_html( $text );
978 $html = (string) preg_replace( '/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $html );
979 $html = (string) preg_replace( '/(?<!\w)_(.+?)_(?!\w)/s', '<em>$1</em>', $html );
980 $html = (string) preg_replace( '/(?<!\*)\*([^*]+)\*(?!\*)/s', '<em>$1</em>', $html );
981 $html = $this->markdown_unescape( $html );
982
983 foreach ( $tokens as $token => $replacement ) {
984 $html = str_replace( $token, $replacement, $html );
985 }
986
987 return wp_kses_post( $html );
988 }
989
990 /**
991 * Remove backslashes used for Markdown punctuation escaping.
992 *
993 * @param string $text Escaped Markdown text.
994 */
995 private function markdown_unescape( string $text ): string {
996 return str_replace(
997 array( '\_', '\*', '\-', '\[', '\]', '\(', '\)', '\.', '\"' ),
998 array( '_', '*', '-', '[', ']', '(', ')', '.', '"' ),
999 $text
1000 );
1001 }
1002
1003 /**
1004 * Render Google Analytics tab.
1005 */
1006 private function render_google_analytics_tab(): void {
1007 $options = $this->settings->get_google_analytics();
1008
1009 echo '<section class="erecht24-panel">';
1010 echo '<h2>' . esc_html__( 'Google Analytics', 'erecht24' ) . '</h2>';
1011 echo '<p class="description">' . esc_html__( 'Sie können den vollständigen Google Analytics Tracking-Code inkl. Codesnippet für das Setzen des Opt-Out-Cookies auch über dieses Plugin generieren lassen.', 'erecht24' ) . '</p>';
1012 echo '<div class="erecht24-admin-warning">' . esc_html__( 'Google Analytics ist standardmäßig deaktiviert. Aktiviere es nur, wenn deine Website die notwendige Einwilligung einholt.', 'erecht24' ) . '</div>';
1013 echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
1014 wp_nonce_field( 'erecht24_legal_text_save' );
1015 echo '<input type="hidden" name="action" value="erecht24_legal_text_save">';
1016 echo '<input type="hidden" name="tab" value="google_analytics">';
1017 echo '<table class="form-table" role="presentation"><tbody>';
1018 echo '<tr><th scope="row"><label for="erecht24-ga-id">' . esc_html__( 'Measurement-ID', 'erecht24' ) . '</label></th><td>';
1019 echo '<input id="erecht24-ga-id" class="regular-text" name="erecht24[google_analytics][measurement]" value="' . esc_attr( (string) $options['measurement'] ) . '" placeholder="G-XXXXXXXX">';
1020 echo '</td></tr>';
1021 echo '<tr><th scope="row">' . esc_html__( 'Optionen', 'erecht24' ) . '</th><td>';
1022 echo '<label><input type="checkbox" name="erecht24[google_analytics][enabled]" value="1" ' . checked( ! empty( $options['enabled'] ), true, false ) . '> ' . esc_html__( 'Google Analytics Code ausgeben', 'erecht24' ) . '</label><br>';
1023 echo '<label><input type="checkbox" name="erecht24[google_analytics][usercentrics]" value="1" ' . checked( ! empty( $options['usercentrics'] ), true, false ) . '> ' . esc_html__( 'Usercentrics-kompatibel als deaktiviertes Script ausgeben', 'erecht24' ) . '</label><br>';
1024 echo '<label><input type="checkbox" name="erecht24[google_analytics][opt_out]" value="1" ' . checked( ! empty( $options['opt_out'] ), true, false ) . '> ' . esc_html__( 'Opt-out Helper bereitstellen', 'erecht24' ) . '</label>';
1025 echo '<p class="description">' . esc_html__( 'Opt-out Link:', 'erecht24' ) . ' <code>&lt;a onclick=&quot;eRecht24GaOptout();&quot;&gt;Google Analytics deaktivieren&lt;/a&gt;</code></p>';
1026 echo '</td></tr>';
1027 echo '</tbody></table>';
1028 submit_button( __( 'Google-Analytics-Einstellungen speichern', 'erecht24' ) );
1029 echo '</form>';
1030 echo '</section>';
1031 }
1032
1033 /**
1034 * Render status tab with local debug export.
1035 */
1036 private function render_status_tab(): void {
1037 $debug_data = $this->get_debug_data();
1038
1039 echo '<section class="erecht24-panel">';
1040 echo '<h2>' . esc_html__( 'Pluginstatus', 'erecht24' ) . '</h2>';
1041 echo '<p class="description">' . esc_html__( 'Hier können Sie prüfen, ob Ihr Server die Systemvoraussetzungen für dieses Plugin erfüllt und ob die eRecht24-Server erreichbar sind.', 'erecht24' ) . '</p>';
1042 echo '<p>' . esc_html__( 'Die Daten werden nur lokal angezeigt und erst durch Klick in die Zwischenablage kopiert. API-Schlüssel und Secret sind maskiert.', 'erecht24' ) . '</p>';
1043 echo '<table class="widefat striped erecht24-status-table"><tbody>';
1044 foreach ( $debug_data['status'] as $row ) {
1045 echo '<tr><th>' . esc_html( $row['label'] ) . '</th><td>' . esc_html( (string) $row['value'] ) . '</td></tr>';
1046 }
1047 echo '</tbody></table>';
1048
1049 if ( $this->settings->can_render_documents() && $this->settings->get_client_id() ) {
1050 echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" class="erecht24-inline-form">';
1051 wp_nonce_field( 'erecht24_legal_text_push_test' );
1052 echo '<input type="hidden" name="action" value="erecht24_legal_text_push_test">';
1053 submit_button( __( 'Remote-Push-Test ausführen', 'erecht24' ), 'secondary', 'submit', false );
1054 echo '</form>';
1055 }
1056
1057 if ( '' !== $this->settings->get_api_key() && ( '' === $this->settings->get_client_secret() || $this->settings->get_push_test_failed() ) ) {
1058 echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" class="erecht24-inline-form">';
1059 wp_nonce_field( 'erecht24_legal_text_force_reregister' );
1060 echo '<input type="hidden" name="action" value="erecht24_legal_text_force_reregister">';
1061 submit_button( __( 'Push-Client jetzt neu registrieren', 'erecht24' ), 'secondary', 'submit', false );
1062 echo '</form>';
1063 }
1064
1065 echo '<p><button type="button" class="button button-secondary" id="erecht24-copy-debug">' . esc_html__( 'Log- und Systeminfos kopieren', 'erecht24' ) . '</button> <span id="erecht24-copy-debug-result" class="description"></span></p>';
1066 echo '<textarea id="erecht24-debug-data" class="large-text code" rows="14" readonly>' . esc_textarea( wp_json_encode( $debug_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) . '</textarea>';
1067 echo '</section>';
1068 }
1069
1070 /**
1071 * Render shortcode example.
1072 *
1073 * @param string $type Document type.
1074 */
1075 private function render_shortcode_examples( string $type ): void {
1076 echo '<div class="erecht24-shortcodes"><strong>' . esc_html__( 'Shortcode-Beispiel', 'erecht24' ) . '</strong>';
1077 echo '<code>' . esc_html( sprintf( '[erecht24 type="%s" lang="de"]', $type ) ) . '</code>';
1078 echo '</div>';
1079 }
1080
1081 /**
1082 * Synchronize a single document after saving the current form.
1083 *
1084 * @param string $document Document type.
1085 */
1086 private function sync_document_and_redirect( string $document ): void {
1087 $type = Settings::normalize_document_type( $document );
1088 $labels = Settings::document_labels();
1089
1090 if ( '' === $type ) {
1091 $this->redirect_with_notice( __( 'Es wurde kein gültiger Rechtstext-Typ übergeben.', 'erecht24' ), 'error', 'settings' );
1092 }
1093
1094 $response = $this->api_client->fetch_document( $type );
1095
1096 if ( is_wp_error( $response ) ) {
1097 $this->redirect_with_notice( wp_strip_all_tags( $response->get_error_message() ), 'error', $type );
1098 }
1099
1100 $this->settings->save_remote_document( $type, $response );
1101
1102 $this->redirect_with_notice(
1103 sprintf(
1104 /* translators: %s: document label. */
1105 __( '%s wurde synchronisiert und die lokalen Einstellungen wurden gespeichert.', 'erecht24' ),
1106 esc_html( $labels[ $type ] ?? $type )
1107 ),
1108 'success',
1109 $type
1110 );
1111 }
1112
1113 /**
1114 * Copy a synchronized document into the local editable fields.
1115 *
1116 * @param string $document Document type.
1117 */
1118 private function copy_remote_document_to_local_and_redirect( string $document ): void {
1119 $type = Settings::normalize_document_type( $document );
1120 $labels = Settings::document_labels();
1121
1122 if ( '' === $type ) {
1123 $this->redirect_with_notice( __( 'Es wurde kein gültiger Rechtstext-Typ übergeben.', 'erecht24' ), 'error', 'settings' );
1124 }
1125
1126 $copied = $this->settings->copy_remote_document_to_local( $type );
1127
1128 if ( empty( $copied ) ) {
1129 $this->redirect_with_notice(
1130 __( 'Es wurden keine zuletzt synchronisierten Texte gefunden. Bitte synchronisieren Sie diesen Rechtstext zuerst.', 'erecht24' ),
1131 'error',
1132 $type
1133 );
1134 }
1135
1136 $this->redirect_with_notice(
1137 sprintf(
1138 /* translators: %s: document label. */
1139 __( '%s: Lokale Daten wurden mit den zuletzt synchronisierten Texten überschrieben.', 'erecht24' ),
1140 esc_html( $labels[ $type ] ?? $type )
1141 ),
1142 'success',
1143 $type
1144 );
1145 }
1146
1147 /**
1148 * Render segmented source toggle.
1149 *
1150 * @param string $type Document type.
1151 * @param string $selected Selected value.
1152 * @param bool $disabled Whether the control is disabled.
1153 */
1154 private function render_source_toggle( string $type, string $selected, bool $disabled = false ): void {
1155 $options = array(
1156 'remote' => __( 'eRecht24 Projekt Manager', 'erecht24' ),
1157 'local' => __( 'Lokaler Text', 'erecht24' ),
1158 );
1159
1160 echo '<div class="erecht24-segmented" role="radiogroup">';
1161 foreach ( $options as $value => $label ) {
1162 printf(
1163 '<label class="%1$s"><input type="radio" name="erecht24[documents][%2$s][source]" value="%3$s" %4$s %5$s> <span>%6$s</span></label>',
1164 esc_attr( $selected === $value ? 'is-active' : '' ),
1165 esc_attr( $type ),
1166 esc_attr( $value ),
1167 checked( $value, $selected, false ),
1168 disabled( $disabled, true, false ),
1169 esc_html( $label )
1170 );
1171 }
1172 echo '</div>';
1173
1174 if ( $disabled ) {
1175 echo '<input type="hidden" name="erecht24[documents][' . esc_attr( $type ) . '][source]" value="' . esc_attr( $selected ) . '">';
1176 }
1177 }
1178
1179 /**
1180 * Render textarea row.
1181 *
1182 * @param string $type Document type.
1183 * @param string $source Source key.
1184 * @param string $language Language key.
1185 * @param string $label Field label.
1186 * @param string $value Current value.
1187 * @param bool $is_readonly Whether field is readonly.
1188 */
1189 private function render_textarea_row( string $type, string $source, string $language, string $label, string $value, bool $is_readonly ): void {
1190 $name = sprintf( 'erecht24[documents][%s][%s][%s]', $type, $source, $language );
1191
1192 echo '<tr data-erecht24-source-row="' . esc_attr( $source ) . '"><th scope="row"><label for="' . esc_attr( $source . '-' . $language ) . '">' . esc_html( $label ) . '</label></th><td>';
1193 printf(
1194 '<textarea id="%1$s" class="large-text code" rows="8" name="%2$s" %3$s>%4$s</textarea>',
1195 esc_attr( $source . '-' . $language ),
1196 esc_attr( $name ),
1197 $is_readonly ? 'readonly' : '',
1198 esc_textarea( $value )
1199 );
1200 echo '</td></tr>';
1201 }
1202
1203 /**
1204 * Return active tab.
1205 */
1206 private function get_active_tab(): string {
1207 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation, no state change
1208 $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';
1209
1210 if ( in_array( $tab, array( 'settings', 'google_analytics', 'status', 'help' ), true ) || in_array( $tab, Settings::DOCUMENT_TYPES, true ) ) {
1211 return $tab;
1212 }
1213
1214 return 'settings';
1215 }
1216
1217 /**
1218 * Build admin page URL.
1219 *
1220 * @param string $tab Optional tab.
1221 */
1222 private function page_url( string $tab = 'settings' ): string {
1223 return add_query_arg(
1224 array(
1225 'page' => self::MENU_SLUG,
1226 'tab' => $tab,
1227 ),
1228 admin_url( 'options-general.php' )
1229 );
1230 }
1231
1232 /**
1233 * Store a notice and redirect back to the settings page.
1234 *
1235 * @param string $message Notice message.
1236 * @param string $type Notice type.
1237 * @param string $tab Destination tab.
1238 */
1239 private function redirect_with_notice( string $message, string $type = 'success', string $tab = 'settings' ): void {
1240 set_transient(
1241 $this->notice_key(),
1242 array(
1243 'type' => $type,
1244 'message' => $message,
1245 ),
1246 60
1247 );
1248
1249 wp_safe_redirect( $this->page_url( $tab ) );
1250 exit;
1251 }
1252
1253 /**
1254 * Build notice transient key.
1255 */
1256 private function notice_key(): string {
1257 return 'erecht24_legal_text_notice_' . get_current_blog_id() . '_' . get_current_user_id();
1258 }
1259
1260 /**
1261 * Return readable API status label.
1262 *
1263 * @param string $status API status.
1264 */
1265 private function get_api_status_label( string $status ): string {
1266 $labels = array(
1267 'missing' => __( 'Kein API-Schlüssel', 'erecht24' ),
1268 'unchecked' => __( 'Nicht erneut geprüft', 'erecht24' ),
1269 'valid' => __( 'Gültig', 'erecht24' ),
1270 'invalid' => __( 'Ungültig', 'erecht24' ),
1271 );
1272
1273 return $labels[ $status ] ?? $labels['missing'];
1274 }
1275
1276 /**
1277 * Build redacted debug data for local clipboard export.
1278 *
1279 * @return array<string,mixed>
1280 */
1281 private function get_debug_data(): array {
1282 global $wp_version;
1283
1284 $settings = $this->settings->get_all();
1285 $documents = array();
1286 $status_checks = $this->run_status_checks();
1287
1288 foreach ( Settings::document_labels() as $type => $label ) {
1289 $document = $this->settings->get_document( $type );
1290 $documents[ $type ] = array(
1291 'label' => $label,
1292 'source' => $document['source'] ?? '',
1293 'remote_modified' => $document['remote']['modified'] ?? '',
1294 'local_modified' => $document['local']['modified'] ?? '',
1295 'has_remote_de' => ! empty( $document['remote']['de'] ),
1296 'has_remote_en' => ! empty( $document['remote']['en'] ),
1297 'has_local_de' => ! empty( $document['local']['de'] ),
1298 'has_local_en' => ! empty( $document['local']['en'] ),
1299 );
1300 }
1301
1302 return array(
1303 // Each row keeps a stable, language-independent 'key' alongside the
1304 // translated 'label', so the JSON export below stays machine-readable
1305 // across locales while the on-screen table is fully translatable.
1306 'status' => array(
1307 array(
1308 'key' => 'wordpress',
1309 'label' => __( 'WordPress', 'erecht24' ),
1310 'value' => implode( '.', array_slice( explode( '.', (string) $wp_version ), 0, 2 ) ),
1311 ),
1312 array(
1313 'key' => 'php',
1314 'label' => __( 'PHP', 'erecht24' ),
1315 'value' => PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION,
1316 ),
1317 array(
1318 'key' => 'plugin',
1319 'label' => __( 'Plugin', 'erecht24' ),
1320 'value' => ERECHT24_LEGAL_TEXT_VERSION,
1321 ),
1322 array(
1323 'key' => 'api_key_status',
1324 'label' => __( 'API-Schlüssel-Status', 'erecht24' ),
1325 'value' => $this->get_api_status_label( $this->settings->get_api_key_status() ),
1326 ),
1327 array(
1328 'key' => 'api_key',
1329 'label' => __( 'API-Schlüssel', 'erecht24' ),
1330 'value' => Settings::mask_secret( $this->settings->get_api_key() ),
1331 ),
1332 array(
1333 'key' => 'client_id',
1334 'label' => __( 'Client-ID', 'erecht24' ),
1335 'value' => (string) $this->settings->get_client_id(),
1336 ),
1337 array(
1338 'key' => 'push_endpoint',
1339 'label' => __( 'Push-Endpoint', 'erecht24' ),
1340 'value' => rest_url( 'erecht24/v1/push' ),
1341 ),
1342 array(
1343 'key' => 'home_url',
1344 'label' => __( 'Home-URL', 'erecht24' ),
1345 'value' => home_url( '/' ),
1346 ),
1347 array(
1348 'key' => 'site_url',
1349 'label' => __( 'Site-URL', 'erecht24' ),
1350 'value' => site_url( '/' ),
1351 ),
1352 array(
1353 'key' => 'wp_http_api',
1354 'label' => __( 'WP HTTP API', 'erecht24' ),
1355 'value' => $status_checks['wp_remote']['message'],
1356 ),
1357 array(
1358 'key' => 'remote_push_test',
1359 'label' => __( 'Remote-Push-Test', 'erecht24' ),
1360 'value' => $status_checks['push']['message'],
1361 ),
1362 array(
1363 'key' => 'rest_api',
1364 'label' => __( 'REST API', 'erecht24' ),
1365 'value' => rest_url(),
1366 ),
1367 ),
1368 'documents' => $documents,
1369 'logs' => $this->settings->get_logs(),
1370 'checks' => $status_checks,
1371 'ga' => array(
1372 'enabled' => ! empty( $settings['google_analytics']['enabled'] ),
1373 'measurement' => ! empty( $settings['google_analytics']['measurement'] ) ? Settings::mask_secret( (string) $settings['google_analytics']['measurement'] ) : '',
1374 'usercentrics' => ! empty( $settings['google_analytics']['usercentrics'] ),
1375 'opt_out' => ! empty( $settings['google_analytics']['opt_out'] ),
1376 ),
1377 );
1378 }
1379
1380 /**
1381 * Build local status checks without automatic external requests.
1382 *
1383 * @return array<string,array<string,mixed>>
1384 */
1385 private function run_status_checks(): array {
1386 $wp_remote_ok = function_exists( 'wp_remote_request' );
1387 $wp_remote_message = $wp_remote_ok
1388 ? __( 'WP HTTP API ist verfügbar.', 'erecht24' )
1389 : __( 'WP HTTP API ist nicht verfügbar.', 'erecht24' );
1390
1391 $push_ok = false;
1392 $push_message = __( 'Push-Test übersprungen: kein gültiger API-Schlüssel oder kein registrierter Client.', 'erecht24' );
1393
1394 if ( $this->settings->can_render_documents() && $this->settings->get_client_id() ) {
1395 $push_message = __( 'Push-Test nicht automatisch ausgeführt. Starte den Test über den Button im Status-Reiter.', 'erecht24' );
1396 }
1397
1398 return array(
1399 'wp_remote' => array(
1400 'success' => $wp_remote_ok,
1401 'message' => $wp_remote_message,
1402 ),
1403 'push' => array(
1404 'success' => $push_ok,
1405 'message' => $push_message,
1406 ),
1407 );
1408 }
1409 }
1410