<?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": []
}
WP开发教程 WordPress导师在线教程