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 {
?>
|
|
|
|
|