diff --git a/assets/css/msls.css b/assets/css/msls.css index 85310606..a9ad0fa8 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}.msls-quick-create{background:none;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(0deg)}to{transform:rotate(360deg)}} \ 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 .msls-tp-view{text-decoration:none;margin-right:10px;white-space:nowrap}.msls-tp-page table.wp-list-table .msls-tp-view:hover{text-decoration:underline}.msls-tp-page table.wp-list-table .msls-tp-create.msls-loading{opacity:.7;pointer-events:none}.msls-tp-page table.wp-list-table .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-page table.wp-list-table .column-actions{width:260px;text-align:right}.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} \ No newline at end of file diff --git a/assets/css/msls.less b/assets/css/msls.less index e10339aa..36736d99 100644 --- a/assets/css/msls.less +++ b/assets/css/msls.less @@ -24,10 +24,38 @@ select.msls-translations { input.msls_title, select { flex-grow: 1; } + .msls-create-new, + .msls-edit-link { + text-decoration: none; + margin-left: 4px; + color: #2271b1; + &:hover { + color: #135e96; + } + } } } } +.msls-quick-create { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + font: inherit; + line-height: inherit; + &.msls-loading .dashicons { + animation: msls-spin 1s linear infinite; + } +} + +@keyframes msls-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + #msls-content-import { .button-primary { margin: 1em auto; @@ -120,6 +148,197 @@ 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 { + .msls-tp-view { + text-decoration: none; + margin-right: 10px; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } + } + + .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; + } + + .column-actions { + width: 260px; + text-align: right; + } + } +} + +.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 736fd9d5..c6763a2e 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($("', + esc_url( $href ), + esc_attr( $title ) + ); + } + } + + if ( msls_options()->activate_quick_create ) { + $action_icon = ( new MslsAdminIcon( $type ) ) + ->set_language( $language ) + ->set_icon_type( 'action' ) + ->set_id( $post_id ) + ->set_origin_language( $origin_language ); + + return $action_icon->get_a(); + } + + $action_icon = ( new MslsAdminIcon( $type ) ) + ->set_language( $language ) + ->set_icon_type( 'action' ) + ->set_id( $post_id ) + ->set_origin_language( $origin_language ); + + $href = $action_icon->get_edit_new(); + + $title = sprintf( + /* translators: %s: language code */ + __( 'Create a new translation in the %s-blog', 'multisite-language-switcher' ), + $language + ); + + return sprintf( + '', + esc_url( $href ), + esc_attr( $title ) + ); + } + /** * Set * diff --git a/includes/MslsPlugin.php b/includes/MslsPlugin.php index 365e47f5..f8858073 100644 --- a/includes/MslsPlugin.php +++ b/includes/MslsPlugin.php @@ -58,10 +58,12 @@ public static function init(): void { if ( is_admin() ) { add_action( 'admin_menu', array( MslsAdmin::class, 'init' ) ); + MslsTranslationPickerPage::init(); add_action( 'load-post.php', array( MslsMetaBox::class, 'init' ) ); add_action( 'load-post-new.php', array( MslsMetaBox::class, 'init' ) ); add_action( 'load-edit.php', array( MslsCustomColumn::class, 'init' ) ); add_action( 'load-edit.php', array( MslsCustomFilter::class, 'init' ) ); + add_action( 'load-edit.php', array( MslsPostListActions::class, 'init' ) ); add_action( 'load-edit-tags.php', array( MslsCustomColumnTaxonomy::class, 'init' ) ); add_action( 'load-edit-tags.php', array( MslsPostTag::class, 'init' ) ); diff --git a/includes/MslsPostListActions.php b/includes/MslsPostListActions.php new file mode 100644 index 00000000..6c6c783f --- /dev/null +++ b/includes/MslsPostListActions.php @@ -0,0 +1,82 @@ +is_excluded() ) { + return; + } + + $post_type = msls_post_type()->get_request(); + if ( empty( $post_type ) ) { + return; + } + + if ( ! current_user_can( 'edit_posts' ) ) { + return; + } + + if ( ! self::has_source_blogs() ) { + return; + } + + add_action( 'admin_enqueue_scripts', array( self::class, 'inject_button' ) ); + } + + /** + * Returns true when at least one other blog has MSLS active and could + * therefore serve as a translation source. + */ + public static function has_source_blogs(): bool { + $collection = msls_blog_collection(); + + foreach ( $collection->get() as $blog ) { + if ( $collection->is_plugin_active( $blog->userblog_id ) ) { + return true; + } + } + + return false; + } + + /** + * Prints an inline script that appends the link next to core's "Add New". + * + * WordPress has no server-side hook at the page-title-action slot, so + * this is the standard pattern used by many admin plugins. + * + * @codeCoverageIgnore + */ + public static function inject_button(): void { + $post_type = msls_post_type()->get_request(); + $url = MslsTranslationPickerPage::url( $post_type ); + $label = __( 'Add from Translation', 'multisite-language-switcher' ); + + $script = sprintf( + 'jQuery(function($){var b=$("").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 17d7002a..eaf34fbe 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,35 @@ private static function get_route_args(): array { ); } + /** + * @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 +153,106 @@ 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 ); + } + + /** + * @param bool $default + * @param int $source_post_id + * @param int $source_blog_id + * @param int $target_blog_id + * @param string $context + * + * @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 + ); } /** @@ -156,14 +328,16 @@ public function create_translation( \WP_REST_Request $request ) { */ 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' ); + $edit_url = get_edit_post_link( $new_post_id, 'raw' ); + $post_title = get_the_title( $new_post_id ); 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, + 'post_id' => $new_post_id, + 'edit_url' => $edit_url, + 'post_title' => $post_title, ); /** @@ -189,6 +363,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 00000000..2349f1a3 --- /dev/null +++ b/includes/MslsTranslationPickerPage.php @@ -0,0 +1,375 @@ + $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 ] ); + + $rebuilt = array(); + $placed = false; + foreach ( $submenu[ $parent ] as $k => $item ) { + $rebuilt[ $k ] = $item; + if ( ! $placed ) { + $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' ), + 'progress' => __( 'Creating drafts: %1$d of %2$d…', 'multisite-language-switcher' ), + '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(); + + echo '
'; + wp_nonce_field( 'msls_tp_bulk', 'msls_tp_nonce' ); + $table->display(); + echo '
'; + } +} diff --git a/includes/MslsTranslationPickerTable.php b/includes/MslsTranslationPickerTable.php new file mode 100644 index 00000000..383c458e --- /dev/null +++ b/includes/MslsTranslationPickerTable.php @@ -0,0 +1,255 @@ +|null */ + protected ?array $taxonomies_cache = null; + + 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::SLUG, + ) + ); + + $this->source_blog_id = $source_blog_id; + $this->post_type = $post_type; + $this->search = $search; + } + + 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' ); + $cols['actions'] = __( 'Actions', '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; + } + + switch_to_blog( $this->source_blog_id ); + $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; + } + } + restore_current_blog(); + + return $this->taxonomies_cache; + } + + protected function get_bulk_actions(): array { + return array( + 'msls_bulk_create' => __( 'Create drafts for selected', 'multisite-language-switcher' ), + ); + } + + public function prepare_items(): void { + $this->_column_headers = array( $this->get_columns(), array(), array() ); + + $target_lang = MslsBlogCollection::get_blog_language( get_current_blog_id() ); + $current_page = $this->get_pagenum(); + + switch_to_blog( $this->source_blog_id ); + + $translated_ids = ( new TranslatedPostIdQuery( MslsSqlCacher::create( __CLASS__, __METHOD__ ) ) )( $target_lang ); + + $args = array( + 'post_type' => $this->post_type, + 'post_status' => array( 'publish', 'draft', 'pending', 'future' ), + 'posts_per_page' => self::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 ), + 'permalink' => get_permalink( $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' => self::PER_PAGE, + 'total_pages' => (int) ceil( $total / self::PER_PAGE ), + ) + ); + } + + /** + * @param array $item + */ + protected function column_cb( $item ): string { + return sprintf( + '', + (int) $item['ID'] + ); + } + + /** + * @param array $item + */ + protected function column_title( $item ): string { + return '' . esc_html( $item['title'] ) . ''; + } + + /** + * @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 ''; + } + + /** + * @param array $item + */ + protected function column_actions( $item ): string { + $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 $view . ' ' . $create; + } + + 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 d529501f..ca818ae4 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-quick-create.js b/src/msls-quick-create.js index 36771c71..b2a2debe 100644 --- a/src/msls-quick-create.js +++ b/src/msls-quick-create.js @@ -25,12 +25,18 @@ jQuery( document ).ready( } ).then( function ( response ) { + var isMetabox = $button.closest( '#msls' ).length > 0; 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' ); + 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 ); @@ -44,10 +50,15 @@ jQuery( document ).ready( $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( - $( ''; + $expected = ''; $this->expectOutputString( $expected ); $this->MslsMetaBoxFactory()->render_select(); @@ -234,15 +240,18 @@ public function test_render_select_hierarchical(): void { Functions\expect( 'get_post_type' )->once()->andReturn( 'page' ); Functions\expect( 'get_option' )->once()->andReturn( array( 'de_DE' => 42 ) ); + Functions\expect( 'get_blog_option' )->once()->andReturn( '' ); + Functions\expect( 'get_post_status' )->once()->andReturn( 'draft' ); Functions\expect( 'wp_nonce_field' )->once()->andReturn( 'nonce_field' ); Functions\expect( 'switch_to_blog' )->once(); Functions\expect( 'restore_current_blog' )->once(); Functions\expect( 'add_query_arg' )->once()->andReturn( 'query_args' ); Functions\expect( 'get_post_type_object' )->once()->andReturn( $wp_post_type ); Functions\expect( 'wp_dropdown_pages' )->once()->andReturn( '' ); - Functions\expect( 'get_edit_post_link' )->once()->andReturn( 'edit-post-link' ); + Functions\expect( 'get_edit_post_link' )->twice()->andReturn( 'edit-post-link' ); + Functions\expect( 'get_current_blog_id' )->once()->andReturn( 1 ); - $expected = ''; + $expected = ''; $this->expectOutputString( $expected ); $this->MslsMetaBoxFactory()->render_select(); @@ -250,35 +259,42 @@ public function test_render_select_hierarchical(): void { public static function render_input_provider(): array { return array( - array( array( 'de_DE' => 42 ), 1, 0, 0, 1, '' ), - array( array( 'en_US' => 17 ), 0, 1, 1, 0, '' ), + array( array( 'de_DE' => 42 ), 1, 1, 0, 2, 0, '' ), + array( array( 'en_US' => 17 ), 0, 3, 2, 0, 2, '' ), ); } /** * @dataProvider render_input_provider */ - public function test_render_input( $option, $the_title_times, $current_blog_id_times, $admin_url_times, $edit_post_link_times, $expected ) { + public function test_render_input( $option, $the_title_times, $current_blog_id_times, $admin_url_times, $edit_post_link_times, $add_query_arg_times, $expected ) { global $post; $post = \Mockery::mock( 'WP_Post' ); $post->ID = 42; + $options = \Mockery::mock( MslsOptions::class ); + $options->activate_quick_create = false; + $post_type = \Mockery::mock( MslsPostType::class ); $post_type->shouldReceive( 'is_taxonomy' )->once()->andReturnFalse(); $post_type->shouldReceive( 'get_request' )->once()->andReturn( 'post' ); Functions\expect( 'msls_content_types' )->once()->andReturn( $post_type ); + Functions\expect( 'msls_options' )->atMost()->times( 1 )->andReturn( $options ); Functions\expect( 'switch_to_blog' )->once(); Functions\expect( 'restore_current_blog' )->once(); Functions\expect( 'get_post_type' )->once()->andReturn( 'page' ); Functions\expect( 'get_option' )->once()->andReturn( $option ); + Functions\expect( 'get_blog_option' )->once()->andReturn( '' ); + Functions\expect( 'get_post_status' )->once()->andReturn( 'draft' ); Functions\expect( 'wp_nonce_field' )->once()->andReturn( 'nonce_field' ); Functions\expect( 'get_the_title' )->times( $the_title_times )->andReturn( 'Test' ); Functions\expect( 'get_current_blog_id' )->times( $current_blog_id_times )->andReturn( 1 ); Functions\expect( 'get_admin_url' )->times( $admin_url_times )->andReturn( 'admin-url-empty' ); Functions\expect( 'get_edit_post_link' )->times( $edit_post_link_times )->andReturn( 'edit-post-link' ); + Functions\expect( 'add_query_arg' )->times( $add_query_arg_times )->andReturn( 'query_args' ); $this->expectOutputString( $expected ); diff --git a/tests/phpunit/TestMslsRestApi.php b/tests/phpunit/TestMslsRestApi.php index 0b195ada..9389b2d9 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 ); @@ -107,6 +148,7 @@ public function test_create_translation_success(): void { Functions\expect( 'post_type_exists' )->once()->with( 'post' )->andReturn( true ); Functions\expect( 'wp_insert_post' )->once()->andReturn( 42 ); Functions\expect( 'get_edit_post_link' )->once()->with( 42, 'raw' )->andReturn( 'https://example.tld/wp-admin/post.php?post=42&action=edit' ); + Functions\expect( 'get_the_title' )->once()->with( 42 )->andReturn( 'Test Title' ); Functions\expect( 'get_option' )->andReturn( array() ); Functions\expect( 'add_option' )->andReturn( true ); @@ -135,6 +177,7 @@ function ( $hook, $value ) { $data = $result->get_data(); $this->assertEquals( 42, $data['post_id'] ); $this->assertEquals( 'https://example.tld/wp-admin/post.php?post=42&action=edit', $data['edit_url'] ); + $this->assertEquals( 'Test Title', $data['post_title'] ); } public function test_prefix_source_language(): void { @@ -165,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 ); + } }