diff --git a/assets/css/msls.css b/assets/css/msls.css index ed42d936c..e835ea074 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.postbox .inside li .msls-create-new,#msls.postbox .inside li .msls-edit-link{text-decoration:none;margin-left:4px;color:#2271b1}#msls.postbox .inside li .msls-create-new:hover,#msls.postbox .inside li .msls-edit-link:hover{color:#135e96}.msls-quick-create{background:0 0;border:none;padding:0;margin:0;cursor:pointer;color:inherit;font:inherit;line-height:inherit}.msls-quick-create.msls-loading .dashicons{animation:msls-spin 1s linear infinite}@keyframes msls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}#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.postbox .inside li .msls-create-new,#msls.postbox .inside li .msls-edit-link{text-decoration:none;margin-left:4px;color:#2271b1}#msls.postbox .inside li .msls-create-new:hover,#msls.postbox .inside li .msls-edit-link:hover{color:#135e96}.msls-quick-create{background:0 0;border:none;padding:0;margin:0;cursor:pointer;color:inherit;font:inherit;line-height:inherit}.msls-quick-create.msls-loading .dashicons{animation:msls-spin 1s linear infinite}@keyframes msls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}#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}#adminmenu .wp-submenu a[href*=msls-translation-picker-]{padding-left:28px}.msls-tp-page .msls-tp-back{margin:4px 0 0;font-size:13px}.msls-tp-page .msls-tp-back a{color:#2271b1;text-decoration:none}.msls-tp-page .msls-tp-back a:focus,.msls-tp-page .msls-tp-back a:hover{color:#135e96;text-decoration:underline}.msls-tp-page .msls-tp-banner{display:flex;align-items:center;gap:8px;margin:14px 0}.msls-tp-page .msls-tp-banner .msls-tp-banner-arrow{color:#2271b1;font-weight:600}.msls-tp-page .msls-tp-sources{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:14px 0 10px}.msls-tp-page .msls-tp-sources .msls-tp-sources-label{font-weight:600;margin-right:4px;color:#1d2327}.msls-tp-page .msls-tp-source-flag{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:#fff;border:1px solid #c3c4c7;border-radius:20px;text-decoration:none;color:#1d2327;font-size:13px;line-height:1.2;transition:background 120ms ease,border-color 120ms ease,box-shadow 120ms ease}.msls-tp-page .msls-tp-source-flag .flag-icon{font-size:18px;line-height:1}.msls-tp-page .msls-tp-source-flag:focus,.msls-tp-page .msls-tp-source-flag:hover{background:#f6f7f7;border-color:#8c8f94;color:#1d2327;box-shadow:none}.msls-tp-page .msls-tp-source-flag.is-active{background:#2271b1;border-color:#2271b1;color:#fff;box-shadow:0 0 0 1px #2271b1}.msls-tp-page .msls-tp-source-flag.is-active:focus,.msls-tp-page .msls-tp-source-flag.is-active:hover{background:#135e96;border-color:#135e96;color:#fff}.msls-tp-page .msls-tp-source-flag .msls-tp-source-label{font-weight:500}.msls-tp-page .msls-tp-filters{display:flex;align-items:center;gap:8px;margin:4px 0 16px;flex-wrap:wrap}.msls-tp-page .msls-tp-filters input[type=search]{min-width:240px}.msls-tp-page .msls-tp-progress{margin:12px 0}.msls-tp-page table.wp-list-table .row-actions .msls-tp-create.msls-loading{opacity:.7;pointer-events:none}.msls-tp-page table.wp-list-table .row-actions .msls-tp-create.msls-loading::after{content:"";display:inline-block;width:10px;height:10px;margin-left:6px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:msls-spin .8s linear infinite;vertical-align:middle}.msls-tp-page table.wp-list-table .column-status{width:110px}.msls-tp-page table.wp-list-table .column-date{width:140px}.msls-tp-lang-chip{display:inline-block;padding:2px 6px;background:#f0f0f1;border-radius:10px;font-size:10px;font-weight:600;letter-spacing:.4px;text-transform:uppercase;color:#50575e}.msls-tp-status-badge{display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.msls-tp-status-badge.msls-tp-status-publish{background:#e0f7e5;color:#1d593f}.msls-tp-status-badge.msls-tp-status-draft{background:#f0f0f1;color:#50575e}.msls-tp-status-badge.msls-tp-status-pending{background:#fff3cd;color:#674d03}.msls-tp-status-badge.msls-tp-status-future{background:#e7e5f7;color:#3a2d6b} diff --git a/assets/css/msls.less b/assets/css/msls.less index 42c32c2ba..f0aac1b25 100644 --- a/assets/css/msls.less +++ b/assets/css/msls.less @@ -148,6 +148,182 @@ select.msls-translations { border: 1px currentColor solid; & > span { - color: currentColor; + color: currentColor; + } +} + +#adminmenu .wp-submenu a[href*="msls-translation-picker-"] { + padding-left: 28px; +} + +.msls-tp-page { + .msls-tp-back { + margin: 4px 0 0; + font-size: 13px; + + a { + color: #2271b1; + text-decoration: none; + + &:hover, + &:focus { + color: #135e96; + text-decoration: underline; + } + } + } + + .msls-tp-banner { + display: flex; + align-items: center; + gap: 8px; + margin: 14px 0; + + .msls-tp-banner-arrow { + color: #2271b1; + font-weight: 600; + } + } + + .msls-tp-sources { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 14px 0 10px; + + .msls-tp-sources-label { + font-weight: 600; + margin-right: 4px; + color: #1d2327; + } + } + + .msls-tp-source-flag { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 20px; + text-decoration: none; + color: #1d2327; + font-size: 13px; + line-height: 1.2; + transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease; + + .flag-icon { + font-size: 18px; + line-height: 1; + } + + &:hover, + &:focus { + background: #f6f7f7; + border-color: #8c8f94; + color: #1d2327; + box-shadow: none; + } + + &.is-active { + background: #2271b1; + border-color: #2271b1; + color: #fff; + box-shadow: 0 0 0 1px #2271b1; + + &:hover, + &:focus { + background: #135e96; + border-color: #135e96; + color: #fff; + } + } + + .msls-tp-source-label { + font-weight: 500; + } + } + + .msls-tp-filters { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0 16px; + flex-wrap: wrap; + + input[type="search"] { + min-width: 240px; + } + } + + .msls-tp-progress { + margin: 12px 0; + } + + table.wp-list-table { + .row-actions .msls-tp-create.msls-loading { + opacity: 0.7; + pointer-events: none; + + &::after { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + margin-left: 6px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: msls-spin 0.8s linear infinite; + vertical-align: middle; + } + } + + .column-status { + width: 110px; + } + + .column-date { + width: 140px; + } + } +} + +.msls-tp-lang-chip { + display: inline-block; + padding: 2px 6px; + background: #f0f0f1; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: #50575e; +} + +.msls-tp-status-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + + &.msls-tp-status-publish { + background: #e0f7e5; + color: #1d593f; + } + &.msls-tp-status-draft { + background: #f0f0f1; + color: #50575e; + } + &.msls-tp-status-pending { + background: #fff3cd; + color: #674d03; + } + &.msls-tp-status-future { + background: #e7e5f7; + color: #3a2d6b; } } \ No newline at end of file diff --git a/assets/js/msls-quick-create.js b/assets/js/msls-quick-create.js index 736fd9d5d..c6763a2e5 100644 --- a/assets/js/msls-quick-create.js +++ b/assets/js/msls-quick-create.js @@ -1 +1 @@ -jQuery(document).ready(function($){$(document).on("click",".msls-quick-create",function(){var $button=$(this);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){var $link=$("").attr("href",response.edit_url).attr("title",$button.attr("title").replace(/Create/,"Edit")).html($button.html());$link.find(".dashicons").removeClass("dashicons-update dashicons-plus").addClass("dashicons-edit");$button.replaceWith($link);var $container=$link.closest("li");if(!$container.length){return}var $hiddenInput=$container.find('input[type="hidden"][name^="msls_input_"]');if($hiddenInput.length){$hiddenInput.val(response.post_id)}var $select=$container.find('select[name^="msls_input_"]');if($select.length){$select.append($("").attr("href",response.edit_url).attr("title",$button.attr("title").replace(/Create/,"Edit")).html($button.html());if(isMetabox){$link.addClass("msls-edit-link").attr("target","_blank")}var successIcon=isMetabox?"dashicons-external":"dashicons-edit";$link.find(".dashicons").removeClass("dashicons-update dashicons-plus").addClass(successIcon);$button.replaceWith($link);var $container=$link.closest("li");if(!$container.length){return}var $hiddenInput=$container.find('input[type="hidden"][name^="msls_input_"]');if($hiddenInput.length){$hiddenInput.val(response.post_id)}var $titleInput=$container.find("input.msls_title");if($titleInput.length){$titleInput.val(response.post_title||"")}var $select=$container.find('select[name^="msls_input_"]');if($select.length){$select.append($("").addClass("page-title-action msls-tp-button").attr("href",%1$s).text(%2$s);var $a=$(".wrap .page-title-action").first();if($a.length){$a.after(" ",b);}else{$(".wrap .wp-heading-inline").after(" ",b);}});', + wp_json_encode( $url ), + wp_json_encode( $label ) + ); + + wp_add_inline_script( 'common', $script ); + } +} diff --git a/includes/MslsRestApi.php b/includes/MslsRestApi.php index d1b5f60b1..bb5b52945 100644 --- a/includes/MslsRestApi.php +++ b/includes/MslsRestApi.php @@ -2,6 +2,8 @@ namespace lloc\Msls; +use lloc\Msls\Query\TranslatedPostIdQuery; + if ( ! defined( 'ABSPATH' ) ) { exit; } @@ -17,6 +19,14 @@ class MslsRestApi { const ROUTE = '/create-translation'; + const ROUTE_UNTRANSLATED = '/untranslated-posts'; + + const UNTRANSLATED_POSTS_LIMIT = 100; + + const UNTRANSLATED_POST_STATUSES = array( 'publish', 'draft', 'pending', 'future' ); + + const LAST_SOURCE_USER_META = 'msls_translation_picker_last_source'; + /** * Registers the REST API route. */ @@ -32,7 +42,53 @@ public static function init(): void { ) ); + register_rest_route( + self::NAMESPACE, + self::ROUTE_UNTRANSLATED, + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( new self(), 'list_untranslated_posts' ), + 'permission_callback' => array( new self(), 'check_list_permission' ), + 'args' => self::get_list_route_args(), + ) + ); + add_filter( 'msls_quick_create_post_data', array( self::class, 'prefix_source_language' ), 10, 4 ); + add_action( 'msls_quick_create_after_insert', array( self::class, 'remember_source_blog' ), 10, 3 ); + } + + /** + * Remembers the source blog a user last used when creating a + * translation, so the picker can pre-select it next time. + * + * Hooked to msls_quick_create_after_insert so it only records on + * successful creates, not on modal-open/cancel. + * + * @param int $new_post_id + * @param \WP_Post $source_post + * @param int $source_blog_id + */ + public static function remember_source_blog( int $new_post_id, \WP_Post $source_post, int $source_blog_id ): void { + $user_id = get_current_user_id(); + if ( $user_id <= 0 ) { + return; + } + + update_user_meta( $user_id, self::LAST_SOURCE_USER_META, $source_blog_id ); + } + + /** + * Returns the source blog id the current user last picked, or 0. + * + * @return int + */ + public static function get_last_source_blog_id(): int { + $user_id = get_current_user_id(); + if ( $user_id <= 0 ) { + return 0; + } + + return (int) get_user_meta( $user_id, self::LAST_SOURCE_USER_META, true ); } /** @@ -58,6 +114,37 @@ private static function get_route_args(): array { ); } + /** + * Argument schema for the GET /untranslated-posts endpoint. + * + * @return array> + */ + private static function get_list_route_args(): array { + return array( + 'source_blog_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'target_blog_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'post_type' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ), + 'search' => array( + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ), + ); + } + /** * @param \WP_REST_Request $request * @@ -68,19 +155,109 @@ public function check_permission( \WP_REST_Request $request ): bool { $source_post_id = (int) $request->get_param( 'source_post_id' ); $target_blog_id = (int) $request->get_param( 'target_blog_id' ); + if ( ! self::user_can_read_source( $source_post_id, $source_blog_id, $target_blog_id ) ) { + return false; + } + + return self::user_can_create_target( $source_post_id, $source_blog_id, $target_blog_id ); + } + + /** + * Evaluates the read capability on the source blog, with filter override. + * + * @param int $source_post_id + * @param int $source_blog_id + * @param int $target_blog_id + * + * @return bool + */ + public static function user_can_read_source( int $source_post_id, int $source_blog_id, int $target_blog_id ): bool { switch_to_blog( $source_blog_id ); - $can_read = current_user_can( 'read_post', $source_post_id ); + $default = current_user_can( 'read_post', $source_post_id ); restore_current_blog(); - if ( ! $can_read ) { - return false; - } + return self::apply_capability_filter( $default, $source_post_id, $source_blog_id, $target_blog_id, 'read' ); + } + /** + * Evaluates the create capability on the target blog, with filter override. + * + * @param int $source_post_id + * @param int $source_blog_id + * @param int $target_blog_id + * + * @return bool + */ + public static function user_can_create_target( int $source_post_id, int $source_blog_id, int $target_blog_id ): bool { switch_to_blog( $target_blog_id ); - $can_edit = current_user_can( 'edit_posts' ); + $default = current_user_can( 'edit_posts' ); + restore_current_blog(); + + return self::apply_capability_filter( $default, $source_post_id, $source_blog_id, $target_blog_id, 'create' ); + } + + /** + * Permission callback for the untranslated-posts listing endpoint. + * + * Runs the same capability filter as the create endpoint but with no + * specific source post id (0) and with a generic 'read' capability + * default on the source blog, since no single post is being targeted. + * + * @param \WP_REST_Request $request + * + * @return bool + */ + public function check_list_permission( \WP_REST_Request $request ): bool { + $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 ); + $default_read = current_user_can( 'read' ); restore_current_blog(); - return $can_edit; + if ( ! self::apply_capability_filter( $default_read, 0, $source_blog_id, $target_blog_id, 'read' ) ) { + return false; + } + + return self::user_can_create_target( 0, $source_blog_id, $target_blog_id ); + } + + /** + * Routes the default capability decision through the + * msls_quick_create_capability filter so integrations can override it. + * + * @param bool $default Result of the default capability check. + * @param int $source_post_id Source post id, or 0 for list-style checks. + * @param int $source_blog_id + * @param int $target_blog_id + * @param string $context 'read' for source-side checks, 'create' for target-side. + * + * @return bool + */ + private static function apply_capability_filter( bool $default, int $source_post_id, int $source_blog_id, int $target_blog_id, string $context ): bool { + /** + * Filters the result of the Quick Create capability check. + * + * Lets integrations override the default read/edit checks, for + * example to permit a translator without an account on the source + * blog to mirror a post into the target blog. + * + * @param bool $default Result of the default capability check. + * @param int $source_post_id Source post ID (0 for list-style checks). + * @param int $source_blog_id Source blog ID. + * @param int $target_blog_id Target blog ID. + * @param string $context 'read' when checking the source, 'create' when checking the target. + * + * @since TBD + */ + return (bool) apply_filters( + 'msls_quick_create_capability', + $default, + $source_post_id, + $source_blog_id, + $target_blog_id, + $context + ); } /** @@ -191,6 +368,86 @@ public function create_translation( \WP_REST_Request $request ) { return new \WP_REST_Response( $response_data, 201 ); } + /** + * Lists source-blog posts of a given type that have no translation + * in the target blog yet. + * + * @param \WP_REST_Request $request + * + * @return \WP_REST_Response|\WP_Error + */ + public function list_untranslated_posts( \WP_REST_Request $request ) { + $source_blog_id = (int) $request->get_param( 'source_blog_id' ); + $target_blog_id = (int) $request->get_param( 'target_blog_id' ); + $post_type = (string) $request->get_param( 'post_type' ); + $search = (string) $request->get_param( 'search' ); + + $target_lang = MslsBlogCollection::get_blog_language( $target_blog_id ); + + switch_to_blog( $source_blog_id ); + + if ( ! post_type_exists( $post_type ) ) { + restore_current_blog(); + + return new \WP_Error( + 'msls_source_post_type_not_found', + __( 'Post type does not exist on the source blog.', 'multisite-language-switcher' ), + array( 'status' => 400 ) + ); + } + + $translated_ids = ( new TranslatedPostIdQuery( MslsSqlCacher::create( __CLASS__, __METHOD__ ) ) )( $target_lang ); + + $query_args = array( + 'post_type' => $post_type, + 'post_status' => self::UNTRANSLATED_POST_STATUSES, + 'numberposts' => self::UNTRANSLATED_POSTS_LIMIT, + 'post__not_in' => $translated_ids, + 'suppress_filters' => false, + 'orderby' => 'date', + 'order' => 'DESC', + ); + + if ( '' !== $search ) { + $query_args['s'] = $search; + } + + $posts = get_posts( $query_args ); + + $items = array(); + foreach ( $posts as $post ) { + $items[] = array( + 'id' => (int) $post->ID, + 'title' => get_the_title( $post ), + 'post_status' => $post->post_status, + 'date_gmt' => mysql_to_rfc3339( $post->post_date_gmt ), + 'view_url' => (string) get_permalink( $post ), + ); + } + + restore_current_blog(); + + /** + * Filters the untranslated-posts listing response. + * + * @param array> $items Listing items. + * @param int $source_blog_id Source blog ID. + * @param int $target_blog_id Target blog ID. + * @param string $post_type Post type queried. + * + * @since TBD + */ + $items = apply_filters( 'msls_untranslated_posts', $items, $source_blog_id, $target_blog_id, $post_type ); + + return new \WP_REST_Response( + array( + 'items' => $items, + 'total' => count( $items ), + ), + 200 + ); + } + /** * @param \WP_Post $source_post * diff --git a/includes/MslsTranslationPickerPage.php b/includes/MslsTranslationPickerPage.php new file mode 100644 index 000000000..2eb3a0cc1 --- /dev/null +++ b/includes/MslsTranslationPickerPage.php @@ -0,0 +1,470 @@ + 0 ? $value : self::PER_PAGE_DEFAULT; + } + return $status; + } + + /** + * Registers a visible submenu entry under each MSLS-supported post + * type's "All Posts" menu. This keeps the sidebar expanded on the + * right section when the page is active and surfaces the entry point + * directly in the menu hierarchy. + * + * @codeCoverageIgnore + */ + public static function register(): void { + if ( msls_options()->is_excluded() ) { + return; + } + + foreach ( MslsPostType::get() as $post_type ) { + $parent = self::parent_slug( $post_type ); + if ( '' === $parent ) { + continue; + } + + $hook = add_submenu_page( + $parent, + __( 'Add Post from Translation', 'multisite-language-switcher' ), + __( 'Add from Translation', 'multisite-language-switcher' ), + 'edit_posts', + self::page_slug( $post_type ), + array( self::class, 'render' ) + ); + + if ( $hook ) { + add_action( + 'load-' . $hook, + static function () use ( $post_type ) { + MslsTranslationPickerPage::on_page_load( $post_type ); + } + ); + } + } + } + + /** + * Called once per request when the picker page is being loaded for a + * specific post type. Registers the screen options (per-page and the + * automatic column-toggle dropdown). + * + * @codeCoverageIgnore + */ + public static function on_page_load( string $post_type ): void { + add_screen_option( + 'per_page', + array( + 'label' => __( 'Posts per page', 'multisite-language-switcher' ), + 'default' => self::PER_PAGE_DEFAULT, + 'option' => self::PER_PAGE_OPTION, + ) + ); + + $screen = get_current_screen(); + if ( ! $screen ) { + return; + } + + // Surface the table's columns to WordPress so the screen-options + // dropdown shows toggles for them and hidden-column user prefs + // persist through manage{$screen->id}columnshidden. + add_filter( + 'manage_' . $screen->id . '_columns', + static function () use ( $post_type ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $source = isset( $_GET['msls_source'] ) ? absint( wp_unslash( (string) $_GET['msls_source'] ) ) : 0; + $table = new MslsTranslationPickerTable( $source, $post_type ); + return $table->get_columns(); + } + ); + } + + /** + * Moves each of our submenu entries to the slot directly after the + * parent's first item ("All Posts" / "All Pages" / "All CPTs"). Runs + * on admin_menu at priority 999 so it applies after every other + * plugin has finished adding entries. + * + * @codeCoverageIgnore + */ + public static function reorder_submenu(): void { + global $submenu; + + if ( ! is_array( $submenu ) || msls_options()->is_excluded() ) { + return; + } + + foreach ( MslsPostType::get() as $post_type ) { + $parent = self::parent_slug( $post_type ); + $slug = self::page_slug( $post_type ); + + if ( empty( $submenu[ $parent ] ) || ! is_array( $submenu[ $parent ] ) ) { + continue; + } + + $our_key = null; + foreach ( $submenu[ $parent ] as $k => $item ) { + if ( isset( $item[2] ) && $slug === $item[2] ) { + $our_key = $k; + break; + } + } + + if ( null === $our_key ) { + continue; + } + + $our_item = $submenu[ $parent ][ $our_key ]; + unset( $submenu[ $parent ][ $our_key ] ); + + // Locate the "All Posts" entry by its slug rather than assuming + // it is the first item — another plugin could have prepended. + $all_key = null; + foreach ( $submenu[ $parent ] as $k => $item ) { + if ( isset( $item[2] ) && $parent === $item[2] ) { + $all_key = $k; + break; + } + } + + $rebuilt = array(); + $placed = false; + foreach ( $submenu[ $parent ] as $k => $item ) { + $rebuilt[ $k ] = $item; + if ( ! $placed && ( null === $all_key || $k === $all_key ) ) { + $rebuilt[] = $our_item; + $placed = true; + } + } + + if ( ! $placed ) { + $rebuilt[] = $our_item; + } + + $submenu[ $parent ] = $rebuilt; + } + } + + /** + * Menu slug of the parent (Posts / Pages / CPT) menu for a post type. + */ + public static function parent_slug( string $post_type ): string { + if ( '' === $post_type ) { + return ''; + } + if ( 'post' === $post_type ) { + return 'edit.php'; + } + return 'edit.php?post_type=' . $post_type; + } + + /** + * Unique page slug per post type. Needed because WordPress enforces + * globally unique submenu slugs, so we can't reuse one slug under + * multiple parents. + */ + public static function page_slug( string $post_type ): string { + return self::BASE_SLUG . '-' . $post_type; + } + + /** + * Canonical URL for the page, scoped to a post type. + * + * @param string $post_type + * + * @return string + */ + public static function url( string $post_type ): string { + return add_query_arg( + array( 'page' => self::page_slug( $post_type ) ), + admin_url( self::parent_slug( $post_type ) ) + ); + } + + /** + * Enqueues the picker script on this page only. + * + * @codeCoverageIgnore + */ + public static function enqueue( int $target_blog_id ): void { + $ver = defined( 'MSLS_PLUGIN_VERSION' ) ? constant( 'MSLS_PLUGIN_VERSION' ) : false; + $folder = defined( 'SCRIPT_DEBUG' ) && constant( 'SCRIPT_DEBUG' ) ? 'src' : 'assets/js'; + + wp_enqueue_script( + self::SCRIPT_HANDLE, + MslsPlugin::plugins_url( "$folder/msls-translation-picker.js" ), + array( 'jquery', 'wp-api-fetch' ), + $ver, + array( 'in_footer' => true ) + ); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'mslsTranslationPicker', + array( + 'targetBlogId' => $target_blog_id, + 'i18n' => array( + 'creating' => __( 'Creating draft…', 'multisite-language-switcher' ), + /* translators: 1: index of the current item being processed, 2: total number of selected items */ + 'progress' => __( 'Creating drafts: %1$d of %2$d…', 'multisite-language-switcher' ), + /* translators: 1: number of drafts successfully created, 2: number of errors encountered */ + 'completed' => __( '%1$d drafts created, %2$d errors.', 'multisite-language-switcher' ), + 'noneChose' => __( 'No posts selected.', 'multisite-language-switcher' ), + 'error' => __( 'Something went wrong. Please try again.', 'multisite-language-switcher' ), + ), + ) + ); + } + + /** + * Renders the page. + * + * @codeCoverageIgnore + */ + public static function render(): void { + if ( ! current_user_can( 'edit_posts' ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'multisite-language-switcher' ) ); + } + + // phpcs:disable WordPress.Security.NonceVerification.Recommended + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; + $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( (string) $_GET['post_type'] ) ) : ''; + $source = isset( $_GET['msls_source'] ) ? absint( wp_unslash( (string) $_GET['msls_source'] ) ) : 0; + $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['s'] ) ) : ''; + // phpcs:enable + + // Derive post type from the page slug (msls-translation-picker-). + if ( '' !== $page && 0 === strpos( $page, self::BASE_SLUG . '-' ) ) { + $post_type = substr( $page, strlen( self::BASE_SLUG ) + 1 ); + } + + if ( ! in_array( $post_type, MslsPostType::get(), true ) ) { + $post_type = 'post'; + } + + $collection = msls_blog_collection(); + $target = $collection->get_current_blog(); + $blogs = array(); + foreach ( $collection->get() as $blog ) { + if ( $collection->is_plugin_active( $blog->userblog_id ) ) { + $blogs[] = $blog; + } + } + + // With only one source available, treat it as pre-selected so the + // user lands straight on the list instead of a picker-of-one. + if ( 0 === $source && 1 === count( $blogs ) ) { + $source = (int) $blogs[0]->userblog_id; + } + + self::enqueue( (int) get_current_blog_id() ); + + echo '
'; + printf( + '

%2$s

', + esc_url( admin_url( self::parent_slug( $post_type ) ) ), + esc_html__( '← Back to all posts', 'multisite-language-switcher' ) + ); + printf( + '

%s

', + esc_html__( 'Add Post from Translation', 'multisite-language-switcher' ) + ); + echo '
'; + + if ( $target instanceof MslsBlog ) { + printf( + '

%1$s %2$s %3$s

', + esc_html__( 'Creating drafts in:', 'multisite-language-switcher' ), + esc_html( $target->get_description() ), + esc_html( strtoupper( $target->get_alpha2() ) ) + ); + } + + self::render_filter_form( $post_type, $source, $search, $blogs ); + + if ( $source > 0 ) { + self::render_list_table( $source, $post_type, $search ); + } else { + printf( + '

%s

', + esc_html__( 'Choose a source blog to list untranslated posts.', 'multisite-language-switcher' ) + ); + } + + echo '
'; + } + + /** + * @param string $post_type + * @param int $source + * @param string $search + * @param array $blogs + * + * @codeCoverageIgnore + */ + private static function render_filter_form( string $post_type, int $source, string $search, array $blogs ): void { + self::render_source_flags( $post_type, $source, $search, $blogs ); + + echo '
'; + echo ''; + if ( 'post' !== $post_type ) { + echo ''; + } + echo ''; + + printf( + '', + esc_attr( $search ), + esc_attr__( 'Filter by title…', 'multisite-language-switcher' ) + ); + + printf( + ' ', + esc_html__( 'Apply', 'multisite-language-switcher' ) + ); + + echo '
'; + } + + /** + * Renders a row of clickable flag-buttons — one per source blog. + * Navigating between sources no longer needs a select + Apply. + * + * @param string $post_type + * @param int $source + * @param string $search + * @param array $blogs + * + * @codeCoverageIgnore + */ + private static function render_source_flags( string $post_type, int $source, string $search, array $blogs ): void { + if ( empty( $blogs ) ) { + return; + } + + echo '
'; + + printf( + '%s', + esc_html__( 'Source blog:', 'multisite-language-switcher' ) + ); + + foreach ( $blogs as $blog ) { + $blog_id = (int) $blog->userblog_id; + $is_active = ( $source === $blog_id ); + + $icon = ( new MslsAdminIcon( null ) ) + ->set_language( $blog->get_language() ) + ->set_icon_type( MslsAdminIcon::TYPE_FLAG ) + ->get_icon(); + + $url = add_query_arg( + array( + 'page' => self::page_slug( $post_type ), + 'msls_source' => $blog_id, + 's' => $search, + ), + admin_url( self::parent_slug( $post_type ) ) + ); + + printf( + '%5$s%6$s', + esc_url( $url ), + $is_active ? ' is-active' : '', + $is_active ? 'true' : 'false', + esc_attr( $blog->get_description() ), + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $icon, + esc_html( $blog->get_description() ) + ); + } + + echo '
'; + } + + /** + * @codeCoverageIgnore + */ + private static function render_list_table( int $source, string $post_type, string $search ): void { + if ( ! class_exists( '\\WP_List_Table' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; + } + + $table = new MslsTranslationPickerTable( $source, $post_type, $search ); + $table->prepare_items(); + + // The form's submit is intercepted client-side and dispatched through + // the REST API (which carries its own X-WP-Nonce), so no server-side + // nonce is required here. + echo '
'; + $table->display(); + echo '
'; + } +} diff --git a/includes/MslsTranslationPickerTable.php b/includes/MslsTranslationPickerTable.php new file mode 100644 index 000000000..97ad69359 --- /dev/null +++ b/includes/MslsTranslationPickerTable.php @@ -0,0 +1,323 @@ +|null + */ + protected ?array $taxonomies_cache = null; + + /** + * @param int $source_blog_id Blog to read posts from. + * @param string $post_type Post type to query. + * @param string $search Optional title-search term. + */ + public function __construct( int $source_blog_id, string $post_type, string $search = '' ) { + parent::__construct( + array( + 'singular' => 'msls_source_post', + 'plural' => 'msls_source_posts', + 'ajax' => false, + 'screen' => MslsTranslationPickerPage::BASE_SLUG, + ) + ); + + $this->source_blog_id = $source_blog_id; + $this->post_type = $post_type; + $this->search = $search; + } + + /** + * Returns the column map for the table — checkbox, title, author, any + * source-blog taxonomies that opted into show_admin_column, then status + * and date. + * + * @return array + */ + public function get_columns(): array { + $cols = array( + 'cb' => '', + 'title' => __( 'Title', 'multisite-language-switcher' ), + 'author' => __( 'Author', 'multisite-language-switcher' ), + ); + + foreach ( $this->get_admin_column_taxonomies() as $name => $tax ) { + $cols[ 'taxonomy-' . $name ] = $tax->labels->name ?? (string) $tax->label; + } + + $cols['status'] = __( 'Status', 'multisite-language-switcher' ); + $cols['date'] = __( 'Date', 'multisite-language-switcher' ); + + return $cols; + } + + /** + * Returns taxonomies registered for the post type that declared + * show_admin_column => true — same signal core edit.php uses to decide + * which taxonomy columns to render. + * + * @return array + */ + protected function get_admin_column_taxonomies(): array { + if ( null !== $this->taxonomies_cache ) { + return $this->taxonomies_cache; + } + + $switched = false; + if ( $this->source_blog_id > 0 ) { + switch_to_blog( $this->source_blog_id ); + $switched = true; + } + + $this->taxonomies_cache = array(); + foreach ( get_object_taxonomies( $this->post_type, 'objects' ) as $tax ) { + if ( ! empty( $tax->show_admin_column ) ) { + $this->taxonomies_cache[ $tax->name ] = $tax; + } + } + + if ( $switched ) { + restore_current_blog(); + } + + return $this->taxonomies_cache; + } + + /** + * Registers the bulk action that the picker JS hijacks to create + * drafts for every checked row. + * + * @return array + */ + protected function get_bulk_actions(): array { + return array( + 'msls_bulk_create' => __( 'Create drafts for selected', 'multisite-language-switcher' ), + ); + } + + /** + * Loads the source-blog posts for the current page into $this->items. + * + * Honours hidden-column user prefs, the per-page screen option, the + * search term and pagination. Already-translated post IDs are excluded + * via TranslatedPostIdQuery against the target language. + */ + public function prepare_items(): void { + $columns = $this->get_columns(); + $hidden = is_object( $this->screen ) ? get_hidden_columns( $this->screen ) : array(); + $this->_column_headers = array( $columns, $hidden, array() ); + + $target_lang = MslsBlogCollection::get_blog_language( get_current_blog_id() ); + $current_page = $this->get_pagenum(); + $per_page = (int) $this->get_items_per_page( MslsTranslationPickerPage::PER_PAGE_OPTION, self::PER_PAGE ); + + switch_to_blog( $this->source_blog_id ); + + // Cache key includes the source blog id so a non-blog-aware object + // cache backend can't leak ids from one switched-to blog to another. + $cache_params = array( __METHOD__, (string) $this->source_blog_id, $target_lang ); + $translated_ids = ( new TranslatedPostIdQuery( MslsSqlCacher::create( __CLASS__, $cache_params ) ) )( $target_lang ); + + $args = array( + 'post_type' => $this->post_type, + 'post_status' => array( 'publish', 'draft', 'pending', 'future' ), + 'posts_per_page' => $per_page, + 'paged' => $current_page, + 'post__not_in' => $translated_ids, + 'orderby' => 'date', + 'order' => 'DESC', + ); + + if ( '' !== $this->search ) { + $args['s'] = $this->search; + } + + $query = new \WP_Query( $args ); + $items = array(); + $taxonomies = array_keys( $this->get_admin_column_taxonomies() ); + + foreach ( $query->posts as $post ) { + $terms_by_tax = array(); + foreach ( $taxonomies as $tax_name ) { + $terms = get_the_terms( $post->ID, $tax_name ); + $terms_by_tax[ $tax_name ] = is_array( $terms ) ? wp_list_pluck( $terms, 'name' ) : array(); + } + + $items[] = array( + 'ID' => (int) $post->ID, + 'title' => get_the_title( $post ), + 'status' => $post->post_status, + 'date' => get_the_date( '', $post ), + // Preview URL for non-published statuses so reviewers + // can view drafts/pending without hitting a public 404. + 'permalink' => 'publish' === $post->post_status + ? get_permalink( $post ) + : get_preview_post_link( $post ), + 'author' => (string) get_the_author_meta( 'display_name', (int) $post->post_author ), + 'taxonomies' => $terms_by_tax, + ); + } + + $total = (int) $query->found_posts; + + restore_current_blog(); + + $this->items = $items; + + $this->set_pagination_args( + array( + 'total_items' => $total, + 'per_page' => $per_page, + 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 0, + ) + ); + } + + /** + * @param array $item + */ + protected function column_cb( $item ): string { + return sprintf( + '', + (int) $item['ID'] + ); + } + + /** + * @param array $item + */ + protected function column_title( $item ): string { + $title = '' . esc_html( $item['title'] ) . ''; + + $actions = array( + 'view' => sprintf( + '%2$s', + esc_url( (string) $item['permalink'] ), + esc_html__( 'View original', 'multisite-language-switcher' ) + ), + 'create' => sprintf( + '', + (int) $item['ID'], + $this->source_blog_id, + esc_html__( 'Create draft', 'multisite-language-switcher' ) + ), + ); + + return $title . $this->row_actions( $actions, true ); + } + + /** + * @param array $item + */ + protected function column_status( $item ): string { + $labels = array( + 'publish' => __( 'Published', 'multisite-language-switcher' ), + 'draft' => __( 'Draft', 'multisite-language-switcher' ), + 'pending' => __( 'Pending', 'multisite-language-switcher' ), + 'future' => __( 'Scheduled', 'multisite-language-switcher' ), + ); + $key = (string) $item['status']; + $label = $labels[ $key ] ?? $key; + + return sprintf( + '%2$s', + esc_attr( $key ), + esc_html( $label ) + ); + } + + /** + * @param array $item + */ + protected function column_date( $item ): string { + return esc_html( (string) $item['date'] ); + } + + /** + * @param array $item + */ + protected function column_author( $item ): string { + return '' !== (string) $item['author'] ? esc_html( (string) $item['author'] ) : '—'; + } + + /** + * Fallback for the dynamic taxonomy-* columns. + * + * @param array $item + * @param string $column_name + */ + protected function column_default( $item, $column_name ): string { + if ( 0 === strpos( $column_name, 'taxonomy-' ) ) { + $tax = substr( $column_name, strlen( 'taxonomy-' ) ); + $names = $item['taxonomies'][ $tax ] ?? array(); + return empty( $names ) ? '—' : esc_html( implode( ', ', $names ) ); + } + return '—'; + } + + /** + * Renders the empty-state message — distinguishes between "search + * returned nothing" and "every source post already has a translation". + */ + public function no_items(): void { + if ( '' !== $this->search ) { + esc_html_e( 'No posts match your search.', 'multisite-language-switcher' ); + } else { + esc_html_e( 'All source posts are already translated.', 'multisite-language-switcher' ); + } + } +} diff --git a/package.json b/package.json index af6b39ea3..335ffd507 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "scripts": { - "uglify": "uglifyjs src/msls.js > assets/js/msls.js && uglifyjs src/msls-quick-create.js > assets/js/msls-quick-create.js", + "uglify": "uglifyjs src/msls.js > assets/js/msls.js && uglifyjs src/msls-quick-create.js > assets/js/msls-quick-create.js && uglifyjs src/msls-translation-picker.js > assets/js/msls-translation-picker.js", "less": "lessc assets/css/msls.less assets/css/msls.css --clean-css=\"--s1 --advanced\"", "build-msls-block": "wp-scripts build --webpack-src-dir=src/msls-widget-block --output-path=assets/js/msls-widget-block", "build": "npm run uglify && npm run less && npm run build-msls-block", diff --git a/src/msls-translation-picker.js b/src/msls-translation-picker.js new file mode 100644 index 000000000..612b5937d --- /dev/null +++ b/src/msls-translation-picker.js @@ -0,0 +1,191 @@ +jQuery( document ).ready( + function ( $ ) { + var config = window.mslsTranslationPicker; + if ( ! config || ! config.targetBlogId ) { + return; + } + + var $form = $( '#msls-tp-form' ); + if ( ! $form.length ) { + return; + } + + var $notice = $( '' ); + $form.before( $notice ); + var $noticeMsg = $notice.find( 'p' ); + + function showNotice( text, cls ) { + $notice.removeClass( 'notice-info notice-success notice-error' ) + .addClass( cls || 'notice-info' ) + .removeAttr( 'hidden' ); + $noticeMsg.text( text ); + } + + function hideNotice() { + $notice.attr( 'hidden', 'hidden' ); + } + + function createOne( sourcePostId, sourceBlogId ) { + return wp.apiFetch( + { + path: '/msls/v1/create-translation', + method: 'POST', + data: { + source_post_id: sourcePostId, + source_blog_id: sourceBlogId, + target_blog_id: config.targetBlogId + } + } + ); + } + + $form.on( + 'click', + '.msls-tp-create', + function ( event ) { + event.preventDefault(); + var $btn = $( this ); + if ( $btn.prop( 'disabled' ) ) { + return; + } + + var sourcePostId = parseInt( $btn.data( 'source-post-id' ), 10 ); + var sourceBlogId = parseInt( $btn.data( 'source-blog-id' ), 10 ); + if ( ! sourcePostId || ! sourceBlogId ) { + return; + } + + $btn.prop( 'disabled', true ).addClass( 'msls-loading' ); + showNotice( config.i18n.creating ); + + createOne( sourcePostId, sourceBlogId ).then( + function ( response ) { + if ( response && response.edit_url ) { + window.location.href = response.edit_url; + } + } + ).catch( + function () { + $btn.prop( 'disabled', false ).removeClass( 'msls-loading' ); + showNotice( config.i18n.error, 'notice-error' ); + } + ); + } + ); + + function selectedRows() { + return $form.find( 'tbody input[type="checkbox"][name="post[]"]:checked' ); + } + + function runBulk( sourceBlogId ) { + var $checked = selectedRows(); + if ( ! $checked.length ) { + showNotice( config.i18n.noneChose, 'notice-warning' ); + return; + } + + var tasks = $checked.map( + function () { + return { + postId: parseInt( this.value, 10 ), + $button: $( this ).closest( 'tr' ).find( '.msls-tp-create' ) + }; + } + ).get(); + + var total = tasks.length; + var done = 0; + var errors = 0; + var completed = []; + + function step() { + if ( ! tasks.length ) { + showNotice( + config.i18n.completed.replace( '%1$d', done ).replace( '%2$d', errors ), + errors ? 'notice-warning' : 'notice-success' + ); + // Fade out successfully-created rows + completed.forEach( + function ( $row ) { + $row.css( 'opacity', 0.5 ); + } + ); + // Reload so the now-translated rows drop out of the list + // and pagination/totals reflect the new state. Brief + // delay lets the user read the completion notice. + if ( done > 0 ) { + window.setTimeout( + function () { + window.location.reload(); + }, + 1500 + ); + } + return; + } + + var task = tasks.shift(); + showNotice( + config.i18n.progress + .replace( '%1$d', done + errors + 1 ) + .replace( '%2$d', total ) + ); + + if ( task.$button.length ) { + task.$button.prop( 'disabled', true ).addClass( 'msls-loading' ); + } + + createOne( task.postId, sourceBlogId ).then( + function () { + done++; + if ( task.$button.length ) { + task.$button + .prop( 'disabled', true ) + .removeClass( 'msls-loading' ) + .addClass( 'msls-tp-done' ); + } + completed.push( task.$button.closest( 'tr' ) ); + step(); + } + ).catch( + function () { + errors++; + if ( task.$button.length ) { + task.$button.prop( 'disabled', false ).removeClass( 'msls-loading' ); + } + step(); + } + ); + } + + step(); + } + + // Derive the source blog id from any visible create button (all + // rows share the same source on the current page). + function currentSourceBlogId() { + var $any = $form.find( '.msls-tp-create' ).first(); + return $any.length ? parseInt( $any.data( 'source-blog-id' ), 10 ) : 0; + } + + $form.on( + 'submit', + function ( event ) { + var action = $form.find( 'select[name="action"]' ).val(); + if ( 'msls_bulk_create' !== action ) { + action = $form.find( 'select[name="action2"]' ).val(); + } + if ( 'msls_bulk_create' !== action ) { + return; + } + + event.preventDefault(); + var sourceBlogId = currentSourceBlogId(); + if ( ! sourceBlogId ) { + return; + } + runBulk( sourceBlogId ); + } + ); + } +); diff --git a/tests/phpunit/TestMslsRestApi.php b/tests/phpunit/TestMslsRestApi.php index 7f0f1a4c5..9389b2d92 100644 --- a/tests/phpunit/TestMslsRestApi.php +++ b/tests/phpunit/TestMslsRestApi.php @@ -2,6 +2,7 @@ namespace lloc\MslsTests; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; use lloc\Msls\MslsBlogCollection; use lloc\Msls\MslsRestApi; @@ -13,7 +14,7 @@ protected function setUp(): void { if ( ! class_exists( \WP_REST_Server::class ) ) { // phpcs:ignore - eval( 'class WP_REST_Server { const CREATABLE = "POST"; }' ); + eval( 'class WP_REST_Server { const CREATABLE = "POST"; const READABLE = "GET"; }' ); } if ( ! class_exists( \WP_REST_Response::class ) ) { @@ -56,6 +57,46 @@ public function test_check_permission_no_read_access(): void { $this->assertFalse( $api->check_permission( $request ) ); } + public function test_capability_filter_can_grant_access_without_read_cap(): void { + $request = \Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' )->with( 'source_blog_id' )->andReturn( 1 ); + $request->shouldReceive( 'get_param' )->with( 'source_post_id' )->andReturn( 10 ); + $request->shouldReceive( 'get_param' )->with( 'target_blog_id' )->andReturn( 2 ); + + Functions\expect( 'switch_to_blog' )->twice(); + Functions\expect( 'current_user_can' )->once()->with( 'read_post', 10 )->andReturn( false ); + Functions\expect( 'current_user_can' )->once()->with( 'edit_posts' )->andReturn( true ); + Functions\expect( 'restore_current_blog' )->twice(); + + Filters\expectApplied( 'msls_quick_create_capability' ) + ->with( false, 10, 1, 2, 'read' ) + ->andReturn( true ); + Filters\expectApplied( 'msls_quick_create_capability' ) + ->with( true, 10, 1, 2, 'create' ) + ->andReturn( true ); + + $api = new MslsRestApi(); + $this->assertTrue( $api->check_permission( $request ) ); + } + + public function test_capability_filter_can_deny_access_with_default_caps(): void { + $request = \Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' )->with( 'source_blog_id' )->andReturn( 1 ); + $request->shouldReceive( 'get_param' )->with( 'source_post_id' )->andReturn( 10 ); + $request->shouldReceive( 'get_param' )->with( 'target_blog_id' )->andReturn( 2 ); + + Functions\expect( 'switch_to_blog' )->once(); + Functions\expect( 'current_user_can' )->once()->with( 'read_post', 10 )->andReturn( true ); + Functions\expect( 'restore_current_blog' )->once(); + + Filters\expectApplied( 'msls_quick_create_capability' ) + ->with( true, 10, 1, 2, 'read' ) + ->andReturn( false ); + + $api = new MslsRestApi(); + $this->assertFalse( $api->check_permission( $request ) ); + } + public function test_check_permission_no_edit_access(): void { $request = \Mockery::mock( \WP_REST_Request::class ); $request->shouldReceive( 'get_param' )->with( 'source_blog_id' )->andReturn( 1 ); @@ -167,4 +208,136 @@ public function test_prefix_source_language_is_removable(): void { $this->assertTrue( $reflection->isPublic() ); $this->assertTrue( $reflection->isStatic() ); } + + public function test_check_list_permission_denied_when_cannot_read_source(): void { + $request = \Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' )->with( 'source_blog_id' )->andReturn( 1 ); + $request->shouldReceive( 'get_param' )->with( 'target_blog_id' )->andReturn( 2 ); + + Functions\expect( 'switch_to_blog' )->once()->with( 1 ); + Functions\expect( 'current_user_can' )->once()->with( 'read' )->andReturn( false ); + Functions\expect( 'restore_current_blog' )->once(); + + Filters\expectApplied( 'msls_quick_create_capability' ) + ->with( false, 0, 1, 2, 'read' ) + ->andReturn( false ); + + $api = new MslsRestApi(); + $this->assertFalse( $api->check_list_permission( $request ) ); + } + + public function test_check_list_permission_filter_can_grant_access(): void { + $request = \Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' )->with( 'source_blog_id' )->andReturn( 1 ); + $request->shouldReceive( 'get_param' )->with( 'target_blog_id' )->andReturn( 2 ); + + Functions\expect( 'switch_to_blog' )->twice(); + Functions\expect( 'current_user_can' )->once()->with( 'read' )->andReturn( false ); + Functions\expect( 'current_user_can' )->once()->with( 'edit_posts' )->andReturn( true ); + Functions\expect( 'restore_current_blog' )->twice(); + + Filters\expectApplied( 'msls_quick_create_capability' ) + ->with( false, 0, 1, 2, 'read' ) + ->andReturn( true ); + Filters\expectApplied( 'msls_quick_create_capability' ) + ->with( true, 0, 1, 2, 'create' ) + ->andReturn( true ); + + $api = new MslsRestApi(); + $this->assertTrue( $api->check_list_permission( $request ) ); + } + + public function test_list_untranslated_posts_returns_filtered_items(): void { + global $wpdb; + $wpdb = \Mockery::mock( \WPDB::class ); + $wpdb->options = 'wp_options'; + $wpdb->shouldReceive( 'prepare' )->andReturn( '' ); + $wpdb->shouldReceive( 'get_results' )->andReturn( array() ); + + Functions\when( 'wp_cache_get' )->justReturn( false ); + Functions\when( 'wp_cache_set' )->justReturn( true ); + + $request = \Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' )->with( 'source_blog_id' )->andReturn( 1 ); + $request->shouldReceive( 'get_param' )->with( 'target_blog_id' )->andReturn( 2 ); + $request->shouldReceive( 'get_param' )->with( 'post_type' )->andReturn( 'post' ); + $request->shouldReceive( 'get_param' )->with( 'search' )->andReturn( '' ); + + Functions\expect( 'get_blog_option' )->once()->andReturn( 'de_DE' ); + Functions\expect( 'switch_to_blog' )->once()->with( 1 ); + Functions\expect( 'post_type_exists' )->once()->with( 'post' )->andReturn( true ); + Functions\expect( 'restore_current_blog' )->once(); + + $post = new \stdClass(); + $post->ID = 42; + $post->post_status = 'publish'; + $post->post_date_gmt = '2026-04-20 12:00:00'; + + Functions\expect( 'get_posts' )->once()->andReturn( array( $post ) ); + Functions\expect( 'get_the_title' )->once()->with( $post )->andReturn( 'Original Title' ); + Functions\expect( 'mysql_to_rfc3339' )->once()->with( '2026-04-20 12:00:00' )->andReturn( '2026-04-20T12:00:00' ); + Functions\expect( 'get_permalink' )->once()->with( $post )->andReturn( 'https://example.tld/?p=42' ); + + $api = new MslsRestApi(); + $result = $api->list_untranslated_posts( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + $this->assertEquals( 200, $result->get_status() ); + + $data = $result->get_data(); + $this->assertEquals( 1, $data['total'] ); + $this->assertEquals( 42, $data['items'][0]['id'] ); + $this->assertEquals( 'Original Title', $data['items'][0]['title'] ); + $this->assertEquals( 'publish', $data['items'][0]['post_status'] ); + $this->assertEquals( '2026-04-20T12:00:00', $data['items'][0]['date_gmt'] ); + $this->assertEquals( 'https://example.tld/?p=42', $data['items'][0]['view_url'] ); + } + + public function test_remember_source_blog_writes_user_meta(): void { + Functions\expect( 'get_current_user_id' )->once()->andReturn( 7 ); + Functions\expect( 'update_user_meta' ) + ->once() + ->with( 7, MslsRestApi::LAST_SOURCE_USER_META, 3 ); + + MslsRestApi::remember_source_blog( 42, \Mockery::mock( \WP_Post::class ), 3 ); + + $this->assertTrue( true ); + } + + public function test_remember_source_blog_skips_guest(): void { + Functions\expect( 'get_current_user_id' )->once()->andReturn( 0 ); + Functions\expect( 'update_user_meta' )->never(); + + MslsRestApi::remember_source_blog( 42, \Mockery::mock( \WP_Post::class ), 3 ); + + $this->assertTrue( true ); + } + + public function test_get_last_source_blog_id_returns_value(): void { + Functions\expect( 'get_current_user_id' )->once()->andReturn( 7 ); + Functions\expect( 'get_user_meta' ) + ->once() + ->with( 7, MslsRestApi::LAST_SOURCE_USER_META, true ) + ->andReturn( '5' ); + + $this->assertSame( 5, MslsRestApi::get_last_source_blog_id() ); + } + + public function test_list_untranslated_posts_rejects_unknown_post_type(): void { + $request = \Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' )->with( 'source_blog_id' )->andReturn( 1 ); + $request->shouldReceive( 'get_param' )->with( 'target_blog_id' )->andReturn( 2 ); + $request->shouldReceive( 'get_param' )->with( 'post_type' )->andReturn( 'nonsense' ); + $request->shouldReceive( 'get_param' )->with( 'search' )->andReturn( '' ); + + Functions\expect( 'get_blog_option' )->once()->andReturn( 'de_DE' ); + Functions\expect( 'switch_to_blog' )->once()->with( 1 ); + Functions\expect( 'post_type_exists' )->once()->with( 'nonsense' )->andReturn( false ); + Functions\expect( 'restore_current_blog' )->once(); + + $api = new MslsRestApi(); + $result = $api->list_untranslated_posts( $request ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } } diff --git a/tests/phpunit/TestMslsTranslationPickerPage.php b/tests/phpunit/TestMslsTranslationPickerPage.php new file mode 100644 index 000000000..439804ddd --- /dev/null +++ b/tests/phpunit/TestMslsTranslationPickerPage.php @@ -0,0 +1,92 @@ +assertSame( 'msls-translation-picker-post', MslsTranslationPickerPage::page_slug( 'post' ) ); + $this->assertSame( 'msls-translation-picker-page', MslsTranslationPickerPage::page_slug( 'page' ) ); + $this->assertSame( 'msls-translation-picker-event', MslsTranslationPickerPage::page_slug( 'event' ) ); + } + + public function test_parent_slug_for_built_in_post(): void { + $this->assertSame( 'edit.php', MslsTranslationPickerPage::parent_slug( 'post' ) ); + } + + public function test_parent_slug_for_other_post_types(): void { + $this->assertSame( 'edit.php?post_type=page', MslsTranslationPickerPage::parent_slug( 'page' ) ); + $this->assertSame( 'edit.php?post_type=event', MslsTranslationPickerPage::parent_slug( 'event' ) ); + } + + public function test_parent_slug_for_empty_post_type(): void { + $this->assertSame( '', MslsTranslationPickerPage::parent_slug( '' ) ); + } + + public function test_url_uses_admin_url_and_query_arg(): void { + Functions\expect( 'admin_url' ) + ->once() + ->with( 'edit.php' ) + ->andReturn( 'https://example.tld/wp-admin/edit.php' ); + + Functions\expect( 'add_query_arg' ) + ->once() + ->andReturnUsing( + function ( $args, $url ) { + return $url . '?' . http_build_query( $args ); + } + ); + + $result = MslsTranslationPickerPage::url( 'post' ); + + $this->assertSame( + 'https://example.tld/wp-admin/edit.php?page=msls-translation-picker-post', + $result + ); + } + + public function test_url_for_non_post_post_type_routes_through_typed_parent(): void { + Functions\expect( 'admin_url' ) + ->once() + ->with( 'edit.php?post_type=page' ) + ->andReturn( 'https://example.tld/wp-admin/edit.php?post_type=page' ); + + Functions\expect( 'add_query_arg' ) + ->once() + ->andReturnUsing( + function ( $args, $url ) { + return $url . '&' . http_build_query( $args ); + } + ); + + $result = MslsTranslationPickerPage::url( 'page' ); + + $this->assertSame( + 'https://example.tld/wp-admin/edit.php?post_type=page&page=msls-translation-picker-page', + $result + ); + } + + public function test_save_per_page_option_returns_int_for_picker_option(): void { + $this->assertSame( + 42, + MslsTranslationPickerPage::save_per_page_option( false, 'msls_tp_per_page', '42' ) + ); + } + + public function test_save_per_page_option_falls_back_for_non_positive(): void { + $this->assertSame( + MslsTranslationPickerPage::PER_PAGE_DEFAULT, + MslsTranslationPickerPage::save_per_page_option( false, 'msls_tp_per_page', '0' ) + ); + } + + public function test_save_per_page_option_passes_through_other_options(): void { + $this->assertFalse( + MslsTranslationPickerPage::save_per_page_option( false, 'unrelated_option', '5' ) + ); + } +}