From e3a8bbff47ff2054387656b27bd6b757756ccd4f Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 22:36:03 +0200 Subject: [PATCH 01/11] feat(settings): add Quick Create option Add activate_quick_create checkbox to the Advanced Settings section, allowing users to enable silent translation creation without leaving the current post. --- includes/MslsAdmin.php | 6 ++++++ includes/MslsOptions.php | 1 + 2 files changed, 7 insertions(+) diff --git a/includes/MslsAdmin.php b/includes/MslsAdmin.php index 17ffa52e6..2325db874 100644 --- a/includes/MslsAdmin.php +++ b/includes/MslsAdmin.php @@ -16,6 +16,7 @@ * Administration of the options * * @method void activate_autocomplete() + * @method void activate_quick_create() * @method void sort_by_description() * @method void exclude_current_blog() * @method void only_with_translation() @@ -113,6 +114,10 @@ public function __call( $method, $args ) { 'Activate the content import functionality', 'multisite-language-switcher' ), + 'activate_quick_create' => __( + 'Create translations without leaving the current post', + 'multisite-language-switcher' + ), 'sort_by_description' => __( 'Sort languages by description', 'multisite-language-switcher' ), 'exclude_current_blog' => __( 'Exclude this blog from output', 'multisite-language-switcher' ), 'only_with_translation' => __( 'Show only links with a translation', 'multisite-language-switcher' ), @@ -295,6 +300,7 @@ public function advanced_section(): int { 'reference_user' => esc_html__( 'Reference user', 'multisite-language-switcher' ), 'exclude_current_blog' => esc_html__( 'Exclude blog', 'multisite-language-switcher' ), 'activate_content_import' => esc_html__( 'Content import', 'multisite-language-switcher' ), + 'activate_quick_create' => esc_html__( 'Quick Create', 'multisite-language-switcher' ), ); return $this->add_settings_fields( $map, 'advanced_section' ); diff --git a/includes/MslsOptions.php b/includes/MslsOptions.php index ba0727b5b..286b0956f 100644 --- a/includes/MslsOptions.php +++ b/includes/MslsOptions.php @@ -14,6 +14,7 @@ * @package Msls * @property bool $activate_autocomplete * @property bool $activate_content_import + * @property bool $activate_quick_create * @property bool $output_current_blog * @property bool $only_with_translation * @property int $content_priority From 7dd198411b3531d1096fc91cf5c887be8415a167 Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 22:38:04 +0200 Subject: [PATCH 02/11] feat(api): add REST endpoint for Quick Create Register msls/v1/create-translation POST endpoint. Creates a draft translation post on the target blog with mapped taxonomies and MSLS links. All data is filterable via dedicated hooks. --- includes/MslsPlugin.php | 1 + includes/MslsRestApi.php | 320 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 includes/MslsRestApi.php diff --git a/includes/MslsPlugin.php b/includes/MslsPlugin.php index 670ca6398..50a99b569 100644 --- a/includes/MslsPlugin.php +++ b/includes/MslsPlugin.php @@ -44,6 +44,7 @@ public static function init(): void { add_action( 'admin_enqueue_scripts', array( $obj, 'custom_enqueue' ) ); add_action( 'wp_enqueue_scripts', array( $obj, 'custom_enqueue' ) ); + add_action( 'rest_api_init', array( MslsRestApi::class, 'init' ) ); add_action( 'init', array( MslsAdminBar::class, 'init' ) ); add_action( 'init', array( MslsBlock::class, 'init' ) ); add_action( 'init', array( MslsShortCode::class, 'init' ) ); diff --git a/includes/MslsRestApi.php b/includes/MslsRestApi.php new file mode 100644 index 000000000..a2729426b --- /dev/null +++ b/includes/MslsRestApi.php @@ -0,0 +1,320 @@ + \WP_REST_Server::CREATABLE, + 'callback' => array( new self(), 'create_translation' ), + 'permission_callback' => array( new self(), 'check_permission' ), + 'args' => self::get_route_args(), + ) + ); + } + + /** + * @return array> + */ + private static function get_route_args(): array { + return array( + 'source_post_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'source_blog_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'target_blog_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ); + } + + /** + * @param \WP_REST_Request $request + * + * @return bool + */ + public function check_permission( \WP_REST_Request $request ): bool { + $target_blog_id = (int) $request->get_param( 'target_blog_id' ); + + switch_to_blog( $target_blog_id ); + $can_edit = current_user_can( 'edit_posts' ); + restore_current_blog(); + + return $can_edit; + } + + /** + * @param \WP_REST_Request $request + * + * @return \WP_REST_Response|\WP_Error + */ + public function create_translation( \WP_REST_Request $request ) { + $source_post_id = (int) $request->get_param( 'source_post_id' ); + $source_blog_id = (int) $request->get_param( 'source_blog_id' ); + $target_blog_id = (int) $request->get_param( 'target_blog_id' ); + + switch_to_blog( $source_blog_id ); + $source_post = get_post( $source_post_id ); + restore_current_blog(); + + if ( ! $source_post instanceof \WP_Post ) { + return new \WP_Error( + 'msls_source_not_found', + __( 'Source post not found.', 'multisite-language-switcher' ), + array( 'status' => 404 ) + ); + } + + $source_lang = MslsBlogCollection::get_blog_language( $source_blog_id ); + $target_lang = MslsBlogCollection::get_blog_language( $target_blog_id ); + + $post_data = $this->prepare_post_data( $source_post, $source_lang ); + $post_data = $this->prepare_taxonomies( $source_post, $source_blog_id, $target_blog_id, $target_lang, $post_data ); + + /** + * Filters the post data before creating the translation. + * + * @param array $post_data The post data for wp_insert_post. + * @param \WP_Post $source_post The source post object. + * @param int $source_blog_id The source blog ID. + * @param int $target_blog_id The target blog ID. + * + * @since TBD + */ + $post_data = apply_filters( 'msls_quick_create_post_data', $post_data, $source_post, $source_blog_id, $target_blog_id ); + + switch_to_blog( $target_blog_id ); + $new_post_id = wp_insert_post( $post_data, true ); + + if ( is_wp_error( $new_post_id ) ) { + restore_current_blog(); + + return $new_post_id; + } + + $this->assign_taxonomies( $post_data, $new_post_id ); + + /** + * Fires after the translation post is created on the target blog. + * + * @param int $new_post_id The new post ID. + * @param \WP_Post $source_post The source post object. + * @param int $source_blog_id The source blog ID. + * @param int $target_blog_id The target blog ID. + * + * @since TBD + */ + do_action( 'msls_quick_create_after_insert', $new_post_id, $source_post, $source_blog_id, $target_blog_id ); + + $edit_url = get_edit_post_link( $new_post_id, 'raw' ); + restore_current_blog(); + + $this->establish_link( $source_post_id, $source_blog_id, $new_post_id, $target_blog_id ); + + $response_data = array( + 'post_id' => $new_post_id, + 'edit_url' => $edit_url, + ); + + /** + * Filters the REST response data after creating a translation. + * + * @param array $response_data The response data. + * @param int $new_post_id The new post ID. + * @param \WP_Post $source_post The source post object. + * @param int $source_blog_id The source blog ID. + * @param int $target_blog_id The target blog ID. + * + * @since TBD + */ + $response_data = apply_filters( + 'msls_quick_create_response', + $response_data, + $new_post_id, + $source_post, + $source_blog_id, + $target_blog_id + ); + + return new \WP_REST_Response( $response_data, 201 ); + } + + /** + * @param \WP_Post $source_post + * @param string $source_lang + * + * @return array + */ + protected function prepare_post_data( \WP_Post $source_post, string $source_lang ): array { + $lang_code = substr( $source_lang, 0, 2 ); + + /* translators: 1: language code, 2: original post title */ + $title = sprintf( __( 'From %1$s: %2$s', 'multisite-language-switcher' ), $lang_code, $source_post->post_title ); + + /* translators: 1: language code, 2: original post content */ + $content = sprintf( __( 'From %1$s: %2$s', 'multisite-language-switcher' ), $lang_code, $source_post->post_content ); + + return array( + 'post_type' => $source_post->post_type, + 'post_status' => 'draft', + 'post_title' => $title, + 'post_content' => $content, + ); + } + + /** + * @param \WP_Post $source_post + * @param int $source_blog_id + * @param int $target_blog_id + * @param string $target_lang + * @param array $post_data + * + * @return array + */ + protected function prepare_taxonomies( + \WP_Post $source_post, + int $source_blog_id, + int $target_blog_id, + string $target_lang, + array $post_data + ): array { + switch_to_blog( $source_blog_id ); + + $taxonomies = get_object_taxonomies( $source_post->post_type ); + $tax_input = array(); + + foreach ( $taxonomies as $taxonomy ) { + $terms = wp_get_object_terms( $source_post->ID, $taxonomy, array( 'fields' => 'ids' ) ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + continue; + } + + $mapped_terms = array(); + foreach ( $terms as $term_id ) { + $term_options = MslsOptionsTax::create( $term_id ); + + if ( $term_options->has_value( $target_lang ) ) { + $mapped_terms[] = (int) $term_options->__get( $target_lang ); + } + } + + if ( ! empty( $mapped_terms ) ) { + $tax_input[ $taxonomy ] = $mapped_terms; + } + } + + restore_current_blog(); + + $post_data['_msls_tax_input'] = $tax_input; + + /** + * Filters the mapped taxonomy terms before assigning them. + * + * @param array $tax_input Taxonomy => term IDs mapping. + * @param \WP_Post $source_post The source post object. + * @param int $source_blog_id The source blog ID. + * @param int $target_blog_id The target blog ID. + * + * @since TBD + */ + $post_data['_msls_tax_input'] = apply_filters( + 'msls_quick_create_tax_input', + $post_data['_msls_tax_input'], + $source_post, + $source_blog_id, + $target_blog_id + ); + + return $post_data; + } + + /** + * @param array $post_data + * @param int $new_post_id + */ + protected function assign_taxonomies( array $post_data, int $new_post_id ): void { + if ( empty( $post_data['_msls_tax_input'] ) ) { + return; + } + + foreach ( $post_data['_msls_tax_input'] as $taxonomy => $term_ids ) { + wp_set_object_terms( $new_post_id, $term_ids, $taxonomy ); + } + } + + /** + * @param int $source_post_id + * @param int $source_blog_id + * @param int $new_post_id + * @param int $target_blog_id + */ + protected function establish_link( + int $source_post_id, + int $source_blog_id, + int $new_post_id, + int $target_blog_id + ): void { + $collection = msls_blog_collection(); + $source_lang = MslsBlogCollection::get_blog_language( $source_blog_id ); + $target_lang = MslsBlogCollection::get_blog_language( $target_blog_id ); + + // Read existing links from the source post + switch_to_blog( $source_blog_id ); + $source_options = new MslsOptionsPost( $source_post_id ); + $existing_links = $source_options->get_arr(); + restore_current_blog(); + + // Build a complete link map: all existing links + source + target + $link_map = $existing_links; + $link_map[ $source_lang ] = $source_post_id; + $link_map[ $target_lang ] = $new_post_id; + + // Update every blog in the link map + foreach ( $link_map as $lang => $post_id ) { + $blog_id = $collection->get_blog_id( $lang ); + + if ( null === $blog_id ) { + continue; + } + + switch_to_blog( $blog_id ); + + $options = new MslsOptionsPost( $post_id ); + $save_data = $link_map; + unset( $save_data[ $lang ] ); + + $options->save( $save_data ); + + restore_current_blog(); + } + } +} From 632f64e1f6157f594e42a25615687048b104d57b Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 22:40:18 +0200 Subject: [PATCH 03/11] feat(metabox): add create new translation button Add a '+' button per language when no translation is linked. In classic mode it opens post-new.php in a new tab. When Quick Create is enabled, it calls the REST API and updates the UI in place. --- assets/css/msls.css | 2 +- includes/MslsMetaBox.php | 62 +++++++++++++++++++++++++++++++++++--- includes/MslsPlugin.php | 4 +++ src/msls-quick-create.js | 64 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/msls-quick-create.js diff --git a/assets/css/msls.css b/assets/css/msls.css index 1a5af2ab5..70c4e663c 100644 --- a/assets/css/msls.css +++ b/assets/css/msls.css @@ -1 +1 @@ -div#msls.postbox label{margin-right:6px}div#msls.postbox input.msls_title,div#msls.postbox select{width:100%}select.msls-translations{width:226px}#msls.postbox .inside li{display:flex;align-items:center}#msls.postbox .inside li label{display:flex}#msls.postbox .inside li input.msls_title,#msls.postbox .inside li select{flex-grow:1}#msls-content-import .button-primary{margin:1em auto}.flag-icon{width:1.3333em!important;height:1em!important;vertical-align:middle;overflow:hidden;line-height:1!important;color:transparent}.msls-icon-wrapper{display:inline-flex;justify-content:center;align-items:center;text-align:center}.msls-icon-wrapper.flag{min-width:36px}.msls-icon-wrapper.label{min-width:48px}label .msls-icon-wrapper{text-align:left}#wpadminbar * .language-badge,#wpadminbar .language-badge,.language-badge{display:inline-block;min-width:32px;height:auto;padding:4px 6px;white-space:nowrap;font-size:10px;line-height:1;text-align:center;background-color:currentColor;border-radius:9px;user-select:none}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span,.language-badge>span{display:inline-block;vertical-align:top;margin:0 1px;font-size:10px;font-weight:600;line-height:1;text-transform:uppercase;color:#fff;text-align:center}#wpadminbar * .language-badge>span:nth-child(2),#wpadminbar .language-badge>span:nth-child(2),.language-badge>span:nth-child(2){opacity:.5}.column-mslscol .language-badge{margin:0 1px!important}.column-mslscol{width:56px}#wpadminbar * .language-badge,#wpadminbar .language-badge{position:relative;top:-1px;padding-top:3px;padding-bottom:3px;background-color:transparent;border:1px currentColor solid}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span{color:currentColor} \ No newline at end of file +div#msls.postbox label{margin-right:6px}div#msls.postbox input.msls_title,div#msls.postbox select{width:100%}select.msls-translations{width:226px}#msls.postbox .inside li{display:flex;align-items:center}#msls.postbox .inside li label{display:flex}#msls.postbox .inside li input.msls_title,#msls.postbox .inside li select{flex-grow:1}#msls-content-import .button-primary{margin:1em auto}.flag-icon{width:1.3333em!important;height:1em!important;vertical-align:middle;overflow:hidden;line-height:1!important;color:transparent}.msls-icon-wrapper{display:inline-flex;justify-content:center;align-items:center;text-align:center}.msls-icon-wrapper.flag{min-width:36px}.msls-icon-wrapper.label{min-width:48px}label .msls-icon-wrapper{text-align:left}#wpadminbar * .language-badge,#wpadminbar .language-badge,.language-badge{display:inline-block;min-width:32px;height:auto;padding:4px 6px;white-space:nowrap;font-size:10px;line-height:1;text-align:center;background-color:currentColor;border-radius:9px;user-select:none}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span,.language-badge>span{display:inline-block;vertical-align:top;margin:0 1px;font-size:10px;font-weight:600;line-height:1;text-transform:uppercase;color:#fff;text-align:center}#wpadminbar * .language-badge>span:nth-child(2),#wpadminbar .language-badge>span:nth-child(2),.language-badge>span:nth-child(2){opacity:.5}.column-mslscol .language-badge{margin:0 1px!important}.column-mslscol{width:56px}#wpadminbar * .language-badge,#wpadminbar .language-badge{position:relative;top:-1px;padding-top:3px;padding-bottom:3px;background-color:transparent;border:1px currentColor solid}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span{color:currentColor}#msls.postbox .inside li .msls-create-new{text-decoration:none;margin-left:4px;color:#2271b1;flex-shrink:0}#msls.postbox .inside li .msls-create-new:hover{color:#135e96}#msls.postbox .inside li .msls-create-new.msls-loading .dashicons{animation:msls-spin 1s linear infinite}@keyframes msls-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} \ No newline at end of file diff --git a/includes/MslsMetaBox.php b/includes/MslsMetaBox.php index 3430774c9..dc0885bf2 100644 --- a/includes/MslsMetaBox.php +++ b/includes/MslsMetaBox.php @@ -215,12 +215,18 @@ public function render_select(): void { ); } + $create_new = ''; + if ( ! $mydata->has_value( $language ) && 'auto-draft' !== $post->post_status ) { + $create_new = $this->get_create_new_link( $icon, $language, $blog->userblog_id ); + } + $lis .= sprintf( - '
  • %3$s
  • ', + '
  • %3$s%5$s
  • ', esc_attr( $language ), $icon, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $selects, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - esc_attr( $icon_type ) + esc_attr( $icon_type ), + $create_new // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ); restore_current_blog(); @@ -317,14 +323,25 @@ public function render_input(): void { $title = get_the_title( $value ); } + $create_new = ''; + $extra_attrs = ''; + if ( ! $my_data->has_value( $language ) && 'auto-draft' !== $post->post_status ) { + $create_new = $this->get_create_new_link( $icon, $language, $blog->userblog_id ); + $extra_attrs = ' style="display:none"'; + } elseif ( $my_data->has_value( $language ) ) { + $extra_attrs = ''; + } + $items .= sprintf( - '
  • ', + '
  • %8$s
  • ', $blog->userblog_id, $icon, $language, $value, $title, - esc_attr( $icon_type ) + esc_attr( $icon_type ), + $extra_attrs, + $create_new ); restore_current_blog(); @@ -352,6 +369,43 @@ public function render_input(): void { } } + /** + * @param MslsAdminIcon $icon + * @param string $language + * @param int $target_blog_id + * + * @return string + */ + private function get_create_new_link( MslsAdminIcon $icon, string $language, int $target_blog_id ): string { + global $post; + + $title = sprintf( + /* translators: %s: language code */ + __( 'Create a new translation in the %s-blog', 'multisite-language-switcher' ), + $language + ); + + if ( $this->options->activate_quick_create ) { + return sprintf( + '', + esc_attr( $title ), + $target_blog_id, + $post->ID, + get_current_blog_id() + ); + } + + $href = $icon->set_id( $post->ID ) + ->set_origin_language( $this->collection->get_current_blog()->get_language() ) + ->get_edit_new(); + + return sprintf( + '', + esc_url( $href ), + esc_attr( $title ) + ); + } + /** * Set * diff --git a/includes/MslsPlugin.php b/includes/MslsPlugin.php index 50a99b569..365e47f5e 100644 --- a/includes/MslsPlugin.php +++ b/includes/MslsPlugin.php @@ -125,6 +125,10 @@ public function custom_enqueue(): void { if ( $this->options->activate_autocomplete ) { wp_enqueue_script( 'msls-autocomplete', self::plugins_url( "$folder/msls.js" ), array( 'jquery-ui-autocomplete' ), $ver, array( 'in_footer' => true ) ); } + + if ( $this->options->activate_quick_create ) { + wp_enqueue_script( 'msls-quick-create', self::plugins_url( "$folder/msls-quick-create.js" ), array( 'jquery', 'wp-api-fetch' ), $ver, array( 'in_footer' => true ) ); + } } /** diff --git a/src/msls-quick-create.js b/src/msls-quick-create.js new file mode 100644 index 000000000..76c3d3535 --- /dev/null +++ b/src/msls-quick-create.js @@ -0,0 +1,64 @@ +jQuery( document ).ready( + function ( $ ) { + $( '.msls-quick-create' ).on( + 'click', + function ( e ) { + e.preventDefault(); + + var $button = $( this ); + var $li = $button.closest( 'li' ); + + if ( $button.hasClass( 'msls-loading' ) ) { + return; + } + + $button.addClass( 'msls-loading' ); + $button.find( '.dashicons' ).removeClass( 'dashicons-plus' ).addClass( 'dashicons-update' ); + + wp.apiFetch( + { + path: '/msls/v1/create-translation', + method: 'POST', + data: { + source_post_id: parseInt( $button.data( 'source-post-id' ), 10 ), + source_blog_id: parseInt( $button.data( 'source-blog-id' ), 10 ), + target_blog_id: parseInt( $button.data( 'target-blog-id' ), 10 ) + } + } + ).then( + function ( response ) { + $button.remove(); + + var $icon = $li.find( '.dashicons-plus' ); + if ( $icon.length ) { + $icon.removeClass( 'dashicons-plus' ).addClass( 'dashicons-edit' ); + $icon.parent( 'a' ).attr( 'href', response.edit_url ); + } + + var $hiddenInput = $li.find( 'input[type="hidden"][name^="msls_input_"]' ); + if ( $hiddenInput.length ) { + $hiddenInput.val( response.post_id ); + } + + var $textInput = $li.find( 'input.msls_title' ); + if ( $textInput.length ) { + $textInput.show(); + } + + var $select = $li.find( 'select[name^="msls_input_"]' ); + if ( $select.length ) { + $select.append( + $( '