Notification::PT, 'request_key' => 'daytidy', 'nonce_name' => Notification::NONCE, ]); } public function init(): void { parent::init(); add_filter('manage_edit-' . $this->PT . '_sortable_columns', [$this, 'sortable']); add_action('pre_get_posts', [$this, 'filterQuery']); (new DetailsMetabox($this))->init(); (new PlaceholdersMetabox($this))->init(); } public function args(): array { return apply_filters('DayTidy/F__DayTidy/Posts/Notification__args/args', [ 'labels' => $this->labels( esc_html__('Template', 'daytidy'), esc_html__('Templates', 'daytidy') ), 'public' => false, 'show_ui' => true, 'show_in_menu' => false, 'show_in_rest' => false, 'show_in_admin_bar' => false, 'has_archive' => false, 'exclude_from_search' => true, 'publicly_queryable' => false, 'supports' => ['title'], // Keep admin-only access primitive so WordPress does not register // DayTidy settings access as a post meta capability. 'map_meta_cap' => false, 'capabilities' => Capabilities::postTypeCapabilities(Capabilities::MANAGE_SETTINGS), 'menu_icon' => 'dashicons-email-alt', 'menu_position' => 28, ]); } public function columns(array $columns): array { unset($columns['date']); unset($columns['author']); $columns['trigger'] = esc_html__('Trigger', 'daytidy'); $columns['type'] = esc_html__('Type', 'daytidy'); $columns['recipients'] = esc_html__('Recipients', 'daytidy'); $columns['enabled'] = esc_html__('Enabled', 'daytidy'); return $columns; } public function content(string $column_name, int $post_id): void { $notification = $this->entity($post_id); if ($column_name === 'trigger') { $triggers = Registry::triggerOptions(); echo esc_html($triggers[$notification->trigger()] ?? $notification->trigger()); } else if ($column_name === 'type') { $types = Registry::types(); echo esc_html($types[$notification->type()]['label'] ?? $notification->type()); } else if ($column_name === 'enabled') { echo $notification->enabled() ? '' : ''; } else if ($column_name === 'recipients') { $labels = []; $contexts = Registry::contexts($notification->trigger()); foreach ($contexts as $context => $details) { $template = $notification->template($context); if (empty($template['active'])) continue; $labels[] = $details['label']; } echo esc_html(count($labels) ? implode(', ', $labels) : __('None', 'daytidy')); } } /** * @param mixed[] $columns * @return mixed[] */ public function sortable(array $columns): array { $columns['trigger'] = 'daytidy_notification_trigger'; $columns['type'] = 'daytidy_notification_type'; return $columns; } public function filterQuery(WP_Query $query): void { if (!is_admin() || !$query->is_main_query() || $query->get('post_type') !== $this->PT) return; $trigger = isset($_REQUEST['daytidy_trigger']) ? sanitize_key(wp_unslash($_REQUEST['daytidy_trigger'])) : ''; if ($trigger !== '') { $meta_query = $query->get('meta_query'); $meta_query = is_array($meta_query) ? $meta_query : []; $meta_query[] = [ 'key' => 'daytidy_notification_trigger', 'value' => $trigger, ]; $query->set('meta_query', $meta_query); } $type = isset($_REQUEST['daytidy_type']) ? sanitize_key(wp_unslash($_REQUEST['daytidy_type'])) : ''; if ($type !== '') { $meta_query = $query->get('meta_query'); $meta_query = is_array($meta_query) ? $meta_query : []; $meta_query[] = [ 'key' => 'daytidy_notification_type', 'value' => $type, ]; $query->set('meta_query', $meta_query); } $orderby = $query->get('orderby'); if ($orderby === 'daytidy_notification_trigger' || $orderby === 'daytidy_notification_type') { $query->set('meta_key', $orderby); $query->set('orderby', $orderby); $order = $query->get('order'); $query->set('order', $order ?: 'asc'); } } public function filters(string $post_type): void { if ($post_type !== $this->PT) return; // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- All generated filter controls are passed through DayTidy Kses below. $selected_trigger = isset($_GET['daytidy_trigger']) ? sanitize_text_field(wp_unslash($_GET['daytidy_trigger'])) : ''; $selected_type = isset($_GET['daytidy_type']) ? sanitize_text_field(wp_unslash($_GET['daytidy_type'])) : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses sanitizes the generated filter control markup. echo Kses::form(Form::select([ 'class' => 'wui-select daytidy-select', 'name' => 'daytidy_trigger', 'id' => 'daytidy-notification-filter-trigger', 'show_empty' => true, 'empty_label' => esc_html__('All Triggers', 'daytidy'), 'options' => $this->triggerFilterOptions(), 'value' => $selected_trigger, ])); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses sanitizes the generated filter control markup. echo Kses::form(Form::select([ 'class' => 'wui-select daytidy-select', 'name' => 'daytidy_type', 'id' => 'daytidy-notification-filter-type', 'show_empty' => true, 'empty_label' => esc_html__('All Types', 'daytidy'), 'options' => $this->typeFilterOptions(), 'value' => $selected_type, ])); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } public function store(Post $entity, array $data): void { $raw = $data; $trigger = isset($raw['trigger']) ? sanitize_key($raw['trigger']) : Registry::TRIGGER_BOOKING_CREATED; if (!array_key_exists($trigger, Registry::triggers())) $trigger = Registry::TRIGGER_BOOKING_CREATED; $type = isset($raw['type']) ? sanitize_key($raw['type']) : Registry::TYPE_EMAIL; if (!array_key_exists($type, Registry::types())) $type = Registry::TYPE_EMAIL; $enabled = !empty($raw['enabled']) ? 1 : 0; $templates = $this->sanitizeTemplates((array) ($raw['templates'] ?? []), $trigger); $reminder_schedule = $this->sanitizeReminderSchedule((array) ($raw['reminder_schedule'] ?? [])); $entity->meta_store([ 'daytidy_notification_trigger' => $trigger, 'daytidy_notification_type' => $type, 'daytidy_notification_enabled' => $enabled, 'daytidy_notification_templates' => $templates, 'daytidy_notification_reminder_schedule' => $reminder_schedule, ]); do_action('DayTidy/A__DayTidy/Posts/Notification__store/data', $entity, $data, $this); } public function entity(int $id): NotificationEntity { return new NotificationEntity($id); } /** * @param mixed[] $templates * @return mixed[] */ private function sanitizeTemplates(array $templates, string $trigger): array { $sanitized = []; foreach (Registry::contexts($trigger) as $context => $details) { $template = isset($templates[$context]) && is_array($templates[$context]) ? $templates[$context] : Defaults::blankTemplate($trigger, $context); $sanitized[$context] = [ 'active' => !empty($template['active']) ? 1 : 0, 'subject' => sanitize_text_field((string) ($template['subject'] ?? '')), 'content' => wp_kses_post((string) ($template['content'] ?? '')), 'to' => $this->sanitizePicker((array) ($template['to'] ?? [])), 'cc_enabled' => !array_key_exists('cc_enabled', $template) || !empty($template['cc_enabled']) ? 1 : 0, 'cc' => $this->sanitizePicker((array) ($template['cc'] ?? [])), 'bcc_enabled' => !array_key_exists('bcc_enabled', $template) || !empty($template['bcc_enabled']) ? 1 : 0, 'bcc' => $this->sanitizePicker((array) ($template['bcc'] ?? [])), 'attach_ical' => !empty($template['attach_ical']) ? 1 : 0, 'attachments' => $this->sanitizeAttachments((array) ($template['attachments'] ?? [])), 'collections' => $this->sanitizeCollections((array) ($template['collections'] ?? [])), ]; } return $sanitized; } /** * @param mixed[] $picker * @return mixed[] */ private function sanitizePicker(array $picker): array { return [ 'users' => isset($picker['users']) && is_array($picker['users']) ? array_values(array_filter(array_map('absint', $picker['users']))) : [], 'roles' => isset($picker['roles']) && is_array($picker['roles']) ? array_values(array_filter(array_map('sanitize_key', $picker['roles']))) : [], 'emails' => isset($picker['emails']) ? sanitize_textarea_field((string) $picker['emails']) : '', ]; } /** * @param mixed[] $attachments * @return int[] */ private function sanitizeAttachments(array $attachments): array { return array_values(array_filter(array_map('absint', $attachments))); } /** * @param mixed[] $collections * @return string[] */ private function sanitizeCollections(array $collections): array { $sanitized = []; foreach (Registry::collectionKeys() as $key) { $sanitized[$key] = wp_kses_post($this->decodeEntities((string) ($collections[$key] ?? ''))); } return $sanitized; } /** * @param mixed[] $schedule * @return array{value: int, unit: string} */ private function sanitizeReminderSchedule(array $schedule): array { $unit = isset($schedule['unit']) ? sanitize_key((string) $schedule['unit']) : 'hours'; if (!in_array($unit, ['minutes', 'hours', 'days'], true)) $unit = 'hours'; return [ 'value' => max(1, absint($schedule['value'] ?? 24)), 'unit' => $unit, ]; } private function decodeEntities(string $value): string { for ($i = 0; $i < 5; $i++) { $decoded = html_entity_decode($value, ENT_QUOTES, get_bloginfo('charset') ?: 'UTF-8'); if ($decoded === $value) break; $value = $decoded; } return $value; } /** * @return string[] */ private function triggerFilterOptions(): array { $options = []; foreach (Registry::triggerOptions() as $trigger => $label) { $options[$trigger] = sprintf( '%s (%d)', (string) $label, $this->countByMeta('daytidy_notification_trigger', (string) $trigger) ); } return $options; } /** * @return string[] */ private function typeFilterOptions(): array { $options = []; foreach (Registry::types() as $type => $details) { $options[$type] = sprintf( '%s (%d)', (string) $details['label'], $this->countByMeta('daytidy_notification_type', (string) $type) ); } return $options; } private function countByMeta(string $key, string $value): int { $posts = get_posts([ 'post_type' => $this->PT, 'post_status' => ['publish', 'future', 'draft', 'pending', 'private'], 'posts_per_page' => -1, 'fields' => 'ids', 'meta_query' => [ [ 'key' => $key, 'value' => $value, ], ], ]); return count($posts); } }