'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' );
}
}