Polylang主题集成翻译模块

<?php
/**
 * Polylang REST API Extension - FIXED VERSION
 * 
 * Fixes:
 * 1. Validates target_lang is a real Polylang language before creating any post
 * 2. Ensures source post has a language assigned before creating translation
 * 3. Wraps post creation in a transaction pattern (delete on failure)
 * 4. Adds cleanup endpoint to fix orphaned posts (no language assigned)
 * 5. Adds assign-language endpoint to batch-assign language to languageless posts
 */
class Polylang_REST_API_Extension
{

    private $namespace = 'polylang-api/v1';

    public function __construct()
    {
        add_action('rest_api_init', array($this, 'register_routes'));
    }

    /**
     * Register all REST API routes
     */
    public function register_routes()
    {
        // Get all translatable post types
        register_rest_route($this->namespace, '/post-types', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_post_types'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // Get untranslated posts
        register_rest_route($this->namespace, '/untranslated-posts', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_untranslated_posts'),
            'permission_callback' => array($this, 'check_permission'),
            'args' => array(
                'post_type' => array(
                    'default' => 'post',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
                'source_lang' => array(
                    'default' => 'en',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
                'target_lang' => array(
                    'required' => true,
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        ));

        // Get post content for translation
        register_rest_route($this->namespace, '/post/(?P<id>\d+)', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_post_content'),
            'permission_callback' => array($this, 'check_permission'),
            'args' => array(
                'id' => array(
                    'validate_callback' => function ($param) {
                        return is_numeric($param);
                    }
                ),
            ),
        ));

        // Create translated post
        register_rest_route($this->namespace, '/translate-post', array(
            'methods' => 'POST',
            'callback' => array($this, 'create_translated_post'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // Get languages
        register_rest_route($this->namespace, '/languages', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_languages'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // Get translation status
        register_rest_route($this->namespace, '/translation-status/(?P<id>\d+)', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_translation_status'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // ========== NEW ENDPOINTS ==========

        // Find orphaned posts (posts with no language)
        register_rest_route($this->namespace, '/orphaned-posts', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_orphaned_posts'),
            'permission_callback' => array($this, 'check_admin_permission'),
            'args' => array(
                'action' => array(
                    'default' => 'list',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        ));

        // Cleanup orphaned posts (delete posts with no language)
        register_rest_route($this->namespace, '/cleanup-orphaned', array(
            'methods' => 'POST',
            'callback' => array($this, 'cleanup_orphaned_posts'),
            'permission_callback' => array($this, 'check_admin_permission'),
            'args' => array(
                'mode' => array(
                    'default' => 'preview',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        ));
    }

    /**
     * Permission callback - edit_posts
     */
    public function check_permission()
    {
        return current_user_can('edit_posts');
    }

    /**
     * Admin permission callback - manage_options
     */
    public function check_admin_permission()
    {
        return current_user_can('manage_options');
    }

    /**
     * Validate that a language slug is a valid Polylang language
     */
    private function is_valid_language($lang_slug)
    {
        if (empty($lang_slug) || !is_string($lang_slug)) {
            return false;
        }

        if (!function_exists('pll_languages_list')) {
            return false;
        }

        $valid_languages = pll_languages_list(array('fields' => 'slug'));
        return in_array($lang_slug, $valid_languages, true);
    }

    /**
     * Get all public post types that support translation
     */
    public function get_post_types()
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $post_types = get_post_types(array(
            'public' => true,
        ), 'objects');

        unset($post_types['attachment'], $post_types['video'], $post_types['page']);

        $translatable_types = array();

        foreach ($post_types as $post_type) {
            if (function_exists('pll_is_translated_post_type') && pll_is_translated_post_type($post_type->name)) {
                $translatable_types[] = array(
                    'name' => $post_type->name,
                    'label' => $post_type->label,
                    'singular_name' => $post_type->labels->singular_name,
                );
            }
        }

        return rest_ensure_response(array(
            'success' => true,
            'post_types' => array_column($translatable_types, 'name'),
            'post_types_details' => $translatable_types,
        ));
    }

    /**
     * Get all untranslated posts for a specific language
     */
    public function get_untranslated_posts($request)
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $post_type = $request->get_param('post_type');
        $source_lang = $request->get_param('source_lang');
        $target_lang = $request->get_param('target_lang');

        // ⭐ FIX: Validate both languages
        if (!$this->is_valid_language($source_lang)) {
            return new WP_Error(
                'invalid_source_lang',
                "Source language '$source_lang' is not a valid Polylang language",
                array('status' => 400)
            );
        }

        if (!$this->is_valid_language($target_lang)) {
            return new WP_Error(
                'invalid_target_lang',
                "Target language '$target_lang' is not a valid Polylang language",
                array('status' => 400)
            );
        }

        $args = array(
            'post_type' => $post_type,
            'posts_per_page' => -1,
            'lang' => $source_lang,
            'post_status' => 'publish',
        );

        $posts = get_posts($args);
        $untranslated = array();

        foreach ($posts as $post) {
            $translation_id = pll_get_post($post->ID, $target_lang);

            if (!$translation_id) {
                $untranslated[] = array(
                    'id' => $post->ID,
                    'title' => $post->post_title,
                    'slug' => $post->post_name,
                    'status' => $post->post_status,
                    'date' => $post->post_date,
                    'modified' => $post->post_modified,
                    'post_type' => $post->post_type,
                    'language' => $source_lang,
                );
            }
        }

        return rest_ensure_response(array(
            'success' => true,
            'count' => count($untranslated),
            'posts' => $untranslated,
        ));
    }

    /**
     * Get full post content including all meta fields
     */
    public function get_post_content($request)
    {
        $post_id = $request->get_param('id');
        $post = get_post($post_id);

        if (!$post) {
            return new WP_Error('post_not_found', 'Post not found', array('status' => 404));
        }

        $post_meta = get_post_meta($post_id);

        $clean_meta = array();
        foreach ($post_meta as $key => $value) {
            if (substr($key, 0, 1) === '_')
                continue;

            $meta_value = is_array($value) ? $value[0] : $value;

            if (!is_string($meta_value) || $this->is_serialized($meta_value))
                continue;

            $clean_meta[$key] = $meta_value;
        }

        $categories = wp_get_post_categories($post_id, array('fields' => 'all'));
        $tags = wp_get_post_tags($post_id, array('fields' => 'all'));

        $custom_taxonomies = array();
        $taxonomies = get_object_taxonomies($post->post_type, 'objects');
        foreach ($taxonomies as $taxonomy) {
            if (!in_array($taxonomy->name, array('category', 'post_tag', 'language', 'post_translations'))) {
                $terms = wp_get_post_terms($post_id, $taxonomy->name);
                if (!is_wp_error($terms) && !empty($terms)) {
                    $custom_taxonomies[$taxonomy->name] = $terms;
                }
            }
        }

        $thumbnail_id = get_post_thumbnail_id($post_id);
        $thumbnail_url = $thumbnail_id ? wp_get_attachment_url($thumbnail_id) : null;

        $acf_fields = array();
        if (function_exists('get_fields')) {
            $acf_fields = get_fields($post_id) ?: array();
        }

        $current_lang = function_exists('pll_get_post_language') ? pll_get_post_language($post_id) : null;

        return rest_ensure_response(array(
            'success' => true,
            'post' => array(
                'id' => $post->ID,
                'title' => $post->post_title,
                'content' => $post->post_content,
                'excerpt' => $post->post_excerpt,
                'slug' => $post->post_name,
                'status' => $post->post_status,
                'type' => $post->post_type,
                'language' => $current_lang,
                'featured_image' => $thumbnail_url,
                'thumbnail_id' => $thumbnail_id,
                'categories' => $categories,
                'tags' => $tags,
                'custom_taxonomies' => $custom_taxonomies,
                'meta' => $clean_meta,
                'acf_fields' => $acf_fields,
            ),
        ));
    }

    /**
     * Create or update translated post - HARDENED VERSION
     * 
     * Key safety measures:
     * 1. Validates target_lang is a real Polylang language BEFORE creating any post
     * 2. Ensures source post exists and has a language assigned
     * 3. Verifies language was actually set after pll_set_post_language()
     * 4. Deletes the newly created post if language assignment fails
     */

/**
 * WHAT CHANGED vs original and WHY
 * 
 * Original code had two hardcoded blocks:
 *   if (isset($params['categories'])) → wp_set_post_categories()   [only works for 'category']
 *   if (isset($params['tags']))       → wp_set_post_tags()         [only works for 'post_tag']
 * 
 * Your products use 'product_category' taxonomy — both blocks silently skipped it.
 * 
 * Fix: n8n now sends categories as:
 *   [{ "term_id": 1539, "taxonomy": "product_category" }, ...]
 * 
 * PHP groups them by taxonomy slug and calls wp_set_object_terms() per group,
 * which works for ANY taxonomy — category, product_category, or any custom one.
 * translate_term() is unchanged; it already uses pll_get_term() correctly.
 */

public function create_translated_post( $request ) {
    $params = $request->get_json_params();

    if ( ! isset( $params['source_post_id'] ) || ! isset( $params['target_lang'] ) ) {
        return new WP_Error( 'missing_params', 'Missing required parameters: source_post_id and target_lang', array( 'status' => 400 ) );
    }

    $source_post_id = intval( $params['source_post_id'] );
    $target_lang    = sanitize_text_field( $params['target_lang'] );

    if ( ! $this->is_valid_language( $target_lang ) ) {
        $valid_langs = implode( ', ', pll_languages_list( array( 'fields' => 'slug' ) ) );
        return new WP_Error(
            'invalid_target_lang',
            "Target language '$target_lang' is not a valid Polylang language. Valid languages: $valid_langs",
            array( 'status' => 400 )
        );
    }

    $translated_title   = isset( $params['title'] )   ? sanitize_text_field( $params['title'] )        : '';
    $translated_content = isset( $params['content'] ) ? wp_kses_post( $params['content'] )             : '';
    $translated_excerpt = isset( $params['excerpt'] ) ? sanitize_textarea_field( $params['excerpt'] )  : '';

    if ( empty( $translated_title ) ) {
        return new WP_Error( 'missing_title', 'Translated title cannot be empty', array( 'status' => 400 ) );
    }

    $source_post = get_post( $source_post_id );
    if ( ! $source_post ) {
        return new WP_Error( 'post_not_found', 'Source post not found', array( 'status' => 404 ) );
    }

    $source_lang = pll_get_post_language( $source_post_id );
    if ( ! $source_lang ) {
        return new WP_Error(
            'source_no_language',
            "Source post ID $source_post_id has no language assigned.",
            array( 'status' => 400 )
        );
    }

    if ( $source_lang === $target_lang ) {
        return new WP_Error(
            'same_language',
            "Source post is already in '$target_lang'. Cannot translate to the same language.",
            array( 'status' => 400 )
        );
    }

    // ── Create or update the translated post ─────────────────────────────

    $existing_translation = pll_get_post( $source_post_id, $target_lang );

    if ( $existing_translation ) {
        $result = wp_update_post( array(
            'ID'           => $existing_translation,
            'post_title'   => $translated_title,
            'post_content' => $translated_content,
            'post_excerpt' => $translated_excerpt,
        ), true );

        if ( is_wp_error( $result ) ) {
            return $result;
        }

        $translated_post_id = $existing_translation;
        $action = 'updated';
    } else {
        $translated_post_id = wp_insert_post( array(
            'post_title'   => $translated_title,
            'post_content' => $translated_content,
            'post_excerpt' => $translated_excerpt,
            'post_status'  => $source_post->post_status,
            'post_type'    => $source_post->post_type,
            'post_author'  => $source_post->post_author,
        ), true );

        if ( is_wp_error( $translated_post_id ) ) {
            return $translated_post_id;
        }

        pll_set_post_language( $translated_post_id, $target_lang );

        $verified_lang = pll_get_post_language( $translated_post_id );
        if ( $verified_lang !== $target_lang ) {
            wp_delete_post( $translated_post_id, true );
            return new WP_Error(
                'language_set_failed',
                "Failed to set language '$target_lang' on new post. Post deleted. Verified: " . ( $verified_lang ?: 'empty' ),
                array( 'status' => 500 )
            );
        }

        $existing_translations = pll_get_post_translations( $source_post_id );
        if ( ! isset( $existing_translations[ $source_lang ] ) ) {
            $existing_translations[ $source_lang ] = $source_post_id;
        }
        $existing_translations[ $target_lang ] = $translated_post_id;
        pll_save_post_translations( $existing_translations );

        $action = 'created';
    }

    // ── Featured image ────────────────────────────────────────────────────

    if ( ! empty( $params['thumbnail_id'] ) ) {
        set_post_thumbnail( $translated_post_id, intval( $params['thumbnail_id'] ) );
    } else {
        $thumbnail_id = get_post_thumbnail_id( $source_post_id );
        if ( $thumbnail_id ) {
            set_post_thumbnail( $translated_post_id, $thumbnail_id );
        }
    }

    // ── Taxonomies (categories, tags, AND any custom taxonomy) ────────────
    //
    // n8n sends a unified 'categories' array regardless of taxonomy type:
    //   [
    //     { "term_id": 1539, "taxonomy": "product_category" },
    //     { "term_id": 22,   "taxonomy": "category" }
    //   ]
    //
    // And a separate 'tags' array:
    //   [
    //     { "term_id": 77, "taxonomy": "post_tag" }
    //   ]
    //
    // We group by taxonomy slug, resolve each source term ID to its
    // target-language equivalent via translate_term(), then call
    // wp_set_object_terms() once per taxonomy — works for any taxonomy.

    $all_term_data = array_merge(
        isset( $params['categories'] ) && is_array( $params['categories'] ) ? $params['categories'] : array(),
        isset( $params['tags'] )       && is_array( $params['tags'] )       ? $params['tags']       : array()
    );

    // Group term data by taxonomy slug
    $terms_by_taxonomy = array();
    foreach ( $all_term_data as $term_data ) {
        if ( empty( $term_data['taxonomy'] ) ) {
            continue; // skip entries without a taxonomy — we can't know where to assign them
        }
        $taxonomy = sanitize_key( $term_data['taxonomy'] );
        if ( ! isset( $terms_by_taxonomy[ $taxonomy ] ) ) {
            $terms_by_taxonomy[ $taxonomy ] = array();
        }
        $terms_by_taxonomy[ $taxonomy ][] = $term_data;
    }

    // Resolve and assign each taxonomy's terms
    foreach ( $terms_by_taxonomy as $taxonomy => $term_data_list ) {
        // Verify this taxonomy actually exists and is registered for this post type
        if ( ! taxonomy_exists( $taxonomy ) ) {
            continue;
        }

        $resolved_term_ids = array();
        foreach ( $term_data_list as $term_data ) {
            $term_id = $this->translate_term( $term_data, $taxonomy, $target_lang );
            if ( $term_id ) {
                $resolved_term_ids[] = $term_id;
            }
        }

        if ( ! empty( $resolved_term_ids ) ) {
            // wp_set_object_terms works for any taxonomy, unlike wp_set_post_categories/tags
            wp_set_object_terms( $translated_post_id, $resolved_term_ids, $taxonomy );
        }
    }

    // ── ACF fields ────────────────────────────────────────────────────────

    if ( isset( $params['acf_fields'] ) && function_exists( 'update_field' ) ) {
        foreach ( $params['acf_fields'] as $field_key => $field_value ) {
            update_field( $field_key, $field_value, $translated_post_id );
        }
    }

    // ── Custom meta fields ────────────────────────────────────────────────

    if ( isset( $params['meta_fields'] ) && is_array( $params['meta_fields'] ) ) {
        foreach ( $params['meta_fields'] as $meta_key => $meta_value ) {
            if ( substr( $meta_key, 0, 1 ) === '_' ) {
                continue;
            }
            update_post_meta( $translated_post_id, $meta_key, sanitize_text_field( $meta_value ) );
        }
    }

    // ── Response ──────────────────────────────────────────────────────────

    return rest_ensure_response( array(
        'success'            => true,
        'action'             => $action,
        'translated_post_id' => $translated_post_id,
        'source_post_id'     => $source_post_id,
        'post_type'          => $source_post->post_type,
        'target_language'    => $target_lang,
        'url'                => get_permalink( $translated_post_id ),
        'all_translations'   => pll_get_post_translations( $source_post_id ),
    ) );
}

    /**
     * Helper to translate taxonomy terms
     */
    private function translate_term($term_data, $taxonomy, $target_lang)
    {
        $source_term_id = isset($term_data['term_id']) ? intval($term_data['term_id']) : 0;
        $translated_name = isset($term_data['translated_name']) ? sanitize_text_field($term_data['translated_name']) : '';

        if ($source_term_id && function_exists('pll_get_term')) {
            $existing_translation = pll_get_term($source_term_id, $target_lang);

            if ($existing_translation) {
                return $existing_translation;
            }
        }

        if ($translated_name) {
            $new_term = wp_insert_term($translated_name, $taxonomy);

            if (!is_wp_error($new_term)) {
                $new_term_id = $new_term['term_id'];

                if (function_exists('pll_set_term_language')) {
                    pll_set_term_language($new_term_id, $target_lang);

                    if ($source_term_id) {
                        $existing_term_translations = pll_get_term_translations($source_term_id);
                        $existing_term_translations[$target_lang] = $new_term_id;
                        pll_save_term_translations($existing_term_translations);
                    }
                }

                return $new_term_id;
            } elseif ($new_term->get_error_code() === 'term_exists') {
                // Term already exists, use the existing one
                $existing_term_id = $new_term->get_error_data();
                if (is_array($existing_term_id)) {
                    $existing_term_id = $existing_term_id['term_id'] ?? null;
                }
                if ($existing_term_id) {
                    if (function_exists('pll_set_term_language')) {
                        pll_set_term_language($existing_term_id, $target_lang);
                    }
                    return intval($existing_term_id);
                }
            }
        }

        return null;
    }

    /**
     * ⭐ NEW: Find all posts that have no language assigned
     */
    public function get_orphaned_posts($request)
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        global $wpdb;

        // Get the language taxonomy
        $language_taxonomy = 'language';

        // Get all translatable post types
        $post_types = get_post_types(array('public' => true), 'names');
        unset($post_types['attachment']);
        $post_types_list = "'" . implode("','", array_map('esc_sql', $post_types)) . "'";

        // Find posts that do NOT have a language term assigned
        $query = "
            SELECT p.ID, p.post_title, p.post_type, p.post_status, p.post_date
            FROM {$wpdb->posts} p
            WHERE p.post_type IN ($post_types_list)
            AND p.post_status IN ('publish', 'draft', 'pending', 'private')
            AND p.ID NOT IN (
                SELECT tr.object_id 
                FROM {$wpdb->term_relationships} tr
                INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
                WHERE tt.taxonomy = %s
            )
            ORDER BY p.post_date DESC
        ";

        $orphaned = $wpdb->get_results($wpdb->prepare($query, $language_taxonomy));

        $results = array();
        foreach ($orphaned as $post) {
            $results[] = array(
                'id' => intval($post->ID),
                'title' => $post->post_title,
                'post_type' => $post->post_type,
                'status' => $post->post_status,
                'date' => $post->post_date,
                'edit_link' => get_edit_post_link($post->ID, 'raw'),
            );
        }

        return rest_ensure_response(array(
            'success' => true,
            'count' => count($results),
            'orphaned_posts' => $results,
            'message' => count($results) > 0
                ? 'These posts have no language assigned. Use POST /cleanup-orphaned with mode=delete to remove them, or manually assign languages in WordPress admin.'
                : 'No orphaned posts found. All posts have a language assigned.',
        ));
    }

    /**
     * ⭐ NEW: Cleanup orphaned posts
     * 
     * Modes:
     * - "preview": Just list what would be deleted (default, safe)
     * - "delete":  Actually delete orphaned posts that look like failed translations
     * - "assign":  Assign a default language to orphaned posts (requires 'default_lang' param)
     */
    public function cleanup_orphaned_posts($request)
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $params = $request->get_json_params();
        $mode = isset($params['mode']) ? sanitize_text_field($params['mode']) : 'preview';
        $default_lang = isset($params['default_lang']) ? sanitize_text_field($params['default_lang']) : '';

        if ($mode === 'assign' && !$this->is_valid_language($default_lang)) {
            $valid_langs = implode(', ', pll_languages_list(array('fields' => 'slug')));
            return new WP_Error(
                'invalid_default_lang',
                "default_lang '$default_lang' is not valid. Valid: $valid_langs",
                array('status' => 400)
            );
        }

        global $wpdb;

        $language_taxonomy = 'language';
        $post_types = get_post_types(array('public' => true), 'names');
        unset($post_types['attachment']);
        $post_types_list = "'" . implode("','", array_map('esc_sql', $post_types)) . "'";

        $query = "
            SELECT p.ID, p.post_title, p.post_type, p.post_status, p.post_date
            FROM {$wpdb->posts} p
            WHERE p.post_type IN ($post_types_list)
            AND p.post_status IN ('publish', 'draft', 'pending', 'private')
            AND p.ID NOT IN (
                SELECT tr.object_id 
                FROM {$wpdb->term_relationships} tr
                INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
                WHERE tt.taxonomy = %s
            )
            ORDER BY p.post_date DESC
        ";

        $orphaned = $wpdb->get_results($wpdb->prepare($query, $language_taxonomy));

        $processed = array();

        foreach ($orphaned as $post) {
            $entry = array(
                'id' => intval($post->ID),
                'title' => $post->post_title,
                'post_type' => $post->post_type,
                'status' => $post->post_status,
            );

            if ($mode === 'delete') {
                $deleted = wp_delete_post($post->ID, true); // Force delete (bypass trash)
                $entry['action'] = $deleted ? 'deleted' : 'delete_failed';
            } elseif ($mode === 'assign') {
                pll_set_post_language($post->ID, $default_lang);
                $verified = pll_get_post_language($post->ID);
                $entry['action'] = ($verified === $default_lang) ? "assigned_$default_lang" : 'assign_failed';
            } else {
                $entry['action'] = 'would_be_processed';
            }

            $processed[] = $entry;
        }

        return rest_ensure_response(array(
            'success' => true,
            'mode' => $mode,
            'total_processed' => count($processed),
            'posts' => $processed,
            'message' => $mode === 'preview'
                ? 'Preview mode: no changes made. Use mode=delete to delete or mode=assign with default_lang to assign a language.'
                : "Completed $mode on " . count($processed) . " posts.",
        ));
    }

    /**
     * Get all configured languages
     */
    public function get_languages()
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $languages = pll_languages_list(array('fields' => 'all'));
        $lang_data = array();

        foreach ($languages as $lang) {
            $lang_data[] = array(
                'slug' => $lang->slug,
                'name' => $lang->name,
                'locale' => $lang->locale,
                'is_rtl' => $lang->is_rtl,
                'flag' => $lang->flag,
            );
        }

        return rest_ensure_response(array(
            'success' => true,
            'languages' => $lang_data,
        ));
    }

    /**
     * Get translation status for a specific post
     */
    public function get_translation_status($request)
    {
        $post_id = $request->get_param('id');

        if (!function_exists('pll_get_post_translations')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $translations = pll_get_post_translations($post_id);
        $languages = pll_languages_list();

        $status = array();
        foreach ($languages as $lang) {
            $status[$lang] = isset($translations[$lang]) ? array(
                'exists' => true,
                'post_id' => $translations[$lang],
                'url' => get_permalink($translations[$lang]),
            ) : array(
                'exists' => false,
                'post_id' => null,
                'url' => null,
            );
        }

        return rest_ensure_response(array(
            'success' => true,
            'post_id' => $post_id,
            'translations' => $status,
        ));
    }

    /**
     * Check if a string is serialized
     */
    private function is_serialized($data)
    {
        if (!is_string($data)) {
            return false;
        }
        $data = trim($data);
        if ($data === 'N;') {
            return true;
        }
        if (strlen($data) < 4 || $data[1] !== ':') {
            return false;
        }
        return @unserialize($data) !== false;
    }
}

// Initialize the plugin
new Polylang_REST_API_Extension();

<?php
/**
 * Polylang REST API Extension - FIXED VERSION
 * 
 * Fixes:
 * 1. Validates target_lang is a real Polylang language before creating any post
 * 2. Ensures source post has a language assigned before creating translation
 * 3. Wraps post creation in a transaction pattern (delete on failure)
 * 4. Adds cleanup endpoint to fix orphaned posts (no language assigned)
 * 5. Adds assign-language endpoint to batch-assign language to languageless posts
 * 6. Strips language suffix from meta keys on retrieval and re-assigns the target language suffix on creation to maintain WP Rapid Fields Pro compatibility.
 */
class Polylang_REST_API_Extension
{

    private $namespace = 'polylang-api/v1';

    public function __construct()
    {
        add_action('rest_api_init', array($this, 'register_routes'));
    }

    /**
     * Register all REST API routes
     */
    public function register_routes()
    {
        // Get all translatable post types
        register_rest_route($this->namespace, '/post-types', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_post_types'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // Get untranslated posts
        register_rest_route($this->namespace, '/untranslated-posts', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_untranslated_posts'),
            'permission_callback' => array($this, 'check_permission'),
            'args' => array(
                'post_type' => array(
                    'default' => 'post',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
                'source_lang' => array(
                    'default' => 'en',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
                'target_lang' => array(
                    'required' => true,
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        ));

        // Get post content for translation
        register_rest_route($this->namespace, '/post/(?P<id>\d+)', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_post_content'),
            'permission_callback' => array($this, 'check_permission'),
            'args' => array(
                'id' => array(
                    'validate_callback' => function ($param) {
                return is_numeric($param);
            }
                ),
            ),
        ));

        // Create translated post
        register_rest_route($this->namespace, '/translate-post', array(
            'methods' => 'POST',
            'callback' => array($this, 'create_translated_post'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // Get languages
        register_rest_route($this->namespace, '/languages', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_languages'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // Get translation status
        register_rest_route($this->namespace, '/translation-status/(?P<id>\d+)', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_translation_status'),
            'permission_callback' => array($this, 'check_permission'),
        ));

        // ========== NEW ENDPOINTS ==========

        // Find orphaned posts (posts with no language)
        register_rest_route($this->namespace, '/orphaned-posts', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_orphaned_posts'),
            'permission_callback' => array($this, 'check_admin_permission'),
            'args' => array(
                'action' => array(
                    'default' => 'list',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        ));

        // Cleanup orphaned posts (delete posts with no language)
        register_rest_route($this->namespace, '/cleanup-orphaned', array(
            'methods' => 'POST',
            'callback' => array($this, 'cleanup_orphaned_posts'),
            'permission_callback' => array($this, 'check_admin_permission'),
            'args' => array(
                'mode' => array(
                    'default' => 'preview',
                    'sanitize_callback' => 'sanitize_text_field',
                ),
            ),
        ));
    }

    /**
     * Permission callback - edit_posts
     */
    public function check_permission()
    {
        return current_user_can('edit_posts');
    }

    /**
     * Admin permission callback - manage_options
     */
    public function check_admin_permission()
    {
        return current_user_can('manage_options');
    }

    /**
     * Validate that a language slug is a valid Polylang language
     */
    private function is_valid_language($lang_slug)
    {
        if (empty($lang_slug) || !is_string($lang_slug)) {
            return false;
        }

        if (!function_exists('pll_languages_list')) {
            return false;
        }

        $valid_languages = pll_languages_list(array('fields' => 'slug'));
        return in_array($lang_slug, $valid_languages, true);
    }

    /**
     * Get all public post types that support translation
     */
    public function get_post_types()
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $post_types = get_post_types(array(
            'public' => true,
        ), 'objects');

        unset($post_types['attachment'], $post_types['video'], $post_types['page']);

        $translatable_types = array();

        foreach ($post_types as $post_type) {
            if (function_exists('pll_is_translated_post_type') && pll_is_translated_post_type($post_type->name)) {
                $translatable_types[] = array(
                    'name' => $post_type->name,
                    'label' => $post_type->label,
                    'singular_name' => $post_type->labels->singular_name,
                );
            }
        }

        return rest_ensure_response(array(
            'success' => true,
            'post_types' => array_column($translatable_types, 'name'),
            'post_types_details' => $translatable_types,
        ));
    }

    /**
     * Get all untranslated posts for a specific language
     */
    public function get_untranslated_posts($request)
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $post_type = $request->get_param('post_type');
        $source_lang = $request->get_param('source_lang');
        $target_lang = $request->get_param('target_lang');

        // ⭐ FIX: Validate both languages
        if (!$this->is_valid_language($source_lang)) {
            return new WP_Error(
                'invalid_source_lang',
                "Source language '$source_lang' is not a valid Polylang language",
                array('status' => 400)
                );
        }

        if (!$this->is_valid_language($target_lang)) {
            return new WP_Error(
                'invalid_target_lang',
                "Target language '$target_lang' is not a valid Polylang language",
                array('status' => 400)
                );
        }

        $args = array(
            'post_type' => $post_type,
            'posts_per_page' => -1,
            'lang' => $source_lang,
            'post_status' => 'publish',
        );

        $posts = get_posts($args);
        $untranslated = array();

        foreach ($posts as $post) {
            $translation_id = pll_get_post($post->ID, $target_lang);

            if (!$translation_id) {
                $untranslated[] = array(
                    'id' => $post->ID,
                    'title' => $post->post_title,
                    'slug' => $post->post_name,
                    'status' => $post->post_status,
                    'date' => $post->post_date,
                    'modified' => $post->post_modified,
                    'post_type' => $post->post_type,
                    'language' => $source_lang,
                );
            }
        }

        return rest_ensure_response(array(
            'success' => true,
            'count' => count($untranslated),
            'posts' => $untranslated,
        ));
    }

    /**
     * Get full post content including all meta fields
     */
    public function get_post_content($request)
    {
        $post_id = $request->get_param('id');
        $post = get_post($post_id);

        if (!$post) {
            return new WP_Error('post_not_found', 'Post not found', array('status' => 404));
        }

        $post_meta = get_post_meta($post_id);
        $current_lang = function_exists('pll_get_post_language') ? pll_get_post_language($post_id) : null;
        $lang_suffix = $current_lang ? '_' . $current_lang : '';

        $clean_meta = array();
        foreach ($post_meta as $key => $value) {
            if (substr($key, 0, 1) === '_')
                continue;

            $meta_value = is_array($value) ? $value[0] : $value;

            if (!is_string($meta_value) || $this->is_serialized($meta_value))
                continue;

            $base_key = $key;
            $has_suffix = false;
            if ($lang_suffix && substr($key, -strlen($lang_suffix)) === $lang_suffix) {
                $base_key = substr($key, 0, -strlen($lang_suffix));
                $has_suffix = true;
            }

            if ($has_suffix || !isset($clean_meta[$base_key])) {
                $clean_meta[$base_key] = $meta_value;
            }
        }

        $categories = wp_get_post_categories($post_id, array('fields' => 'all'));
        $tags = wp_get_post_tags($post_id, array('fields' => 'all'));

        $custom_taxonomies = array();
        $taxonomies = get_object_taxonomies($post->post_type, 'objects');
        foreach ($taxonomies as $taxonomy) {
            if (!in_array($taxonomy->name, array('category', 'post_tag', 'language', 'post_translations'))) {
                $terms = wp_get_post_terms($post_id, $taxonomy->name);
                if (!is_wp_error($terms) && !empty($terms)) {
                    $custom_taxonomies[$taxonomy->name] = $terms;
                }
            }
        }

        $thumbnail_id = get_post_thumbnail_id($post_id);
        $thumbnail_url = $thumbnail_id ? wp_get_attachment_url($thumbnail_id) : null;

        $acf_fields = array();
        if (function_exists('get_fields')) {
            $acf_fields = get_fields($post_id) ?: array();
        }

        $current_lang = function_exists('pll_get_post_language') ? pll_get_post_language($post_id) : null;

        return rest_ensure_response(array(
            'success' => true,
            'post' => array(
                'id' => $post->ID,
                'title' => $post->post_title,
                'content' => $post->post_content,
                'excerpt' => $post->post_excerpt,
                'slug' => $post->post_name,
                'status' => $post->post_status,
                'type' => $post->post_type,
                'language' => $current_lang,
                'featured_image' => $thumbnail_url,
                'thumbnail_id' => $thumbnail_id,
                'categories' => $categories,
                'tags' => $tags,
                'custom_taxonomies' => $custom_taxonomies,
                'meta' => $clean_meta,
                'acf_fields' => $acf_fields,
            ),
        ));
    }

    /**
     * Create or update translated post - HARDENED VERSION
     * 
     * Key safety measures:
     * 1. Validates target_lang is a real Polylang language BEFORE creating any post
     * 2. Ensures source post exists and has a language assigned
     * 3. Verifies language was actually set after pll_set_post_language()
     * 4. Deletes the newly created post if language assignment fails
     */
    /**
     * WHAT CHANGED vs original and WHY
     * 
     * Original code had two hardcoded blocks:
     *   if (isset($params['categories'])) → wp_set_post_categories()   [only works for 'category']
     *   if (isset($params['tags']))       → wp_set_post_tags()         [only works for 'post_tag']
     * 
     * Your products use 'product_category' taxonomy — both blocks silently skipped it.
     * 
     * Fix: n8n now sends categories as:
     *   [{ "term_id": 1539, "taxonomy": "product_category" }, ...]
     * 
     * PHP groups them by taxonomy slug and calls wp_set_object_terms() per group,
     * which works for ANY taxonomy — category, product_category, or any custom one.
     * translate_term() is unchanged; it already uses pll_get_term() correctly.
     */
    public function create_translated_post($request)
    {
        $params = $request->get_json_params();

        if (!isset($params['source_post_id']) || !isset($params['target_lang'])) {
            return new WP_Error('missing_params', 'Missing required parameters: source_post_id and target_lang', array('status' => 400));
        }

        $source_post_id = intval($params['source_post_id']);
        $target_lang = sanitize_text_field($params['target_lang']);

        if (!$this->is_valid_language($target_lang)) {
            $valid_langs = implode(', ', pll_languages_list(array('fields' => 'slug')));
            return new WP_Error(
                'invalid_target_lang',
                "Target language '$target_lang' is not a valid Polylang language. Valid languages: $valid_langs",
                array('status' => 400)
                );
        }

        $translated_title = isset($params['title']) ? sanitize_text_field($params['title']) : '';
        $translated_content = isset($params['content']) ? wp_kses_post($params['content']) : '';
        $translated_excerpt = isset($params['excerpt']) ? sanitize_textarea_field($params['excerpt']) : '';

        if (empty($translated_title)) {
            return new WP_Error('missing_title', 'Translated title cannot be empty', array('status' => 400));
        }

        $source_post = get_post($source_post_id);
        if (!$source_post) {
            return new WP_Error('post_not_found', 'Source post not found', array('status' => 404));
        }

        $source_lang = pll_get_post_language($source_post_id);
        if (!$source_lang) {
            return new WP_Error(
                'source_no_language',
                "Source post ID $source_post_id has no language assigned.",
                array('status' => 400)
                );
        }

        if ($source_lang === $target_lang) {
            return new WP_Error(
                'same_language',
                "Source post is already in '$target_lang'. Cannot translate to the same language.",
                array('status' => 400)
                );
        }

        // ── Create or update the translated post ─────────────────────────────

        $existing_translation = pll_get_post($source_post_id, $target_lang);

        if ($existing_translation) {
            $result = wp_update_post(array(
                'ID' => $existing_translation,
                'post_title' => $translated_title,
                'post_content' => $translated_content,
                'post_excerpt' => $translated_excerpt,
            ), true);

            if (is_wp_error($result)) {
                return $result;
            }

            $translated_post_id = $existing_translation;
            $action = 'updated';
        }
        else {
            $translated_post_id = wp_insert_post(array(
                'post_title' => $translated_title,
                'post_content' => $translated_content,
                'post_excerpt' => $translated_excerpt,
                'post_status' => $source_post->post_status,
                'post_type' => $source_post->post_type,
                'post_author' => $source_post->post_author,
            ), true);

            if (is_wp_error($translated_post_id)) {
                return $translated_post_id;
            }

            pll_set_post_language($translated_post_id, $target_lang);

            $verified_lang = pll_get_post_language($translated_post_id);
            if ($verified_lang !== $target_lang) {
                wp_delete_post($translated_post_id, true);
                return new WP_Error(
                    'language_set_failed',
                    "Failed to set language '$target_lang' on new post. Post deleted. Verified: " . ($verified_lang ?: 'empty'),
                    array('status' => 500)
                    );
            }

            $existing_translations = pll_get_post_translations($source_post_id);
            if (!isset($existing_translations[$source_lang])) {
                $existing_translations[$source_lang] = $source_post_id;
            }
            $existing_translations[$target_lang] = $translated_post_id;
            pll_save_post_translations($existing_translations);

            $action = 'created';
        }

        // ── Featured image ────────────────────────────────────────────────────

        if (!empty($params['thumbnail_id'])) {
            set_post_thumbnail($translated_post_id, intval($params['thumbnail_id']));
        }
        else {
            $thumbnail_id = get_post_thumbnail_id($source_post_id);
            if ($thumbnail_id) {
                set_post_thumbnail($translated_post_id, $thumbnail_id);
            }
        }

        // ── Taxonomies (categories, tags, AND any custom taxonomy) ────────────
        //
        // n8n sends a unified 'categories' array regardless of taxonomy type:
        //   [
        //     { "term_id": 1539, "taxonomy": "product_category" },
        //     { "term_id": 22,   "taxonomy": "category" }
        //   ]
        //
        // And a separate 'tags' array:
        //   [
        //     { "term_id": 77, "taxonomy": "post_tag" }
        //   ]
        //
        // We group by taxonomy slug, resolve each source term ID to its
        // target-language equivalent via translate_term(), then call
        // wp_set_object_terms() once per taxonomy — works for any taxonomy.

        $all_term_data = array_merge(
            isset($params['categories']) && is_array($params['categories']) ? $params['categories'] : array(),
            isset($params['tags']) && is_array($params['tags']) ? $params['tags'] : array()
        );

        // Group term data by taxonomy slug
        $terms_by_taxonomy = array();
        foreach ($all_term_data as $term_data) {
            if (empty($term_data['taxonomy'])) {
                continue; // skip entries without a taxonomy — we can't know where to assign them
            }
            $taxonomy = sanitize_key($term_data['taxonomy']);
            if (!isset($terms_by_taxonomy[$taxonomy])) {
                $terms_by_taxonomy[$taxonomy] = array();
            }
            $terms_by_taxonomy[$taxonomy][] = $term_data;
        }

        // Resolve and assign each taxonomy's terms
        foreach ($terms_by_taxonomy as $taxonomy => $term_data_list) {
            // Verify this taxonomy actually exists and is registered for this post type
            if (!taxonomy_exists($taxonomy)) {
                continue;
            }

            $resolved_term_ids = array();
            foreach ($term_data_list as $term_data) {
                $term_id = $this->translate_term($term_data, $taxonomy, $target_lang);
                if ($term_id) {
                    $resolved_term_ids[] = $term_id;
                }
            }

            if (!empty($resolved_term_ids)) {
                // wp_set_object_terms works for any taxonomy, unlike wp_set_post_categories/tags
                wp_set_object_terms($translated_post_id, $resolved_term_ids, $taxonomy);
            }
        }

        // ── ACF fields ────────────────────────────────────────────────────────

        if (isset($params['acf_fields']) && function_exists('update_field')) {
            foreach ($params['acf_fields'] as $field_key => $field_value) {
                update_field($field_key, $field_value, $translated_post_id);
            }
        }

        // ── Custom meta fields ────────────────────────────────────────────────

        if (isset($params['meta_fields']) && is_array($params['meta_fields'])) {
            $lang_suffix = '_' . $target_lang;
            foreach ($params['meta_fields'] as $meta_key => $meta_value) {
                if (substr($meta_key, 0, 1) === '_') {
                    continue;
                }

                $sanitized_value = sanitize_text_field($meta_value);

                $base_key = $meta_key;
                if (substr($meta_key, -strlen($lang_suffix)) === $lang_suffix) {
                    $base_key = substr($meta_key, 0, -strlen($lang_suffix));
                }

                $suffixed_key = $base_key . $lang_suffix;

                update_post_meta($translated_post_id, $base_key, $sanitized_value);
                update_post_meta($translated_post_id, $suffixed_key, $sanitized_value);
            }
        }

        // ── Response ──────────────────────────────────────────────────────────

        return rest_ensure_response(array(
            'success' => true,
            'action' => $action,
            'translated_post_id' => $translated_post_id,
            'source_post_id' => $source_post_id,
            'post_type' => $source_post->post_type,
            'target_language' => $target_lang,
            'url' => get_permalink($translated_post_id),
            'all_translations' => pll_get_post_translations($source_post_id),
        ));
    }

    /**
     * Helper to translate taxonomy terms
     */
    private function translate_term($term_data, $taxonomy, $target_lang)
    {
        $source_term_id = isset($term_data['term_id']) ? intval($term_data['term_id']) : 0;
        $translated_name = isset($term_data['translated_name']) ? sanitize_text_field($term_data['translated_name']) : '';

        if ($source_term_id && function_exists('pll_get_term')) {
            $existing_translation = pll_get_term($source_term_id, $target_lang);

            if ($existing_translation) {
                return $existing_translation;
            }
        }

        if ($translated_name) {
            $new_term = wp_insert_term($translated_name, $taxonomy);

            if (!is_wp_error($new_term)) {
                $new_term_id = $new_term['term_id'];

                if (function_exists('pll_set_term_language')) {
                    pll_set_term_language($new_term_id, $target_lang);

                    if ($source_term_id) {
                        $existing_term_translations = pll_get_term_translations($source_term_id);
                        $existing_term_translations[$target_lang] = $new_term_id;
                        pll_save_term_translations($existing_term_translations);
                    }
                }

                return $new_term_id;
            }
            elseif ($new_term->get_error_code() === 'term_exists') {
                // Term already exists, use the existing one
                $existing_term_id = $new_term->get_error_data();
                if (is_array($existing_term_id)) {
                    $existing_term_id = $existing_term_id['term_id'] ?? null;
                }
                if ($existing_term_id) {
                    if (function_exists('pll_set_term_language')) {
                        pll_set_term_language($existing_term_id, $target_lang);
                    }
                    return intval($existing_term_id);
                }
            }
        }

        return null;
    }

    /**
     * ⭐ NEW: Find all posts that have no language assigned
     */
    public function get_orphaned_posts($request)
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        global $wpdb;

        // Get the language taxonomy
        $language_taxonomy = 'language';

        // Get all translatable post types
        $post_types = get_post_types(array('public' => true), 'names');
        unset($post_types['attachment']);
        $post_types_list = "'" . implode("','", array_map('esc_sql', $post_types)) . "'";

        // Find posts that do NOT have a language term assigned
        $query = "
            SELECT p.ID, p.post_title, p.post_type, p.post_status, p.post_date
            FROM {$wpdb->posts} p
            WHERE p.post_type IN ($post_types_list)
            AND p.post_status IN ('publish', 'draft', 'pending', 'private')
            AND p.ID NOT IN (
                SELECT tr.object_id 
                FROM {$wpdb->term_relationships} tr
                INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
                WHERE tt.taxonomy = %s
            )
            ORDER BY p.post_date DESC
        ";

        $orphaned = $wpdb->get_results($wpdb->prepare($query, $language_taxonomy));

        $results = array();
        foreach ($orphaned as $post) {
            $results[] = array(
                'id' => intval($post->ID),
                'title' => $post->post_title,
                'post_type' => $post->post_type,
                'status' => $post->post_status,
                'date' => $post->post_date,
                'edit_link' => get_edit_post_link($post->ID, 'raw'),
            );
        }

        return rest_ensure_response(array(
            'success' => true,
            'count' => count($results),
            'orphaned_posts' => $results,
            'message' => count($results) > 0
            ? 'These posts have no language assigned. Use POST /cleanup-orphaned with mode=delete to remove them, or manually assign languages in WordPress admin.'
            : 'No orphaned posts found. All posts have a language assigned.',
        ));
    }

    /**
     * ⭐ NEW: Cleanup orphaned posts
     * 
     * Modes:
     * - "preview": Just list what would be deleted (default, safe)
     * - "delete":  Actually delete orphaned posts that look like failed translations
     * - "assign":  Assign a default language to orphaned posts (requires 'default_lang' param)
     */
    public function cleanup_orphaned_posts($request)
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $params = $request->get_json_params();
        $mode = isset($params['mode']) ? sanitize_text_field($params['mode']) : 'preview';
        $default_lang = isset($params['default_lang']) ? sanitize_text_field($params['default_lang']) : '';

        if ($mode === 'assign' && !$this->is_valid_language($default_lang)) {
            $valid_langs = implode(', ', pll_languages_list(array('fields' => 'slug')));
            return new WP_Error(
                'invalid_default_lang',
                "default_lang '$default_lang' is not valid. Valid: $valid_langs",
                array('status' => 400)
                );
        }

        global $wpdb;

        $language_taxonomy = 'language';
        $post_types = get_post_types(array('public' => true), 'names');
        unset($post_types['attachment']);
        $post_types_list = "'" . implode("','", array_map('esc_sql', $post_types)) . "'";

        $query = "
            SELECT p.ID, p.post_title, p.post_type, p.post_status, p.post_date
            FROM {$wpdb->posts} p
            WHERE p.post_type IN ($post_types_list)
            AND p.post_status IN ('publish', 'draft', 'pending', 'private')
            AND p.ID NOT IN (
                SELECT tr.object_id 
                FROM {$wpdb->term_relationships} tr
                INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
                WHERE tt.taxonomy = %s
            )
            ORDER BY p.post_date DESC
        ";

        $orphaned = $wpdb->get_results($wpdb->prepare($query, $language_taxonomy));

        $processed = array();

        foreach ($orphaned as $post) {
            $entry = array(
                'id' => intval($post->ID),
                'title' => $post->post_title,
                'post_type' => $post->post_type,
                'status' => $post->post_status,
            );

            if ($mode === 'delete') {
                $deleted = wp_delete_post($post->ID, true); // Force delete (bypass trash)
                $entry['action'] = $deleted ? 'deleted' : 'delete_failed';
            }
            elseif ($mode === 'assign') {
                pll_set_post_language($post->ID, $default_lang);
                $verified = pll_get_post_language($post->ID);
                $entry['action'] = ($verified === $default_lang) ? "assigned_$default_lang" : 'assign_failed';
            }
            else {
                $entry['action'] = 'would_be_processed';
            }

            $processed[] = $entry;
        }

        return rest_ensure_response(array(
            'success' => true,
            'mode' => $mode,
            'total_processed' => count($processed),
            'posts' => $processed,
            'message' => $mode === 'preview'
            ? 'Preview mode: no changes made. Use mode=delete to delete or mode=assign with default_lang to assign a language.'
            : "Completed $mode on " . count($processed) . " posts.",
        ));
    }

    /**
     * Get all configured languages
     */
    public function get_languages()
    {
        if (!function_exists('pll_languages_list')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $languages = pll_languages_list(array('fields' => 'all'));
        $lang_data = array();

        foreach ($languages as $lang) {
            $lang_data[] = array(
                'slug' => $lang->slug,
                'name' => $lang->name,
                'locale' => $lang->locale,
                'is_rtl' => $lang->is_rtl,
                'flag' => $lang->flag,
            );
        }

        return rest_ensure_response(array(
            'success' => true,
            'languages' => $lang_data,
        ));
    }

    /**
     * Get translation status for a specific post
     */
    public function get_translation_status($request)
    {
        $post_id = $request->get_param('id');

        if (!function_exists('pll_get_post_translations')) {
            return new WP_Error('polylang_not_active', 'Polylang is not active', array('status' => 500));
        }

        $translations = pll_get_post_translations($post_id);
        $languages = pll_languages_list();

        $status = array();
        foreach ($languages as $lang) {
            $status[$lang] = isset($translations[$lang]) ? array(
                'exists' => true,
                'post_id' => $translations[$lang],
                'url' => get_permalink($translations[$lang]),
            ) : array(
                'exists' => false,
                'post_id' => null,
                'url' => null,
            );
        }

        return rest_ensure_response(array(
            'success' => true,
            'post_id' => $post_id,
            'translations' => $status,
        ));
    }

    /**
     * Check if a string is serialized
     */
    private function is_serialized($data)
    {
        if (!is_string($data)) {
            return false;
        }
        $data = trim($data);
        if ($data === 'N;') {
            return true;
        }
        if (strlen($data) < 4 || $data[1] !== ':') {
            return false;
        }
        return @unserialize($data) !== false;
    }
}

// Initialize the plugin
new Polylang_REST_API_Extension();

{
    "name": "这个版本保留Post conten所有html v2",
    "nodes": [
        {
            "parameters": {
                "rule": {
                    "interval": [
                        {
                            "field": "hours",
                            "hoursInterval": 6
                        }
                    ]
                }
            },
            "name": "Schedule Trigger1",
            "type": "n8n-nodes-base.scheduleTrigger",
            "typeVersion": 1,
            "position": [
                -1536,
                64
            ],
            "id": "a41e8d64-7a10-4dcd-8e99-def0ab79ba94"
        },
        {
            "parameters": {
                "url": "=https://fuckthe.work/wp-json/polylang-api/v1/post-types",
                "authentication": "genericCredentialType",
                "genericAuthType": "httpBasicAuth",
                "options": {}
            },
            "name": "Get All Post Types1",
            "type": "n8n-nodes-base.httpRequest",
            "typeVersion": 4.1,
            "position": [
                -1344,
                64
            ],
            "id": "ea84905b-40bc-41ad-9b51-8e486d270410",
            "credentials": {
                "httpBasicAuth": {
                    "id": "YHTb3KwybUXqKyc2",
                    "name": "Unnamed credential"
                }
            }
        },
        {
            "parameters": {
                "jsCode": "const inputData = $input.first().json;\n\nreturn [\n  {\n    json: {\n      post_types: inputData.post_types,\n      target_languages: [\"de\",\"en\",\"es\",\"ja\"],\n      source_lang: \"zh\"\n    }\n  }\n];"
            },
            "type": "n8n-nodes-base.code",
            "typeVersion": 2,
            "position": [
                -1136,
                64
            ],
            "id": "d96d9296-d839-40f0-adb6-2dddc714a5e3",
            "name": "Set Language Config1"
        },
        {
            "parameters": {
                "fieldToSplitOut": "post_types",
                "include": "selectedOtherFields",
                "fieldsToInclude": "target_languages,source_lang",
                "options": {}
            },
            "name": "Split Post Types1",
            "type": "n8n-nodes-base.splitOut",
            "typeVersion": 1,
            "position": [
                -944,
                64
            ],
            "id": "a4a3eed0-d4c3-4ba8-8c6b-dc2f81a73979"
        },
        {
            "parameters": {
                "fieldToSplitOut": "target_languages",
                "include": "selectedOtherFields",
                "fieldsToInclude": "post_types,source_lang",
                "options": {}
            },
            "name": "Split Target Languages1",
            "type": "n8n-nodes-base.splitOut",
            "typeVersion": 1,
            "position": [
                -736,
                64
            ],
            "id": "6f371708-8fe0-44e2-a9ae-d4045b3988b4"
        },
        {
            "parameters": {
                "url": "=https://fuckthe.work/wp-json/polylang-api/v1/untranslated-posts",
                "authentication": "genericCredentialType",
                "genericAuthType": "httpBasicAuth",
                "sendQuery": true,
                "queryParameters": {
                    "parameters": [
                        {
                            "name": "post_type",
                            "value": "={{ $json.post_types }}"
                        },
                        {
                            "name": "source_lang",
                            "value": "={{ $json.source_lang }}"
                        },
                        {
                            "name": "target_lang",
                            "value": "={{ $json.target_languages }}"
                        }
                    ]
                },
                "options": {}
            },
            "name": "Get Untranslated Posts1",
            "type": "n8n-nodes-base.httpRequest",
            "typeVersion": 4.1,
            "position": [
                -528,
                64
            ],
            "id": "b8d65d15-f770-4c71-9e66-8cf453c687dd",
            "credentials": {
                "httpBasicAuth": {
                    "id": "YHTb3KwybUXqKyc2",
                    "name": "Unnamed credential"
                }
            }
        },
        {
            "parameters": {
                "conditions": {
                    "options": {
                        "caseSensitive": true,
                        "leftValue": "",
                        "typeValidation": "strict",
                        "version": 1
                    },
                    "conditions": [
                        {
                            "id": "has-posts",
                            "leftValue": "={{ $json.count }}",
                            "rightValue": 0,
                            "operator": {
                                "type": "number",
                                "operation": "gt"
                            }
                        }
                    ],
                    "combinator": "and"
                },
                "options": {}
            },
            "name": "Has Untranslated Posts?1",
            "type": "n8n-nodes-base.if",
            "typeVersion": 2,
            "position": [
                -336,
                80
            ],
            "id": "9abab5de-fb38-4881-9e85-2f052a415de9"
        },
        {
            "parameters": {
                "mode": "runOnceForEachItem",
                "jsCode": "const targetLang = $('Split Target Languages1').item.json.target_languages;\nconst sourceLang = $('Split Target Languages1').item.json.source_lang;\n\nreturn {\n  json: {\n    ...$input.item.json,\n    _target_lang: targetLang,\n    _source_lang: sourceLang\n  }\n};"
            },
            "name": "Inject Target Lang",
            "type": "n8n-nodes-base.code",
            "typeVersion": 2,
            "position": [
                -112,
                -48
            ],
            "id": "inject-target-lang-001"
        },
        {
            "parameters": {
                "fieldToSplitOut": "posts",
                "include": "selectedOtherFields",
                "fieldsToInclude": "_target_lang,_source_lang",
                "options": {}
            },
            "name": "Split Posts1",
            "type": "n8n-nodes-base.splitOut",
            "typeVersion": 1,
            "position": [
                96,
                -48
            ],
            "id": "f40616f9-5598-49fb-a782-ae67686b345a"
        },
        {
            "parameters": {
                "url": "=https://fuckthe.work/wp-json/polylang-api/v1/post/{{ $json.id }}",
                "authentication": "genericCredentialType",
                "genericAuthType": "httpBasicAuth",
                "options": {}
            },
            "name": "Get Post Content1",
            "type": "n8n-nodes-base.httpRequest",
            "typeVersion": 4.1,
            "position": [
                304,
                -48
            ],
            "id": "dc96330f-ef2b-4bc4-ae45-f2981d2cebeb",
            "credentials": {
                "httpBasicAuth": {
                    "id": "YHTb3KwybUXqKyc2",
                    "name": "Unnamed credential"
                }
            }
        },
        {
            "parameters": {
                "mode": "runOnceForEachItem",
                "jsCode": "function stripHtml(html) {\n  if (!html) return '';\n  let text = html\n    .replace(/<script\\b[^<]*(?:(?!<\\/script>)<[^<]*)*<\\/script>/gi, '')\n    .replace(/<style\\b[^<]*(?:(?!<\\/style>)<[^<]*)*<\\/style>/gi, '');\n  text = text.replace(/<[^>]+>/g, ' ');\n  text = text.replace(/\\s+/g, ' ').trim();\n  return text;\n}\n\nfunction extractTranslatableMeta(metaFields) {\n  const translatable = {};\n  if (!metaFields || typeof metaFields !== 'object') return translatable;\n  for (const [key, value] of Object.entries(metaFields)) {\n    if (key.startsWith('_') || !value) continue;\n    const metaValue = Array.isArray(value) ? value[0] : value;\n    if (typeof metaValue === 'string' && metaValue.length > 0) {\n      translatable[key] = stripHtml(metaValue);\n    }\n  }\n  return translatable;\n}\n\nconst item = $input.item;\nconst postData = item.json.post || item.json;\nconst targetLang = $('Inject Target Lang').item.json._target_lang;\nconst sourceLang = $('Inject Target Lang').item.json._source_lang;\n\nreturn {\n  json: {\n    id: item.json.id || postData.id,\n    target_lang: targetLang,\n    source_lang: sourceLang,\n    post: {\n      id: postData.id,\n      post_type: postData.type,\n      title: stripHtml(postData.title),\n      content: postData.content,\n      excerpt: stripHtml(postData.excerpt || ''),\n      categories: postData.categories || [],\n      tags: postData.tags || [],\n      acf_fields: postData.acf_fields || {},\n      meta_fields: extractTranslatableMeta(postData.meta),\n      thumbnail_id: postData.thumbnail_id || null\n    }\n  }\n};"
            },
            "type": "n8n-nodes-base.code",
            "typeVersion": 2,
            "position": [
                512,
                -48
            ],
            "id": "b6e6f012-9284-4cd7-b976-15c7519a3f7a",
            "name": "Process Post Data1"
        },
        {
            "parameters": {
                "mode": "runOnceForEachItem",
                "jsCode": "const item = $input.item;\nconst postData = item.json.post;\nconst targetLang = item.json.target_lang;\nconst sourceLang = item.json.source_lang;\n\nfunction truncateText(text, maxLength = 8000) {\n  if (!text) return '';\n  if (text.length <= maxLength) return text;\n  return text.substring(0, maxLength) + '... [truncated]';\n}\n\nconst title = truncateText(postData.title, 500);\nconst content = truncateText(postData.content, 10000);\nconst excerpt = postData.excerpt ? truncateText(postData.excerpt, 1000) : '';\n\nlet translationContent = `Title: ${title}\\n\\nContent: ${content}`;\n\nif (excerpt) {\n  translationContent += `\\n\\nExcerpt: ${excerpt}`;\n}\n\nconst metaFieldsArray = [];\nif (postData.meta_fields && Object.keys(postData.meta_fields).length > 0) {\n  translationContent += '\\n\\nMeta Fields:';\n  for (const [key, value] of Object.entries(postData.meta_fields)) {\n    const truncatedValue = truncateText(String(value), 500);\n    translationContent += `\\n${key}: ${truncatedValue}`;\n    metaFieldsArray.push({ key, value: truncatedValue });\n  }\n}\n\nlet expectedStructure = '\"title\": \"translated title\", \"content\": \"translated content\"';\nif (postData.excerpt) {\n  expectedStructure += ', \"excerpt\": \"translated excerpt\"';\n}\nif (metaFieldsArray.length > 0) {\n  expectedStructure += ', \"meta_fields\": {\"field_key\": \"translated value\"}';\n}\n\nconst requestBody = {\n  model: \"deepseek-chat\",\n  messages: [\n    {\n      role: \"system\",\n      content: `You are a professional translator. Translate the following WordPress ${postData.post_type} content from ${sourceLang} to ${targetLang}. CRITICAL RULES:\\n- The input content contains HTML.\\n- DO NOT remove, rewrite, reorder, escape, or normalize any HTML tags.\\n- DO NOT add or remove <p>, <br>, <strong>, <a>, <ul>, <li>, or any attributes.\\n- ONLY translate the visible text between HTML tags.\\n- Preserve whitespace and line breaks.\\n- Return VALID JSON ONLY.\\n- Keep HTML exactly as-is. Return ONLY a valid JSON object with these exact keys: {${expectedStructure}}. For meta_fields, use the same keys but translate the values. IMPORTANT: Ensure all strings in the JSON are properly escaped and the response is valid JSON.`\n    },\n    {\n      role: \"user\",\n      content: translationContent\n    }\n  ],\n  temperature: 0.3,\n  max_tokens: 6000,\n  response_format: { type: \"json_object\" }\n};\n\nreturn {\n  json: {\n    requestBody: requestBody,\n    post: postData,\n    targetLang: targetLang\n  }\n};"
            },
            "type": "n8n-nodes-base.code",
            "typeVersion": 2,
            "position": [
                720,
                -48
            ],
            "id": "e6b9913d-9cec-43c5-8880-33a016c222a2",
            "name": "Build DeepSeek Request1"
        },
        {
            "parameters": {
                "method": "POST",
                "url": "https://api.deepseek.com/v1/chat/completions",
                "authentication": "predefinedCredentialType",
                "nodeCredentialType": "deepSeekApi",
                "sendHeaders": true,
                "headerParameters": {
                    "parameters": [
                        {
                            "name": "Content-Type",
                            "value": "application/json"
                        }
                    ]
                },
                "sendBody": true,
                "specifyBody": "json",
                "jsonBody": "={{ JSON.stringify($json.requestBody) }}",
                "options": {}
            },
            "name": "DeepSeek Translate1",
            "type": "n8n-nodes-base.httpRequest",
            "typeVersion": 4.1,
            "position": [
                928,
                -48
            ],
            "id": "1eed0def-e597-4cb0-aea5-234310dd659a",
            "credentials": {
                "deepSeekApi": {
                    "id": "pjFtmRh3TiV2rNnV",
                    "name": "DeepSeek account 3"
                }
            }
        },
        {
            "parameters": {
                "mode": "runOnceForEachItem",
                "jsCode": "const response = $input.item.json;\nconst buildItem = $('Build DeepSeek Request1').item.json;\nconst targetLang = buildItem.targetLang;\nconst originalPost = buildItem.post;\n\nlet translatedData;\ntry {\n  const rawContent = response.choices[0].message.content;\n  translatedData = JSON.parse(rawContent);\n} catch (parseError) {\n  const rawContent = response.choices[0].message.content;\n  console.error('Failed to parse translation response:', parseError.message);\n  translatedData = {\n    title: 'Translation failed - JSON parse error',\n    content: 'Error: Could not parse translation response',\n    excerpt: ''\n  };\n  const titleMatch = rawContent.match(/\"title\"\\s*:\\s*\"([^\"]*)\"/);\n  if (titleMatch) {\n    translatedData.title = titleMatch[1];\n  }\n}\n\nconst categories = (originalPost.categories || []).map(cat => ({\n  term_id: cat.term_id,\n  translated_name: cat.name\n}));\n\nconst tags = (originalPost.tags || []).map(tag => ({\n  term_id: tag.term_id,\n  translated_name: tag.name\n}));\n\nlet acfFields = {};\nif (originalPost.acf_fields && typeof originalPost.acf_fields === 'object') {\n  acfFields = originalPost.acf_fields;\n}\n\nlet translatedMeta = {};\nif (translatedData.meta_fields && typeof translatedData.meta_fields === 'object') {\n  translatedMeta = translatedData.meta_fields;\n}\n\nreturn {\n  json: {\n    source_post_id: originalPost.id,\n    post_type: originalPost.post_type,\n    target_lang: targetLang,\n    title: translatedData.title || originalPost.title,\n    content: translatedData.content || originalPost.content,\n    excerpt: translatedData.excerpt || '',\n    categories: categories,\n    tags: tags,\n    acf_fields: acfFields,\n    meta_fields: translatedMeta,\n    thumbnail_id: originalPost.thumbnail_id\n  }\n};"
            },
            "name": "Format Translation Data1",
            "type": "n8n-nodes-base.code",
            "typeVersion": 2,
            "position": [
                1136,
                -48
            ],
            "id": "f53dd148-2085-437e-91f0-c5cad3896a21"
        },
        {
            "parameters": {
                "method": "POST",
                "url": "=https://fuckthe.work/wp-json/polylang-api/v1/translate-post",
                "authentication": "genericCredentialType",
                "genericAuthType": "httpBasicAuth",
                "sendBody": true,
                "specifyBody": "json",
                "jsonBody": "={{ JSON.stringify({ source_post_id: $json.source_post_id, target_lang: $json.target_lang, title: $json.title, content: $json.content, excerpt: $json.excerpt, categories: $json.categories, tags: $json.tags, acf_fields: $json.acf_fields, meta_fields: $json.meta_fields, thumbnail_id: $json.thumbnail_id }) }}",
                "options": {}
            },
            "name": "Create Translation1",
            "type": "n8n-nodes-base.httpRequest",
            "typeVersion": 4.1,
            "position": [
                1344,
                -48
            ],
            "id": "8f6f6d53-3636-430d-838e-40f4becef7e2",
            "credentials": {
                "httpBasicAuth": {
                    "id": "YHTb3KwybUXqKyc2",
                    "name": "Unnamed credential"
                }
            }
        },
        {
            "parameters": {
                "amount": 2
            },
            "name": "Rate Limit Delay1",
            "type": "n8n-nodes-base.wait",
            "typeVersion": 1.1,
            "position": [
                1536,
                -48
            ],
            "id": "ede73148-27b3-4a90-b678-9794f384ad32",
            "webhookId": "0f33c2d9-789c-43d0-9186-25a2b0a33997"
        },
        {
            "parameters": {
                "options": {}
            },
            "name": "Log Success1",
            "type": "n8n-nodes-base.set",
            "typeVersion": 3.2,
            "position": [
                1744,
                -48
            ],
            "id": "8233d4ca-0379-4c51-bb97-128aab79dee3"
        },
        {
            "parameters": {
                "options": {}
            },
            "name": "Workflow Complete1",
            "type": "n8n-nodes-base.set",
            "typeVersion": 3.2,
            "position": [
                1952,
                64
            ],
            "id": "e883dad1-b38a-4f47-8c2a-d69d46b40ae8"
        }
    ],
    "pinData": {},
    "connections": {
        "Schedule Trigger1": {
            "main": [
                [
                    {
                        "node": "Get All Post Types1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Get All Post Types1": {
            "main": [
                [
                    {
                        "node": "Set Language Config1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Set Language Config1": {
            "main": [
                [
                    {
                        "node": "Split Post Types1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Split Post Types1": {
            "main": [
                [
                    {
                        "node": "Split Target Languages1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Split Target Languages1": {
            "main": [
                [
                    {
                        "node": "Get Untranslated Posts1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Get Untranslated Posts1": {
            "main": [
                [
                    {
                        "node": "Has Untranslated Posts?1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Has Untranslated Posts?1": {
            "main": [
                [
                    {
                        "node": "Inject Target Lang",
                        "type": "main",
                        "index": 0
                    }
                ],
                [
                    {
                        "node": "Workflow Complete1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Inject Target Lang": {
            "main": [
                [
                    {
                        "node": "Split Posts1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Split Posts1": {
            "main": [
                [
                    {
                        "node": "Get Post Content1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Get Post Content1": {
            "main": [
                [
                    {
                        "node": "Process Post Data1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Process Post Data1": {
            "main": [
                [
                    {
                        "node": "Build DeepSeek Request1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Build DeepSeek Request1": {
            "main": [
                [
                    {
                        "node": "DeepSeek Translate1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "DeepSeek Translate1": {
            "main": [
                [
                    {
                        "node": "Format Translation Data1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Format Translation Data1": {
            "main": [
                [
                    {
                        "node": "Create Translation1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Create Translation1": {
            "main": [
                [
                    {
                        "node": "Rate Limit Delay1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Rate Limit Delay1": {
            "main": [
                [
                    {
                        "node": "Log Success1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        },
        "Log Success1": {
            "main": [
                [
                    {
                        "node": "Workflow Complete1",
                        "type": "main",
                        "index": 0
                    }
                ]
            ]
        }
    },
    "active": false,
    "settings": {
        "executionOrder": "v1",
        "binaryMode": "separate",
        "availableInMCP": false
    },
    "versionId": "da71a6d6-2092-4c3b-bedf-f489548de4a4",
    "meta": {
        "instanceId": "15a1059dab6d63d5697a1e8c0c7551c25cf58b59196bf1bbba2967bacd678682"
    },
    "id": "HYcaxQDwfrl2Meku",
    "tags": []
}
×
Polylang主题集成翻译模块

Polylang主题集成翻译模块

<?php /** * Polyl...