\d+)', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_unlink' ), 'permission_callback' => function ( $request ) { return self::user_can_unlink( (int) $request['id'] ); }, 'args' => array( 'id' => array( 'validate_callback' => array( 'Duolangue_Linker', 'is_id' ) ), ), ) ); register_rest_route( 'duolangue/v1', '/link-candidates/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_link_candidates' ), 'permission_callback' => function ( $request ) { return current_user_can( 'edit_post', (int) $request['id'] ); }, 'args' => array( 'id' => array( 'validate_callback' => array( 'Duolangue_Linker', 'is_id' ) ), ), ) ); register_rest_route( 'duolangue/v1', '/link/(?P\d+)', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_link' ), 'permission_callback' => function ( $request ) { return self::user_can_link( (int) $request['id'], (int) $request['target'] ); }, 'args' => array( 'id' => array( 'validate_callback' => array( 'Duolangue_Linker', 'is_id' ) ), 'target' => array( 'required' => true, 'validate_callback' => array( 'Duolangue_Linker', 'is_id' ) ), ), ) ); } /* ------------------------------------------------------------------ * Capabilities * ---------------------------------------------------------------- */ /** REST arg validator: a positive integer ID. */ public static function is_id( $value ) { return is_numeric( $value ) && (int) $value > 0; } /** May the current user link these two posts (both are written to)? */ public static function user_can_link( $post_id, $target_id ) { return $post_id && $target_id && current_user_can( 'edit_post', $post_id ) && current_user_can( 'edit_post', $target_id ); } /** May the current user unlink this post (its linked post is written to too)? */ public static function user_can_unlink( $post_id ) { if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { return false; } $linked = duolangue_get_post_translation( $post_id ); return ! $linked || ! get_post( $linked ) || current_user_can( 'edit_post', $linked ); } /** Unlinked posts of the same post type in the other language. */ public static function link_candidates( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return array(); } $lang = duolangue_get_post_lang( $post_id ); $codes = wp_list_pluck( duolangue_get_languages(), 'code' ); $other = array_values( array_diff( $codes, array( $lang ) ) ); $other_lang = $other ? $other[0] : ''; $is_default = $other_lang === duolangue_default_lang()['code']; $clause = $is_default ? array( 'relation' => 'OR', array( 'key' => '_duolangue_lang', 'value' => $other_lang ), array( 'key' => '_duolangue_lang', 'compare' => 'NOT EXISTS' ), ) : array( array( 'key' => '_duolangue_lang', 'value' => $other_lang ) ); $query = new WP_Query( array( 'post_type' => $post->post_type, 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => 201, 'orderby' => 'title', 'order' => 'ASC', 'duolangue_lang' => 'all', // Bypass the front-end language filter (REST context). 'meta_query' => $clause, // phpcs:ignore WordPress.DB.SlowDBQuery 'no_found_rows' => true, ) ); $out = array(); foreach ( $query->posts as $candidate ) { if ( (int) $candidate->ID === (int) $post_id ) { continue; } if ( duolangue_get_post_translation( $candidate->ID ) ) { continue; // Already linked to something else. } if ( ! current_user_can( 'edit_post', $candidate->ID ) ) { continue; // Linking writes to the candidate: only offer editable ones. } $out[] = array( 'id' => $candidate->ID, 'title' => get_the_title( $candidate ) ?: __( '(no title)', 'duolangue' ), ); } return $out; } public function rest_link_candidates( $request ) { return rest_ensure_response( self::link_candidates( (int) $request['id'] ) ); } /** Link two EXISTING posts as translations of each other. */ public static function link_posts( $post_id, $target_id ) { $post = get_post( $post_id ); $target = get_post( $target_id ); if ( ! $post || ! $target || $post->post_type !== $target->post_type || $post_id === $target_id ) { return new WP_Error( 'duolangue_bad_target', __( 'These two items cannot be linked.', 'duolangue' ), array( 'status' => 400 ) ); } if ( duolangue_get_post_translation( $post_id ) || duolangue_get_post_translation( $target_id ) ) { return new WP_Error( 'duolangue_already_linked', __( 'A linked translation already exists.', 'duolangue' ), array( 'status' => 409 ) ); } if ( duolangue_get_post_lang( $post_id ) === duolangue_get_post_lang( $target_id ) ) { return new WP_Error( 'duolangue_same_lang', __( 'Both items are in the same language.', 'duolangue' ), array( 'status' => 400 ) ); } update_post_meta( $post_id, '_duolangue_translation_of', $target_id ); update_post_meta( $target_id, '_duolangue_translation_of', $post_id ); return true; } public function rest_link( $request ) { $result = self::link_posts( (int) $request['id'], (int) $request['target'] ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( array( 'linked' => true, 'id' => (int) $request['target'] ) ); } public function rest_unlink( $request ) { self::unlink_post( (int) $request['id'] ); return rest_ensure_response( array( 'unlinked' => true ) ); } /** Break the link between a post and its translation (both sides). */ public static function unlink_post( $post_id ) { $linked = duolangue_get_post_translation( $post_id ); delete_post_meta( $post_id, '_duolangue_translation_of' ); if ( $linked ) { delete_post_meta( $linked, '_duolangue_translation_of' ); } } /** Nonce-protected admin URL to unlink a pair. */ public static function unlink_action_url( $post_id ) { return wp_nonce_url( admin_url( 'admin-post.php?action=duolangue_unlink&post=' . (int) $post_id ), 'duolangue_unlink_' . (int) $post_id ); } public function handle_admin_unlink() { $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; check_admin_referer( 'duolangue_unlink_' . $post_id ); if ( ! self::user_can_unlink( $post_id ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'duolangue' ), '', array( 'response' => 403 ) ); } self::unlink_post( $post_id ); $back = wp_get_referer() ?: get_edit_post_link( $post_id, 'url' ); wp_safe_redirect( $back ?: admin_url( 'edit.php' ) ); exit; } /* ------------------------------------------------------------------ * Gutenberg panel * ---------------------------------------------------------------- */ public function enqueue_editor_panel() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( $screen && ! in_array( $screen->post_type, duolangue_post_types(), true ) ) { return; } wp_enqueue_script( 'duolangue-editor', DUOLANGUE_URL . 'assets/js/editor.js', array( 'wp-plugins', 'wp-editor', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-core-data', 'wp-i18n', 'wp-api-fetch', 'wp-hooks' ), DUOLANGUE_VERSION, true ); wp_localize_script( 'duolangue-editor', 'DuolangueEditor', array( 'languages' => duolangue_get_languages(), 'defaultLang' => duolangue_default_lang()['code'], 'editUrlBase' => admin_url( 'post.php?action=edit&post=' ), ) ); wp_set_script_translations( 'duolangue-editor', 'duolangue' ); } /* ------------------------------------------------------------------ * Classic-editor fallback metabox * ---------------------------------------------------------------- */ public function register_classic_metabox() { if ( ! function_exists( 'use_block_editor_for_post_type' ) ) { return; } foreach ( duolangue_post_types() as $post_type ) { if ( use_block_editor_for_post_type( $post_type ) ) { continue; // Gutenberg panel handles it. } add_meta_box( 'duolangue-language', __( 'Duolangue — Language', 'duolangue' ), array( $this, 'render_classic_metabox' ), $post_type, 'side', 'high' ); } } public function render_classic_metabox( $post ) { wp_nonce_field( 'duolangue_metabox', 'duolangue_metabox_nonce' ); $lang = duolangue_get_post_lang( $post->ID ); $linked = duolangue_get_post_translation( $post->ID ); $linked_post = $linked ? get_post( $linked ) : null; $other = $lang === duolangue_default_lang()['code'] ? duolangue_secondary_lang() : duolangue_default_lang(); ?>


post_status ) : ?> ID ); ?>