'edit' ); if ( $rule_id > 0 ) { $args['rule'] = $rule_id; } return add_query_arg( $args, Menu::tab_url( 'rules' ) ); } /** * Render the tab: the editor when asked for, otherwise the list. * * @return void */ public static function render(): void { self::enqueue_script(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only screen switch. $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; $rule_id = isset( $_GET['rule'] ) ? absint( wp_unslash( $_GET['rule'] ) ) : 0; // phpcs:enable if ( 'edit' === $action ) { self::render_editor( $rule_id ); return; } $smsportal_rules = Rules::all(); $smsportal_events = self::events_by_id(); include SMSPORTAL_DIR . 'includes/Admin/views/rules-list.php'; } /** * Render the editor of one rule. * * @param int $rule_id Rule ID, 0 for a new rule. * @return void */ private static function render_editor( int $rule_id ): void { $rule = $rule_id > 0 ? Rules::get( $rule_id ) : null; if ( $rule_id > 0 && null === $rule ) { echo '

' . esc_html__( 'This rule does not exist.', '85pl-android-sms-api' ) . '

'; return; } $draft = self::take_draft( $rule_id ); $base = null === $rule ? self::new_rule() : $rule; $smsportal_rule = null === $draft ? $base : array_merge( $base, $draft ); $smsportal_events = self::editor_events(); $smsportal_groups = self::groups( $smsportal_events ); $smsportal_event = $smsportal_events[ $smsportal_rule['event_id'] ] ?? reset( $smsportal_events ); $smsportal_event = false === $smsportal_event ? null : $smsportal_event; $smsportal_open_group = null === $smsportal_event ? (string) array_key_first( $smsportal_groups ) : sanitize_title( '' === $smsportal_event['group'] ? __( 'Other', '85pl-android-sms-api' ) : $smsportal_event['group'] ); $smsportal_preview = null === $smsportal_event ? '' : self::preview( $smsportal_rule['template'], $smsportal_event ); // A saved rule shows its event in one line; the tiles open on "Change". $smsportal_collapsed = (int) $smsportal_rule['id'] > 0 && null !== $smsportal_event && $smsportal_event['id'] === $smsportal_rule['event_id']; wp_add_inline_script( self::SCRIPT, 'window.smsportalRuleEditor = ' . wp_json_encode( array( 'events' => $smsportal_events, 'i18n' => self::script_strings(), ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' ); include SMSPORTAL_DIR . 'includes/Admin/views/rule-edit.php'; } /** * Load the editor script (counter, variables, preview, delete confirmation). * * @return void */ private static function enqueue_script(): void { wp_enqueue_script( self::SCRIPT, SMSPORTAL_URL_BASE . 'assets/rule-editor.js', array(), SMSPORTAL_VERSION, true ); } /** * Fields of a new rule. * * @return array */ private static function new_rule(): array { return array( 'id' => 0, 'event_id' => '', 'recipient_type' => 'customer', 'fixed_phone' => '', 'phone_field' => '', 'template' => '', 'expires_hours' => Rules::DEFAULT_EXPIRES_HOURS, 'hours_before' => Rules::DEFAULT_HOURS_BEFORE, 'enabled' => true, ); } /** * Registered events by ID (for labels in the list). * * @return array> */ private static function events_by_id(): array { $events = array(); foreach ( Events::all() as $event ) { $events[ $event['id'] ] = $event; } return $events; } /** * What the editor needs about each event: label, group, variables, every name they answer to * (Polish and English), extra fields, the phone field a new rule starts with and a sample. The sample * holds only the event's variables, so the page never carries e.g. the customer's phone. * * @return array,names:string[],fields:string[],phone_field:string,sample:array|null}> */ public static function editor_events(): array { $events = array(); foreach ( Events::all() as $event ) { $variables = array(); foreach ( (array) ( $event['variables'] ?? array() ) as $name => $label ) { if ( is_string( $name ) && 1 === preg_match( '/^[A-Za-z0-9_:.\-]+$/', $name ) ) { $variables[ $name ] = is_scalar( $label ) ? (string) $label : $name; } } $events[ $event['id'] ] = array( 'id' => $event['id'], 'label' => (string) ( $event['label'] ?? $event['id'] ), 'group' => (string) ( $event['group'] ?? '' ), 'icon' => is_string( $event['icon'] ?? null ) ? esc_url_raw( $event['icon'] ) : '', 'title' => self::short_label( (string) ( $event['label'] ?? $event['id'] ), (string) ( $event['group'] ?? '' ) ), 'variables' => $variables, 'names' => array_keys( Variables::with_aliases( $variables ) ), 'fields' => array_values( array_intersect( array( 'phone_field', 'hours_before' ), (array) ( $event['rule_fields'] ?? array() ) ) ), 'phone_field' => is_string( $event['phone_field_default'] ?? null ) ? $event['phone_field_default'] : '', 'sample' => self::sample( $event, array_keys( $variables ) ), ); } return $events; } /** * Data of the latest such event, limited to its variables. * * @param array $event Event. * @param string[] $names Variable names. * @return array|null */ private static function sample( array $event, array $names ): ?array { if ( ! isset( $event['sample'] ) || ! is_callable( $event['sample'] ) ) { return null; } try { $context = call_user_func( $event['sample'] ); } catch ( \Throwable $e ) { Events::report( $e, (string) $event['id'] ); return null; } if ( ! is_array( $context ) ) { return null; } $context = Variables::with_aliases( $context ); $sample = array(); foreach ( $names as $name ) { if ( isset( $context[ $name ] ) && is_scalar( $context[ $name ] ) ) { $sample[ $name ] = (string) $context[ $name ]; } } return Variables::with_aliases( $sample ); } /** * Event label without the plugin name in front ("WooCommerce: new order" -> "new order"): the tile * already sits under the plugin's tile. * * @param string $label Label. * @param string $group Plugin name. * @return string */ private static function short_label( string $label, string $group ): string { $prefix = $group . ': '; $short = '' !== $group && 0 === strpos( $label, $prefix ) && strlen( $label ) > strlen( $prefix ) ? substr( $label, strlen( $prefix ) ) : $label; return self::sentence_case( $short ); } /** * First letter upper case ("new order" -> "New order"). * * @param string $text Text. * @return string */ private static function sentence_case( string $text ): string { return mb_strtoupper( mb_substr( $text, 0, 1 ) ) . mb_substr( $text, 1 ); } /** * A plugin's events in blocks for the picker. Titles that differ only by a quoted name * ("Order status changed to "Completed"", "... "Cancelled"") share one heading ("Order status * changed to …") and show just the name; other events stay whole, in their original order. * * @param array> $events Editor events of one plugin. * @return array,text:string}>}> */ private static function sections( array $events ): array { $parsed = array(); $counts = array(); foreach ( $events as $event ) { $pattern = ''; $name = ''; if ( 1 === preg_match( '/^(.*?)\s*["„“](.+)["”]\s*(.*)$/u', (string) $event['title'], $match ) && '' !== trim( $match[1] . $match[3] ) ) { $pattern = self::sentence_case( trim( $match[1] . ' … ' . $match[3] ) ); $name = $match[2]; $counts[ $pattern ] = ( $counts[ $pattern ] ?? 0 ) + 1; } $parsed[] = array( $event, $pattern, $name ); } $blocks = array(); $index = array(); foreach ( $parsed as list( $event, $pattern, $name ) ) { if ( '' !== $pattern && $counts[ $pattern ] > 1 ) { if ( ! isset( $index[ $pattern ] ) ) { $index[ $pattern ] = count( $blocks ); $blocks[] = array( 'heading' => $pattern, 'options' => array(), ); } $blocks[ $index[ $pattern ] ]['options'][] = array( 'event' => $event, 'text' => $name, ); continue; } $last = count( $blocks ) - 1; if ( $last < 0 || '' !== $blocks[ $last ]['heading'] ) { $blocks[] = array( 'heading' => '', 'options' => array(), ); ++$last; } $blocks[ $last ]['options'][] = array( 'event' => $event, 'text' => (string) $event['title'], ); } return $blocks; } /** * Events grouped by plugin, with the plugin's logo (from its first event that has one). Active built-in * integrations without events (no form yet) get an empty group, so their tile still shows. * * @param array> $events Editor events. * @return array>,sections:array>}> By group key. */ private static function groups( array $events ): array { $groups = array(); foreach ( $events as $event ) { $name = '' === $event['group'] ? __( 'Other', '85pl-android-sms-api' ) : $event['group']; $key = sanitize_title( $name ); if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'name' => $name, 'icon' => '', 'events' => array(), ); } if ( '' === $groups[ $key ]['icon'] ) { $groups[ $key ]['icon'] = $event['icon']; } $groups[ $key ]['events'][] = $event; } foreach ( Loader::event_groups() as $group ) { $key = sanitize_title( $group['name'] ); if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'name' => $group['name'], 'icon' => $group['icon'], 'events' => array(), ); } } foreach ( $groups as $key => $group ) { $groups[ $key ]['sections'] = self::sections( $group['events'] ); } return $groups; } /** * Text of the SMS for the latest such event; without one, the variables stay as typed. * * @param string $template Template. * @param array $event Editor event. * @return string */ public static function preview( string $template, array $event ): string { if ( null !== $event['sample'] ) { return Template::render( $template, $event['sample'] ); } $names = array(); foreach ( $event['names'] as $name ) { $names[ $name ] = '{' . $name . '}'; } return Template::render( $template, $names ); } /** * Translated strings for the editor script. * * @return array */ private static function script_strings(): array { return array( /* translators: 1: number of characters, 2: number of SMS parts. */ 'counter' => __( 'Characters: %1$d · SMS: %2$d', '85pl-android-sms-api' ), /* translators: %d: characters per SMS part. */ 'perSms' => __( '%d characters per SMS.', '85pl-android-sms-api' ), 'gsm' => __( 'No Polish letters, so 160 characters fit in one SMS. One Polish letter makes it 70.', '85pl-android-sms-api' ), /* translators: %s: characters outside the basic SMS alphabet, e.g. "ą, ł". */ 'ucs' => __( 'Letters outside the basic SMS alphabet (%s) make it 70 characters per SMS instead of 160.', '85pl-android-sms-api' ), 'previewSample' => __( 'Preview with the data of the latest such event', '85pl-android-sms-api' ), 'previewNoData' => __( 'No such event yet, so the preview shows the variable names.', '85pl-android-sms-api' ), 'previewEmpty' => __( 'Write the text of the SMS and it shows here.', '85pl-android-sms-api' ), 'noVariables' => __( 'This event has no variables.', '85pl-android-sms-api' ), 'chooseField' => __( '— choose a field —', '85pl-android-sms-api' ), /* translators: %s: variable name. */ 'insertVariable' => __( 'Insert %s', '85pl-android-sms-api' ), ); } /** * Save form handler (admin-post). * * @return void */ public static function handle_save(): void { if ( ! current_user_can( Menu::CAPABILITY ) ) { Guard::deny(); } check_admin_referer( self::SAVE_ACTION ); Guard::redirect( self::process_save( wp_unslash( $_POST ) ) ); } /** * Validate the posted rule, then save it, or with do=test only send its text to the owner. * * @param array $post Unslashed form fields. * @return string Where to go next. */ public static function process_save( array $post ): string { $rule_id = isset( $post['rule_id'] ) && is_scalar( $post['rule_id'] ) ? absint( $post['rule_id'] ) : 0; $data = self::posted_rule( $post ); $event = Events::get( $data['event_id'] ); $problem = self::problem( $data, $event ); if ( null !== $problem ) { return self::fail( $problem, $data, $rule_id ); } if ( 'test' === ( $post['do'] ?? '' ) ) { // A test changes nothing: the form comes back with what was typed. self::send_test( $data['template'], $data['event_id'] ); self::keep_draft( $data, $rule_id ); return self::edit_url( $rule_id ); } if ( 'fixed' === $data['recipient_type'] ) { $data['fixed_phone'] = (string) Phone::normalize( $data['fixed_phone'], Phone::default_country_code() ); } $saved = Rules::save( $data, $rule_id ); if ( is_wp_error( $saved ) ) { return self::fail( $saved->get_error_message(), $data, $rule_id ); } Notice::add( 'success', __( 'Rule saved.', '85pl-android-sms-api' ) ); return self::edit_url( $saved ); } /** * Rule fields from the form. * * @param array $post Unslashed form fields. * @return array */ private static function posted_rule( array $post ): array { $data = array(); foreach ( self::FIELDS as $field ) { $data[ $field ] = isset( $post[ $field ] ) && is_scalar( $post[ $field ] ) ? (string) $post[ $field ] : ''; } $data['event_id'] = sanitize_text_field( $data['event_id'] ); $data['recipient_type'] = 'fixed' === $data['recipient_type'] ? 'fixed' : 'customer'; $data['fixed_phone'] = sanitize_text_field( $data['fixed_phone'] ); $data['phone_field'] = sanitize_text_field( $data['phone_field'] ); $data['template'] = Rules::clean_template( $data['template'] ); $data['enabled'] = ! empty( $post['enabled'] ); return $data; } /** * What is wrong with a posted rule, or null. * * @param array $data Posted rule. * @param array|null $event Its event. * @return string|null */ private static function problem( array $data, ?array $event ): ?string { if ( null === $event ) { return __( 'Choose an event.', '85pl-android-sms-api' ); } if ( '' === trim( $data['template'] ) ) { return __( 'Write the text of the SMS.', '85pl-android-sms-api' ); } if ( 'fixed' === $data['recipient_type'] && null === Phone::normalize( $data['fixed_phone'], Phone::default_country_code() ) ) { return __( 'The fixed number is not a valid phone number.', '85pl-android-sms-api' ); } $fields = (array) ( $event['rule_fields'] ?? array() ); if ( 'customer' === $data['recipient_type'] && in_array( 'phone_field', $fields, true ) && '' === $data['phone_field'] && '' === (string) ( $event['phone_field_default'] ?? '' ) ) { return __( 'Choose the form field with the customer\'s phone number.', '85pl-android-sms-api' ); } return null; } /** * Report a failed save and keep what was typed for the editor. * * @param string $message Error. * @param array $data Posted rule. * @param int $rule_id Rule ID. * @return string Editor URL. */ private static function fail( string $message, array $data, int $rule_id ): string { Notice::add( 'error', $message ); self::keep_draft( $data, $rule_id ); return self::edit_url( $rule_id ); } /** * Keep what was typed so the editor shows it once more. * * @param array $data Posted rule. * @param int $rule_id Rule ID. * @return void */ private static function keep_draft( array $data, int $rule_id ): void { set_transient( self::DRAFT_PREFIX . get_current_user_id(), array_merge( $data, array( 'id' => $rule_id ) ), self::DRAFT_TTL ); } /** * What was typed before a failed save of this rule (read once). * * @param int $rule_id Rule ID. * @return array|null */ private static function take_draft( int $rule_id ): ?array { $key = self::DRAFT_PREFIX . get_current_user_id(); $draft = get_transient( $key ); if ( ! is_array( $draft ) || (int) ( $draft['id'] ?? -1 ) !== $rule_id ) { return null; } delete_transient( $key ); $fields = array_merge( self::FIELDS, array( 'enabled' ) ); return array_intersect_key( $draft, array_flip( $fields ) ); } /** * Send the rule's text, filled with the latest such event, to the owner. Saves nothing. * * @param string $template Template. * @param string $event_id Event ID. * @return void */ private static function send_test( string $template, string $event_id ): void { $owner = SettingsPage::owner_phone(); if ( '' === $owner ) { Notice::add( 'error', __( 'To get a test SMS, enter your phone number in Settings. The rule was not saved.', '85pl-android-sms-api' ) ); return; } $events = self::editor_events(); $event = $events[ $event_id ]; $result = smsportal_send_sms( $owner, self::preview( $template, $event ), array( /* translators: %s: event label. */ 'event_label' => sprintf( __( 'Test: %s', '85pl-android-sms-api' ), $event['label'] ), 'expires_hours' => self::TEST_EXPIRES_HOURS, ) ); if ( is_wp_error( $result ) ) { /* translators: %s: error message. */ Notice::add( 'error', sprintf( __( 'The test SMS was not queued: %s', '85pl-android-sms-api' ), $result->get_error_message() ) ); return; } /* translators: %s: masked phone number. */ Notice::add( 'success', sprintf( __( 'Test SMS queued for %s. The rule was not saved: click Save when it is right.', '85pl-android-sms-api' ), Phone::mask( $owner ) ) ); } /** * Delete form handler (admin-post). * * @return void */ public static function handle_delete(): void { if ( ! current_user_can( Menu::CAPABILITY ) ) { Guard::deny(); } check_admin_referer( self::DELETE_ACTION ); Guard::redirect( self::process_delete( wp_unslash( $_POST ) ) ); } /** * Delete the posted rule. * * @param array $post Unslashed form fields. * @return string Where to go next. */ public static function process_delete( array $post ): string { $rule_id = isset( $post['rule'] ) && is_scalar( $post['rule'] ) ? absint( $post['rule'] ) : 0; if ( Rules::delete( $rule_id ) ) { Notice::add( 'success', __( 'Rule deleted.', '85pl-android-sms-api' ) ); } else { Notice::add( 'error', __( 'This rule does not exist.', '85pl-android-sms-api' ) ); } return Menu::tab_url( 'rules' ); } }