variables = $variables; $this->types = $types; $this->rules = $rules; $this->locales = $locales; $this->categories = $categories ? $categories : new CategoryRepository(); $this->conversion = $conversion ? $conversion : new ContentConversionService(); } public function register() { add_action( 'rest_api_init', array( $this, 'routes' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor' ) ); // Fires wherever WordPress's TinyMCE editor loads: Classic Editor and builders that reuse it. add_action( 'wp_enqueue_editor', array( $this, 'enqueue_dialog' ) ); add_action( 'elementor/editor/after_enqueue_scripts', array( $this, 'enqueue_elementor' ) ); add_action( 'fl_builder_ui_enqueue_scripts', array( $this, 'enqueue_dialog' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_for_frontend_builders' ) ); // Elementor removes every priority-10 TinyMCE button and plugin filter, and Beaver // Builder keeps only allowlisted plugins at priority 9999, so register later than both. add_filter( 'mce_external_plugins', array( $this, 'tinymce_plugin' ), 10000 ); add_filter( 'mce_buttons', array( $this, 'tinymce_button' ), 20 ); } public function enqueue_dialog() { if ( ! current_user_can( 'edit_posts' ) || wp_script_is( 'glovario-make-variable', 'enqueued' ) ) { return; } wp_enqueue_style( 'glovario-make-variable', GLOVARIO_URL . 'assets/make-variable.css', array(), GLOVARIO_VERSION ); wp_enqueue_script( 'glovario-make-variable', GLOVARIO_URL . 'assets/make-variable.js', array(), GLOVARIO_VERSION, true ); wp_localize_script( 'glovario-make-variable', 'glovarioMakeVariableSettings', $this->script_settings() ); /** * Fires after the shared Make variable dialog was enqueued, so extensions can enqueue * scripts that depend on `glovario-make-variable` and call `glovarioMakeVariable.registerSection()`. */ do_action( 'glovario_selection_dialog_enqueued' ); } /** * Elementor: the shared dialog for its Text Editor widget (TinyMCE) plus the * toolbar buttons for Elementor 4's atomic Paragraph, Heading, and Button. */ public function enqueue_elementor() { if ( ! current_user_can( 'edit_posts' ) ) { return; } $this->enqueue_dialog(); wp_enqueue_script( 'glovario-make-variable-elementor', GLOVARIO_URL . 'assets/make-variable-elementor.js', array( 'glovario-make-variable' ), GLOVARIO_VERSION, true ); } /** * Front-end builders (Beaver Builder) render their TinyMCE after scripts are queued. */ public function enqueue_for_frontend_builders() { if ( class_exists( 'FLBuilderModel' ) && \FLBuilderModel::is_builder_active() ) { $this->enqueue_dialog(); } } public function enqueue_block_editor() { if ( ! current_user_can( 'edit_posts' ) ) { return; } $this->enqueue_dialog(); wp_enqueue_script( 'glovario-make-variable-editor', GLOVARIO_URL . 'assets/make-variable-editor.js', array( 'glovario-make-variable', 'wp-rich-text', 'wp-block-editor', 'wp-components', 'wp-element' ), GLOVARIO_VERSION, true ); } public function tinymce_plugin( $plugins ) { if ( current_user_can( 'edit_posts' ) ) { $plugins['glovariomakevariable'] = GLOVARIO_URL . 'assets/make-variable-tinymce.js?ver=' . rawurlencode( GLOVARIO_VERSION ); } return $plugins; } public function tinymce_button( $buttons ) { if ( current_user_can( 'edit_posts' ) && ! in_array( 'glovarioinsertvariable', (array) $buttons, true ) ) { // Unregistered button names are ignored by TinyMCE, so "Make variable" only shows for administrators. $buttons[] = 'glovariomakevariable'; $buttons[] = 'glovarioinsertvariable'; } return $buttons; } public function routes() { register_rest_route( self::ROUTE_NAMESPACE, self::ROUTE, array( 'methods' => 'POST', 'callback' => array( $this, 'create' ), 'permission_callback' => static function () { return current_user_can( 'manage_options' ); }, 'args' => array( 'label' => array( 'type' => 'string', 'required' => true, ), 'value' => array( 'type' => 'string', 'required' => true, ), 'key' => array( 'type' => 'string', 'default' => '', ), 'type' => array( 'type' => 'string', 'default' => 'text', 'enum' => self::TYPES, ), 'description' => array( 'type' => 'string', 'default' => '', ), 'translation' => array( 'type' => 'string', 'default' => '', ), 'category_id' => array( 'type' => 'integer', 'default' => 0, ), 'schedule' => array( 'type' => 'object', 'default' => array(), ), ), ) ); $admin = static function () { return current_user_can( 'manage_options' ); }; register_rest_route( self::ROUTE_NAMESPACE, '/variables/(?P\d+)/occurrences', array( 'methods' => 'GET', 'callback' => array( $this, 'occurrences' ), 'permission_callback' => $admin, 'args' => array( 'current_post' => array( 'description' => 'The post being edited, left out of the list.', 'type' => 'integer', 'default' => 0, ), ), ) ); register_rest_route( self::ROUTE_NAMESPACE, '/variables/(?P\d+)/replace', array( 'methods' => 'POST', 'callback' => array( $this, 'replace' ), 'permission_callback' => $admin, 'args' => array( 'post_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'required' => true, ), 'current_post' => array( 'description' => 'The post being edited, never rewritten here.', 'type' => 'integer', 'default' => 0, ), 'selection' => array( 'type' => 'object', 'default' => array(), ), ), ) ); } /** * Create a variable (and optional per-language values) from selected text. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function create( $request ) { $label = sanitize_text_field( (string) $request->get_param( 'label' ) ); $raw = (string) $request->get_param( 'value' ); $type = sanitize_key( (string) $request->get_param( 'type' ) ); if ( '' === $label || '' === trim( $raw ) ) { return new \WP_Error( 'glovario_missing_fields', __( 'Enter a label and select some text.', 'glovario' ), array( 'status' => 400 ) ); } if ( ! in_array( $type, self::TYPES, true ) || ! $this->types->exists( $type ) ) { return new \WP_Error( 'glovario_invalid_type', __( 'Choose a valid variable type.', 'glovario' ), array( 'status' => 400 ) ); } $requested = strtolower( trim( (string) $request->get_param( 'key' ) ) ); if ( '' !== $requested ) { if ( ! preg_match( '/^[a-z0-9_-]+$/', $requested ) ) { return new \WP_Error( 'glovario_invalid_key', __( 'Use only lowercase letters, numbers, hyphens, or underscores in the key.', 'glovario' ), array( 'status' => 400 ) ); } if ( $this->key_taken( $requested ) ) { return new \WP_Error( 'glovario_duplicate_key', __( 'A variable with this key already exists.', 'glovario' ), array( 'status' => 409 ) ); } $key = $requested; } else { $key = $this->unique_key( $label ); } // The value in the second language, when one is configured. $second = $this->locales->second_locale(); $translation = (string) $request->get_param( 'translation' ); if ( '' !== trim( $translation ) && '' === $second ) { return new \WP_Error( 'glovario_no_second_locale', __( 'Choose a second language under Locale Settings first.', 'glovario' ), array( 'status' => 400 ) ); } $translation = '' === trim( $translation ) ? null : array( 'locale' => $second, 'value' => $translation, ); $category_id = absint( $request->get_param( 'category_id' ) ); if ( $category_id && ! $this->categories->find( $category_id ) ) { return new \WP_Error( 'glovario_invalid_category', __( 'Choose an existing category.', 'glovario' ), array( 'status' => 400 ) ); } $schedule = $this->schedule_from_request( $request->get_param( 'schedule' ) ); if ( is_wp_error( $schedule ) ) { $schedule->add_data( array( 'status' => 400 ) ); return $schedule; } foreach ( array_filter( array( $raw, $translation ? $translation['value'] : null, $schedule ? $schedule['value'] : null ), 'is_string' ) as $value ) { $valid = $this->types->validate( $value, $type ); if ( is_wp_error( $valid ) ) { $valid->add_data( array( 'status' => 400 ) ); return $valid; } } $payload = array( 'variable' => array( 'variable_key' => $key, 'label' => $label, 'type' => $type, 'value' => $this->types->sanitize( $raw, $type ), 'description' => sanitize_textarea_field( (string) $request->get_param( 'description' ) ), 'category_id' => $category_id, ), 'translation' => $translation, 'schedule' => $schedule, ); /** * Filters a validated "Make variable" request before anything is saved. * * Extensions read their own request parameters (for example the fields of a * dialog section they registered) and may change the payload or return a * WP_Error to reject the request. * * @param array $payload Variable fields, the second-language value, and the schedule. * @param \WP_REST_Request $request Original request. */ $payload = apply_filters( 'glovario_selection_variable_payload', $payload, $request ); if ( is_wp_error( $payload ) ) { $payload->add_data( array( 'status' => 400 ) ); return $payload; } /** * Short-circuits storing the variable as a site variable, for example to create a * network variable instead. Return the created variable array (language and scheduled * values are then not stored) or a WP_Error. * * @param array|\WP_Error|null $variable Null to let Glovario insert a site variable. * @param array $payload Validated payload. * @param \WP_REST_Request $request Original request. */ $stored = apply_filters( 'glovario_selection_variable_pre_insert', null, $payload, $request ); if ( null !== $stored ) { if ( is_wp_error( $stored ) ) { $stored->add_data( array( 'status' => 400 ) ); return $stored; } $variable = $stored; $payload['translation'] = null; $payload['schedule'] = null; } else { $variable = $this->variables->insert( $payload['variable'] ); } if ( is_wp_error( $variable ) ) { $variable->add_data( array( 'status' => 400 ) ); return $variable; } $rules = array(); if ( ! empty( $payload['translation'] ) ) { $rules[] = self::language_rule( $payload['translation']['locale'], $payload['translation']['value'] ); } if ( ! empty( $payload['schedule'] ) ) { // Above the language values, so the scheduled value wins in every language while active. $rules[] = array( 'name' => __( 'Scheduled value', 'glovario' ), 'alternative_value' => $payload['schedule']['value'], 'priority' => 20, 'conditions' => array( 'version' => 1, 'date' => array( 'from' => $payload['schedule']['from'], 'until' => $payload['schedule']['until'], ), ), ); } if ( $rules ) { do_action( 'glovario_before_rule_change', $variable ); foreach ( $rules as $rule ) { $rule['variable_id'] = $variable['id']; $rule['alternative_value'] = $this->types->sanitize( $rule['alternative_value'], $type ); $rule['enabled'] = 1; $this->rules->save( $rule ); } do_action( 'glovario_after_rule_change', $variable['id'], 'rule_created' ); } /** * Fires after "Make variable" created a variable and its language and scheduled values. * * @param array $variable Created variable. * @param \WP_REST_Request $request Original request. * @param array $payload Payload that was saved. */ do_action( 'glovario_selection_variable_created', $variable, $request, $payload ); $response = array( 'id' => (int) $variable['id'], 'key' => $variable['variable_key'], 'label' => $variable['label'], 'shortcode' => '[glvr key="' . $variable['variable_key'] . '"]', 'token' => '{{glvr:' . $variable['variable_key'] . '}}', 'edit_url' => admin_url( 'admin.php?page=glovario&action=edit&id=' . absint( $variable['id'] ) ), 'conversion_url' => admin_url( 'admin.php?page=glovario-content-conversion&variable_id=' . absint( $variable['id'] ) ), 'occurrences_url' => self::rest_url( self::ROUTE_NAMESPACE . '/variables/' . absint( $variable['id'] ) . '/occurrences' ), 'replace_url' => self::rest_url( self::ROUTE_NAMESPACE . '/variables/' . absint( $variable['id'] ) . '/replace' ), ); /** * Filters the "Make variable" response sent back to the editor dialog. * * @param array $response Response data. * @param array $variable Created variable. * @param \WP_REST_Request $request Original request. */ return new \WP_REST_Response( apply_filters( 'glovario_selection_variable_response', $response, $variable, $request ), 201 ); } /** * A language-specific value as a rule (shared with extensions that add languages). * * @param string $locale Locale code. * @param string $value Value in that language. * @return array Rule fields without the variable ID. */ public static function language_rule( $locale, $value ) { return array( /* translators: %s: locale code. */ 'name' => sprintf( __( 'Language: %s', 'glovario' ), $locale ), 'alternative_value' => $value, 'priority' => 10, 'conditions' => array( 'version' => 1, 'locales' => array( $locale ), ), ); } /** * Validate the optional scheduled value: a value shown between two dates. * * @param mixed $schedule Request parameter. * @return array|null|\WP_Error Schedule, null when none was requested, or an error. */ private function schedule_from_request( $schedule ) { $schedule = is_array( $schedule ) ? $schedule : array(); $value = isset( $schedule['value'] ) && is_scalar( $schedule['value'] ) ? (string) $schedule['value'] : ''; $from = isset( $schedule['from'] ) && is_scalar( $schedule['from'] ) ? sanitize_text_field( (string) $schedule['from'] ) : ''; $until = isset( $schedule['until'] ) && is_scalar( $schedule['until'] ) ? sanitize_text_field( (string) $schedule['until'] ) : ''; if ( '' === trim( $value ) && '' === $from && '' === $until ) { return null; } if ( '' === trim( $value ) || ( '' === $from && '' === $until ) ) { return new \WP_Error( 'glovario_invalid_schedule', __( 'A scheduled value needs a value and at least one date.', 'glovario' ) ); } foreach ( array_filter( array( $from, $until ) ) as $date ) { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $date ) ) { return new \WP_Error( 'invalid_date', __( 'Enter valid condition dates.', 'glovario' ) ); } } if ( '' !== $from && '' !== $until && strcmp( $from, $until ) >= 0 ) { return new \WP_Error( 'glovario_invalid_schedule', __( 'The end of the scheduled period must come after its start.', 'glovario' ) ); } return array( 'value' => $value, 'from' => $from, 'until' => $until, ); } /** * Other content that still contains a variable's text as plain text. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function occurrences( $request ) { $variable = $this->variables->find( absint( $request['id'] ) ); if ( ! $variable ) { return new \WP_Error( 'glovario_not_found', __( 'Variable not found.', 'glovario' ), array( 'status' => 404 ) ); } $exclude = absint( $request->get_param( 'current_post' ) ); $found = $this->conversion->discover( $variable ); $items = array(); $review = 0; $occurrences = 0; foreach ( $found['items'] as $item ) { // The post open in the editor is saved by the editor itself; changing it here would be overwritten. if ( $item['id'] === $exclude ) { continue; } // Builder pages keep their visible content outside post_content; list them for review only. $builder = 'builder' === get_post_meta( $item['id'], '_elementor_edit_mode', true ) || get_post_meta( $item['id'], '_fl_builder_enabled', true ); if ( $builder ) { ++$review; continue; } if ( $item['can_edit'] && $item['occurrences'] > 0 ) { $content = (string) get_post_field( 'post_content', $item['id'] ); $items[] = array( 'id' => $item['id'], 'title' => $item['title'], 'post_type' => $item['post_type'], 'occurrences' => $item['occurrences'], // Each match with its surrounding text, so the administrator chooses which ones to replace. 'matches' => $this->conversion->matches( $content, (string) $variable['value'] ), 'hash' => ContentConversionService::content_hash( $content ), ); $occurrences += $item['occurrences']; } if ( $item['unsupported'] > 0 || ! $item['can_edit'] ) { ++$review; } } return new \WP_REST_Response( array( 'items' => $items, 'occurrences' => $occurrences, 'review' => $review, 'truncated' => $found['truncated'], 'conversion_url' => admin_url( 'admin.php?page=glovario-content-conversion&variable_id=' . absint( $variable['id'] ) ), ) ); } /** * Replace a variable's exact text with its token in the confirmed content items. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function replace( $request ) { $variable = $this->variables->find( absint( $request['id'] ) ); if ( ! $variable ) { return new \WP_Error( 'glovario_not_found', __( 'Variable not found.', 'glovario' ), array( 'status' => 404 ) ); } $exclude = absint( $request->get_param( 'current_post' ) ); // Same limit as the Content Conversion screen, so one request cannot rewrite unbounded content. $post_ids = array_filter( array_slice( array_map( 'absint', (array) $request->get_param( 'post_ids' ) ), 0, 500 ), static function ( $post_id ) use ( $exclude ) { return $post_id && $post_id !== $exclude && 'builder' !== get_post_meta( $post_id, '_elementor_edit_mode', true ) && ! get_post_meta( $post_id, '_fl_builder_enabled', true ); } ); // Optional choice of matches per content item (from the preview); items without one convert fully. $selection = array(); foreach ( (array) $request->get_param( 'selection' ) as $post_id => $choice ) { if ( is_array( $choice ) && in_array( absint( $post_id ), $post_ids, true ) ) { $selection[ absint( $post_id ) ] = array( 'matches' => array_map( 'absint', (array) ( $choice['matches'] ?? array() ) ), 'hash' => sanitize_text_field( (string) ( $choice['hash'] ?? '' ) ), ); } } return new \WP_REST_Response( $this->conversion->convert( $variable, $post_ids, $selection ) ); } /** * Settings shared by every editor integration. * * @return array */ public function script_settings() { $second = $this->locales->second_locale(); $types = array(); foreach ( self::TYPES as $type ) { $definition = $this->types->all()[ $type ] ?? array(); $types[] = array( 'value' => $type, 'label' => $definition['label'] ?? $type, ); } $categories = array(); if ( current_user_can( 'manage_options' ) ) { foreach ( $this->categories->all() as $category ) { $categories[] = array( 'id' => (int) $category['id'], 'name' => $category['name'], ); } } $data = array( 'canCreate' => current_user_can( 'manage_options' ), 'canInsert' => current_user_can( 'edit_posts' ), 'restUrl' => self::rest_url( self::ROUTE_NAMESPACE . self::ROUTE ), 'listUrl' => self::rest_url( self::ROUTE_NAMESPACE . '/variables' ), 'previewUrl' => self::rest_url( self::ROUTE_NAMESPACE . '/preview' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'types' => $types, 'categories' => $categories, 'secondLocale' => $second, 'secondLocaleLabel' => '' === $second ? '' : ( new LocaleCatalog() )->label( $second ), 'i18n' => array( 'button' => __( 'Make variable', 'glovario' ), 'title' => __( 'Make a Site Variable', 'glovario' ), 'intro' => __( 'The selected text becomes a reusable variable and is replaced here with a reference to it (shortcode or token). Edit the variable once to update it everywhere.', 'glovario' ), 'label' => __( 'Label', 'glovario' ), 'key' => __( 'Key (optional)', 'glovario' ), 'keyHelp' => __( 'Leave empty to generate one from the label.', 'glovario' ), 'type' => __( 'Type', 'glovario' ), 'value' => __( 'Value', 'glovario' ), /* translators: %s: locale code. */ 'translation' => __( 'Value in %s (optional)', 'glovario' ), 'create' => __( 'Create and replace', 'glovario' ), 'cancel' => __( 'Cancel', 'glovario' ), 'creating' => __( 'Creating…', 'glovario' ), 'noSelection' => __( 'Select the text you want to turn into a variable first.', 'glovario' ), /* translators: %s: variable shortcode. */ 'created' => __( 'Variable created and inserted as %s.', 'glovario' ), 'findOthers' => __( 'Find this text elsewhere on the site', 'glovario' ), 'failed' => __( 'The variable could not be created.', 'glovario' ), 'unexpected' => __( 'The server returned an unexpected response. Reload the page and try again.', 'glovario' ), 'insertButton' => __( 'Insert variable', 'glovario' ), 'insertTitle' => __( 'Insert a Site Variable', 'glovario' ), 'search' => __( 'Search variables', 'glovario' ), 'loading' => __( 'Loading…', 'glovario' ), 'noResults' => __( 'No variables found.', 'glovario' ), 'category' => __( 'Category', 'glovario' ), 'noCategory' => __( 'No category', 'glovario' ), 'insertAs' => __( 'Insert as', 'glovario' ), 'asShortcode' => __( 'Shortcode', 'glovario' ), 'asToken' => __( 'Token', 'glovario' ), 'insertAsHelp' => __( 'Tokens are shorter and work in post content; shortcodes also work in widgets and page builders.', 'glovario' ), 'schedule' => __( 'Show a different value for a period (optional)', 'glovario' ), 'scheduleValue' => __( 'Value during the period', 'glovario' ), 'activeFrom' => __( 'Active from', 'glovario' ), 'activeUntil' => __( 'Active until', 'glovario' ), 'scheduleHelp' => __( 'Dates use the site time zone. Outside the period, the value above is shown.', 'glovario' ), 'searching' => __( 'Looking for the same text in other content…', 'glovario' ), 'foundElsewhere' => __( 'The same text appears in other content. Choose which places to replace (occurrences in brackets):', 'glovario' ), /* translators: %s: number of selected places. */ 'replaceSelected' => __( 'Replace selected (%s)', 'glovario' ), /* translators: %s: number of listed places. */ 'moreMatches' => __( 'Only the first %s places are listed; the others are not replaced.', 'glovario' ), 'replaceAll' => __( 'Replace everywhere now', 'glovario' ), 'replaceConfirm' => __( 'Replace the text with the variable in these content items? Each change is saved as a new revision where the post type keeps revisions.', 'glovario' ), 'replacing' => __( 'Replacing…', 'glovario' ), /* translators: 1: number of replacements, 2: number of content items. */ 'replaced' => __( 'Done. Replacements: %1$s. Content items updated: %2$s.', 'glovario' ), /* translators: %s: number of content items. */ 'replaceFailed' => __( 'Content items that could not be changed: %s.', 'glovario' ), /* translators: %s: number of content items. */ 'needsReview' => __( 'Content items to review manually (page builders, unsupported blocks, or no permission): %s.', 'glovario' ), 'reviewLink' => __( 'Review on the Content Conversion screen', 'glovario' ), 'limitReached' => __( 'The candidate limit was reached. Convert reviewed results, then scan again for additional content.', 'glovario' ), 'notElsewhere' => __( 'This text does not appear in other content.', 'glovario' ), 'done' => __( 'Done', 'glovario' ), ), ); /** * Filters the settings shared by every "Make variable" and "Insert variable" integration. * * @param array $data Script settings. */ return apply_filters( 'glovario_selection_variable_settings', $data ); } private function key_taken( $key ) { return VariableKey::taken( $key, $this->variables ); } private function unique_key( $label ) { return VariableKey::from_label( $label, $this->variables ); } /** * REST URL that answers in the user's admin language (messages, category * names), like the block editor's own requests. * * @param string $path Route. * @return string */ private static function rest_url( $path ) { return add_query_arg( '_locale', 'user', rest_url( $path ) ); } }