register_admin_assets(); } } private function register_admin_assets(): void { wp_enqueue_style('commentmate-admin', COMMENTMATE_URL . 'assets/admin/admin.css', array(), COMMENTMATE_VERSION); wp_enqueue_script('commentmate-admin', COMMENTMATE_URL . 'assets/admin/admin.js', array(), COMMENTMATE_VERSION, true); wp_add_inline_script('commentmate-admin', 'window.CommentMateAdminMessages = ' . wp_json_encode(array( 'm0' => __('Share link copied to your clipboard.', 'commentmate'), 'm1' => __('Link copied to clipboard.', 'commentmate'), 'm2' => __('Select the share link and copy it manually.', 'commentmate'), 'm3' => __('Continue deleting data', 'commentmate'), 'm4' => __('Finishing the current safe deletion step. The dialog will close afterward, and you can continue deleting data later.', 'commentmate'), 'm5' => __('Reload page', 'commentmate'), 'm6' => __('Try again', 'commentmate'), 'm7' => __('Deleting CommentMate data safely…', 'commentmate'), 'm8' => __('CommentMate could not continue deletion. Resolve the issue and try again.', 'commentmate'), 'm9' => __('All CommentMate review data was deleted and verified.', 'commentmate'), 'm10' => __('Close', 'commentmate'), 'm11' => __('CommentMate is safely deleting data in batches.', 'commentmate'), 'm12' => __('CommentMate could not continue deletion. Check the connection and try again.', 'commentmate'), 'm13' => __('Cancel', 'commentmate'), 'm14' => __('Deletion complete', 'commentmate'), ), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';', 'before'); } public function register_menu(): void { $capability = Capabilities::MANAGE; add_menu_page( __('CommentMate', 'commentmate'), __('CommentMate', 'commentmate'), $capability, self::PAGE_SLUG, array($this, 'render'), 'dashicons-format-chat', 58 ); foreach (array('review' => __('Review', 'commentmate'), 'notifications' => __('Notifications', 'commentmate'), 'advanced' => __('Advanced', 'commentmate')) as $tab => $label) { add_submenu_page(self::PAGE_SLUG, $label, $label, $capability, 'review' === $tab ? self::PAGE_SLUG : self::PAGE_SLUG . '&tab=' . $tab, array($this, 'render')); } } public function review_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to manage CommentMate.', 'commentmate'), 403); } check_admin_referer('commentmate_review_action'); $operation = isset($_POST['commentmate_action']) ? sanitize_key((string) wp_unslash($_POST['commentmate_action'])) : ''; $reviews = new Review_Service(); $result = match ($operation) { 'start' => $reviews->start(get_current_user_id()), 'pause' => $reviews->pause(get_current_user_id()), 'rotate' => $reviews->rotate(get_current_user_id()), default => Security::error('invalid_operation', __('Unknown review operation.', 'commentmate'), 400), }; if (is_wp_error($result)) { $this->store_notice('error', $result->get_error_message()); } else { $message = match ($operation) { 'start' => __('Sharing is active. Copy the private share link below.', 'commentmate'), 'pause' => __('Sharing is paused. The share link and all reviewer sessions have been revoked.', 'commentmate'), 'rotate' => __('A replacement review link is ready. The previous link no longer works.', 'commentmate'), default => __('Review updated.', 'commentmate'), }; if ('pause' === $operation && isset($result['review']['id'], $result['review']['tokenVersion'])) { set_transient('commentmate_stopped_offer_' . get_current_user_id(), $result['review'], 300); } $this->store_notice('success', $message); } $this->redirect(); } /** * Saves the owner's explicit data-retention preference for the standard * WordPress plugin Delete action. It does not erase anything immediately. */ public function uninstall_data_setting_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to update CommentMate data settings.', 'commentmate'), 403); } check_admin_referer('commentmate_uninstall_data_setting'); $delete_data = isset($_POST['commentmate_delete_data_on_uninstall']) && '1' === sanitize_text_field((string) wp_unslash($_POST['commentmate_delete_data_on_uninstall'])); update_option('commentmate_delete_data_on_uninstall', $delete_data ? '1' : '0', false); $this->store_notice( 'success', $delete_data ? __('CommentMate data will be removed when the plugin is deleted through WordPress. Media Library items will be kept.', 'commentmate') : __('CommentMate data will be kept if the plugin is deleted through WordPress.', 'commentmate') ); $this->redirect('advanced'); } /** * Handles one confirmed batch from the owner-only Delete all data dialog. * A stopped-review offer supplies a nonce-bound review id and generation, never * an export choice or internal checkpoint. Cleanup_Service advances durable state * server-side so retries and concurrent requests remain fail-closed. */ public function delete_all_data_action(): void { if (!Owner::can_manage_current_user()) { wp_send_json_error(array( 'message' => __('Only a site administrator may delete review data.', 'commentmate'), ), 403); } $review_id = isset($_POST['stopped_review']) ? absint($_POST['stopped_review']) : 0; $version = isset($_POST['stopped_version']) ? absint($_POST['stopped_version']) : 0; $nonce_action = $review_id > 0 ? 'commentmate_delete_stopped_' . $review_id . '_' . $version : 'commentmate_delete_all_data'; $nonce = isset($_POST['nonce']) ? sanitize_text_field((string) wp_unslash($_POST['nonce'])) : ''; if ('' === $nonce || false === wp_verify_nonce($nonce, $nonce_action)) { wp_send_json_error(array( 'message' => __('This deletion confirmation has expired. Reload the CommentMate page to receive a new confirmation, then try again.', 'commentmate'), 'requiresReload' => true, ), 403); } $cleanup = new Cleanup_Service(); $result = $review_id > 0 ? $cleanup->delete_stopped_for_owner($review_id, $version, get_current_user_id()) : $cleanup->delete_all_for_owner(get_current_user_id()); if (is_wp_error($result)) { $error_data = $result->get_error_data(); $status = is_array($error_data) && isset($error_data['status']) ? (int) $error_data['status'] : 503; $status = $status >= 400 && $status <= 599 ? $status : 503; wp_send_json_error(array( 'message' => $result->get_error_message(), 'canContinue' => true, ), $status); } $completed = 'completed' === (string) ($result['state'] ?? ''); wp_send_json_success(array( 'completed' => $completed, 'canContinue' => !$completed, 'message' => $completed ? __('Review data was deleted and verified. Media Library images, including compressed copies, were kept.', 'commentmate') : __('CommentMate is safely deleting data in batches. Keep this page open while it continues.', 'commentmate'), )); } /** * Saves the five owner-only notification preferences. Notification_Service * owns all validation, normalization, storage and schedule reconciliation; * this controller only verifies the WordPress request boundary. */ public function notification_retry_action(): void { if (!Owner::can_manage_current_user()) wp_die(esc_html__('Only a site administrator may retry notifications.', 'commentmate'), '', array('response' => 403)); check_admin_referer('commentmate_notification_retry'); $result = Notification_Service::retry_failed(absint($_POST['review_id'] ?? 0), sanitize_key((string) wp_unslash($_POST['channel'] ?? '')), absint($_POST['attempt'] ?? 0), absint($_POST['failures'] ?? 0)); $this->store_notice(true === $result ? 'success' : 'error', true === $result ? __('Notification handed to WordPress mail. Confirm inbox delivery separately.', 'commentmate') : __('Notification retry was not completed. Check mail configuration and reload the failure details.', 'commentmate')); $this->redirect('notifications'); } public function notification_settings_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('Only a site administrator may update notification settings.', 'commentmate'), 403); } check_admin_referer('commentmate_notification_settings'); $settings = Notification_Service::update_settings_for_owner(array( 'enabled' => isset($_POST['commentmate_notification_enabled']) ? '1' : '0', 'recipients' => isset($_POST['commentmate_notification_recipients']) ? sanitize_text_field((string) wp_unslash($_POST['commentmate_notification_recipients'])) : '', 'daily_summary' => isset($_POST['commentmate_notification_daily_summary']) ? '1' : '0', 'daily_time' => isset($_POST['commentmate_notification_daily_time']) ? sanitize_text_field((string) wp_unslash($_POST['commentmate_notification_daily_time'])) : '', 'immediate_alerts' => isset($_POST['commentmate_notification_immediate_alerts']) ? '1' : '0', 'detail_level' => isset($_POST['commentmate_notification_detail_level']) ? sanitize_text_field((string) wp_unslash($_POST['commentmate_notification_detail_level'])) : '', ), get_current_user_id()); if (is_wp_error($settings)) { $this->store_notice('error', $settings->get_error_message()); $this->redirect('notifications'); } $message = !empty($settings['enabled']) ? __('CommentMate notification settings were saved. Mail will use this site’s existing WordPress mail configuration.', 'commentmate') : __('CommentMate notification settings were saved. Email notifications remain off.', 'commentmate'); $this->store_notice('success', $message); $this->redirect('notifications'); } /** * Streams an explicit owner-requested diagnostic file. The service builds * only a privacy-safe summary and this handler never uploads or stores it. */ public function download_diagnostics_action(): never { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('Only a site administrator may download diagnostics.', 'commentmate'), 403); } check_admin_referer('commentmate_download_diagnostics'); $json = wp_json_encode(Diagnostics_Service::document(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if (!is_string($json)) { wp_die(esc_html__('CommentMate could not generate a diagnostics file.', 'commentmate'), 500); } Security::send_no_cache_headers(); header('Content-Type: application/json; charset=UTF-8'); header('Content-Disposition: attachment; filename="commentmate-diagnostics.json"'); echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON generated by wp_json_encode. exit; } /** * Handles an owner-only resolve/reopen action from the WordPress board. The * review id is taken from the current server-side review, never from the * browser, so an admin form cannot be repurposed across reviews. */ public function comment_status_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to manage CommentMate comments.', 'commentmate'), 403); } check_admin_referer('commentmate_comment_status'); $comment_id = isset($_POST['comment_id']) ? absint($_POST['comment_id']) : 0; $status = isset($_POST['comment_status']) ? sanitize_key((string) wp_unslash($_POST['comment_status'])) : ''; $current = (new Admin_Board())->current(); $review = $current['review']; if (!is_array($review)) { $this->store_notice('error', __('There is no active or paused review to update.', 'commentmate')); $this->redirect(); } $result = (new Admin_Board())->set_status((int) $review['id'], $comment_id, $status, get_current_user_id()); if (is_wp_error($result)) { $this->store_notice('error', $result->get_error_message()); } else { $this->store_notice('success', 'resolved' === $status ? __('Comment resolved.', 'commentmate') : __('Comment reopened.', 'commentmate')); } $this->redirect(); } public function comment_edit_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to edit CommentMate comments.', 'commentmate'), 403); } check_admin_referer('commentmate_comment_edit'); $comment_id = isset($_POST['comment_id']) ? absint($_POST['comment_id']) : 0; $body = isset($_POST['comment_body']) ? wp_kses_post((string) wp_unslash($_POST['comment_body'])) : ''; $current = (new Admin_Board())->current(); $review = $current['review']; if (!is_array($review)) { $this->store_notice('error', __('There is no retained review comment to edit.', 'commentmate')); $this->redirect(); } $result = (new Admin_Board())->update_body((int) $review['id'], $comment_id, $body, get_current_user_id()); $this->store_notice(is_wp_error($result) ? 'error' : 'success', is_wp_error($result) ? $result->get_error_message() : __('Comment updated.', 'commentmate')); $this->redirect(); } public function comment_delete_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to delete CommentMate comments.', 'commentmate'), 403); } check_admin_referer('commentmate_comment_delete'); if (!hash_equals('DELETE', sanitize_text_field((string) wp_unslash($_POST['delete_confirmation'] ?? '')))) { $this->store_notice('error', __('Type DELETE exactly before removing a review comment.', 'commentmate')); $this->redirect(); } $comment_id = isset($_POST['comment_id']) ? absint($_POST['comment_id']) : 0; $current = (new Admin_Board())->current(); $review = $current['review']; if (!is_array($review)) { $this->store_notice('error', __('There is no retained review comment to delete.', 'commentmate')); $this->redirect(); } $result = (new Admin_Board())->delete_comment((int) $review['id'], $comment_id, get_current_user_id()); $this->store_notice(is_wp_error($result) ? 'error' : 'success', is_wp_error($result) ? $result->get_error_message() : __('Comment thread removed from this review.', 'commentmate')); $this->redirect(); } /** * Removes either every comment thread or just the resolved threads from the * latest retained review. The review and scope are resolved server-side so a * dashboard form cannot be reused against another review. */ public function bulk_comment_delete_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to delete CommentMate comments.', 'commentmate'), 403); } check_admin_referer('commentmate_bulk_comment_delete'); $scope = isset($_POST['delete_scope']) ? sanitize_key((string) wp_unslash($_POST['delete_scope'])) : ''; if (!in_array($scope, array('all', 'resolved'), true)) { $this->store_notice('error', __('Choose the comments to remove and try again.', 'commentmate')); $this->redirect('advanced'); } $current = (new Admin_Board())->current(); $review = $current['review']; if (!is_array($review)) { $this->store_notice('error', __('There are no retained review comments to delete.', 'commentmate')); $this->redirect('advanced'); } $result = (new Admin_Board())->delete_comments((int) $review['id'], $scope, get_current_user_id()); if (is_wp_error($result)) { $this->store_notice('error', $result->get_error_message()); } elseif (0 === $result) { $this->store_notice('success', 'resolved' === $scope ? __('There were no resolved comments to delete.', 'commentmate') : __('There were no comments to delete.', 'commentmate')); } elseif ('resolved' === $scope) { /* translators: %d: number of comments deleted. */ $this->store_notice('success', sprintf(_n('%d resolved comment deleted.', '%d resolved comments deleted.', $result, 'commentmate'), $result)); } else { /* translators: %d: number of comments deleted. */ $this->store_notice('success', sprintf(_n('%d comment deleted.', '%d comments deleted.', $result, 'commentmate'), $result)); } $this->redirect('advanced'); } /** * Streams a board attachment through the same owner/capability check used by * the REST boundary. The posted attachment id is not trusted to choose a * review; the latest retained board review is resolved server-side. */ public function asset_download_action(): never { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to download CommentMate attachments.', 'commentmate'), 403); } check_admin_referer('commentmate_asset_download'); $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0; $review_id = $this->board_review_id(); if ($attachment_id < 1 || $review_id < 1) { wp_die(esc_html__('That attachment is no longer available.', 'commentmate'), 404); } $result = (new Asset_Service())->stream_for_owner($review_id, $attachment_id); if (is_wp_error($result)) { $error_data = $result->get_error_data(); $status = is_array($error_data) && isset($error_data['status']) ? (int) $error_data['status'] : 503; wp_die(esc_html($result->get_error_message()), '', array('response' => $status)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Integer HTTP status is response metadata, not rendered HTML; the message is escaped. } exit; } /** * Promotes a board-selected image into the normal WordPress Media Library. */ public function asset_promote_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to promote CommentMate attachments.', 'commentmate'), 403); } check_admin_referer('commentmate_asset_promote'); $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0; $review_id = $this->board_review_id(); if ($attachment_id < 1 || $review_id < 1) { $this->store_notice('error', __('That attachment is no longer available.', 'commentmate')); $this->redirect(); } $result = (new Asset_Service())->promote_to_media($review_id, $attachment_id); if (is_wp_error($result)) { $this->store_notice('error', $result->get_error_message()); } else { $this->store_notice('success', __('Image promoted to the Media Library. Cleanup will preserve that Media Library item.', 'commentmate')); } $this->redirect(); } /** * Soft-deletes a private board attachment. Promoted Media Library items are * intentionally preserved by Asset_Service. */ public function asset_delete_action(): void { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('You do not have permission to delete CommentMate attachments.', 'commentmate'), 403); } check_admin_referer('commentmate_asset_delete'); $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0; $review_id = $this->board_review_id(); if ($attachment_id < 1 || $review_id < 1) { $this->store_notice('error', __('That attachment is no longer available.', 'commentmate')); $this->redirect(); } $result = (new Asset_Service())->delete_for_owner($review_id, $attachment_id); if (is_wp_error($result)) { $this->store_notice('error', $result->get_error_message()); } else { $this->store_notice('success', __('Attachment removed from this review.', 'commentmate')); } $this->redirect(); } public function export_review_action(): never { if (!Owner::can_manage_current_user()) { wp_die(esc_html__('Only a site administrator may download this review.', 'commentmate'), 403); } check_admin_referer('commentmate_export_review'); $review = (new Review_Service())->latest(); if (!is_array($review)) { wp_die(esc_html__('There is no review to download.', 'commentmate'), 404); } $service = new Export_Service(); $actor = get_current_user_id(); $export = $service->create((int) $review['id'], $actor); if (is_wp_error($export)) { $this->store_notice('error', $export->get_error_message()); $this->redirect('advanced'); } try { $result = $service->stream($export, $actor); if (true === $result) $service->record_delivery($export, $actor); } finally { $service->destroy($export, $actor); } if (is_wp_error($result)) { $this->store_notice('error', $result->get_error_message()); $this->redirect('advanced'); } exit; } public function storage_setting_action(): never { if (!Owner::can_manage_current_user()) wp_die(esc_html__('Only a site administrator may change storage settings.', 'commentmate'), 403); check_admin_referer('commentmate_storage_setting'); $value = isset($_POST['commentmate_storage_limit_mb']) && is_string($_POST['commentmate_storage_limit_mb']) ? sanitize_text_field(wp_unslash($_POST['commentmate_storage_limit_mb'])) : ''; if (!ctype_digit($value) || (int) $value < 25 || (int) $value > 250) { $this->store_notice('error', __('Choose a whole-number storage limit between 25 and 250 MB.', 'commentmate')); } else { update_option('commentmate_storage_limit_mb', (int) $value, false); $this->store_notice('success', __('Private storage limit saved. Lowering the limit never deletes existing files.', 'commentmate')); } $this->redirect('advanced'); } private function render_next_steps(): void { ?>

current(); if (is_array($current) && 'active' === (string) ($current['state'] ?? '')) { // The active-link field contains a capability secret, so prevent an // authenticated owner response from being retained by an intermediary. Security::send_no_cache_headers(); } $latest = $reviews->latest(); $notice = $this->take_notice(); $tab = $this->requested_tab(); ?>

render_notice($notice); ?> render_notifications_tab(); ?> latest(); $this->render_advanced_tab( $reviews->storage_status(is_array($managed_review) ? (int) $managed_review['id'] : null), $managed_review ); ?> render_delete_control(true, false, $offer); } $this->render_review_tab($current, $latest); $this->render_next_steps(); ?>
|null $current * @param array|null $latest */ private function render_review_tab(?array $current, ?array $latest): void { $state = is_array($current) ? (string) ($current['state'] ?? '') : ''; $ended = '' === $state && is_array($latest) && 'ended' === (string) ($latest['state'] ?? ''); $display_state = $ended ? 'ended' : $state; $owner_workspace_url = add_query_arg('review', 'true', home_url('/')); $share_link = null; if ('active' === $state && is_array($current)) { $candidate = (new Review_Service())->active_share_link((int) $current['id']); $share_link = is_string($candidate) ? $candidate : null; } $status_label = match ($display_state) { 'active' => __('Sharing active', 'commentmate'), 'paused' => __('Sharing paused', 'commentmate'), 'ended' => __('Previous review retained', 'commentmate'), default => __('Not started', 'commentmate'), }; ?>

render_active_share_link_control($share_link); ?>

$storage * @param array|null $managed_review */ private function render_advanced_tab(array $storage, ?array $managed_review): void { $storage_status = match ((string) ($storage['warning'] ?? 'ok')) { 'critical' => !empty($storage['acceptingFiles']) ? __('Storage nearly full', 'commentmate') : __('Storage full', 'commentmate'), 'warning' => __('Storage running low', 'commentmate'), default => __('Storage available', 'commentmate'), }; $pending_cleanup = (new Cleanup_Service())->pending_review_id(get_current_user_id()); $has_deletable_data = is_array($managed_review) || is_int($pending_cleanup); $continuing_deletion = is_int($pending_cleanup); ?>

render_info_tooltip(__('Files uploaded by reviewers and automatic screenshots are kept private on your website. This shows the space they use, separate from your WordPress Media Library. Saving an image to the Media Library makes that copy publicly accessible. Comments themselves do not count towards this limit.', 'commentmate')); ?>

render_compatibility_summary(Compatibility_Service::summary()); ?>

render_info_tooltip(__('Applies only when WordPress deletes the plugin. Media Library images stay.', 'commentmate')); ?>
render_delete_control($has_deletable_data, $continuing_deletion); ?>
estimated_reclaimable_bytes($review_id) : null; $nonce_action = $review_id > 0 ? 'commentmate_delete_stopped_' . $review_id . '_' . $version : 'commentmate_delete_all_data'; ?>

render_info_tooltip(__('WordPress cron timing is approximate unless your host runs real server cron.', 'commentmate')); ?>

/>
render_info_tooltip(__('Includes activity counts, page titles, timestamps, and secure owner links without feedback excerpts.', 'commentmate')); ?>
/>
render_info_tooltip(__('Includes a sanitized excerpt of up to 20 words per feedback item, plus a secure owner link.', 'commentmate')); ?>

'last_attempt_at', 'failures' => 'failure_count') as $field => $source) : $field = is_int($field) ? $source : $field; ?>

0) : ?>

$summary */ private function render_compatibility_summary(array $summary): void { $sections = array( 'required' => __('Required checks', 'commentmate'), 'optional' => __('Optional capabilities', 'commentmate'), ); ?>

$heading) : ?>

  • :
|null,comments:list>} $board */ private function render_comment_board(array $board): void { $comments = $board['comments']; ?>



  • :

  • (, )
self::PAGE_SLUG); if (in_array($tab, array('advanced', 'notifications'), true)) { $args['tab'] = $tab; } return add_query_arg($args, admin_url('admin.php')); } /** * @param array{type:string,message:string}|null $notice */ private function render_notice(?array $notice): void { if (!is_array($notice)) { return; } ?>

'success' === $type ? 'success' : 'error', 'message' => $message, ), 5 * MINUTE_IN_SECONDS ); } /** * @return array{type:string,message:string}|null */ private function take_notice(): ?array { $key = 'commentmate_admin_notice_' . get_current_user_id(); $notice = get_transient($key); delete_transient($key); return is_array($notice) ? $notice : null; } /** * Uses the board's server-side selected review for every attachment action, * avoiding a user-supplied review id in admin forms. */ private function board_review_id(): int { $current = (new Admin_Board())->current(); return is_array($current['review']) ? (int) ($current['review']['id'] ?? 0) : 0; } private function redirect(string $tab = 'review'): never { wp_safe_redirect($this->tab_url(in_array($tab, array('advanced', 'notifications'), true) ? $tab : 'review')); exit; } }