前言

在多語網站開發中,使用 Polylang 外掛管理翻譯文章是一種常見做法。但當需要將大量文章從某一語言批次複製到另一語言時,手動操作效率低且容易出錯。這段程式碼提供一個獨立於特定文章類型的批次翻譯複製工具,適合有基礎 WordPress 與 Polylang 使用經驗的工程師或自學者,幫助快速複製文章並保持翻譯關聯。

工具功能與設計架構

1. 固定來源語言與多目標語言選擇

程式碼中定義常數 IR_SOURCE_LANG 為來源語言(此處固定為中文 zh),目標語言則可從下拉選單選擇(預設有英文 en、日文 ja),方便擴充其他語言。

2. 獨立後台介面

透過 add_submenu_page 將工具掛載於 WordPress 後台「工具」選單下,並提供兩種操作模式:

  • 單筆測試:指定文章 ID,快速測試複製功能。
  • 批次複製:依選定文章類型,批次複製該語言下所有文章。

3. 使用 Polylang 官方 API 確保語言設定與翻譯關聯

核心複製流程依序使用 wp_insert_post 建立新文章,pll_set_post_language 設定語言,最後用 pll_save_post_translations 儲存翻譯群組關聯,確保與 Polylang 外掛的正確整合。

核心複製流程解析

Step 1: 確認來源文章與語言

先取得來源文章,確認文章類型與來源語言是否符合設定,避免誤複製。

ir_set_lang_official( $source_id, IR_SOURCE_LANG );

這行確保來源文章語言正確。

Step 2: 建立新文章

使用 wp_insert_post 複製文章標題、內容、狀態、作者、日期等基本欄位,確保新文章與原文一致。

Step 3: 設定新文章語言

新文章建立後,設定為目標語言,保持語言一致性。

Step 4: 儲存翻譯關聯

將新文章加入翻譯群組,讓 Polylang 知道這是原文的翻譯版本。

Step 5: 複製分類法(Taxonomy)

複製原文文章所屬的分類與標籤,但排除語言相關的 taxonomy,避免衝突。

Step 6: 複製特色圖片

如果原文有設定特色圖片,將同一張圖片設定給新文章,保持視覺一致性。

Step 7: 複製 ACF 自訂欄位

利用 Advanced Custom Fields (ACF) 官方 API 安全複製所有自訂欄位,避免欄位名稱變動造成錯誤。

實務應用與優化建議

  • 資料備份:批次操作前務必備份資料庫,避免誤操作造成資料遺失。
  • 語言擴充:可依需求擴充 $allowed_target_langs 陣列,支援更多語言。
  • 效能考量:批次複製大量文章時,可能造成伺服器負擔,可分批執行或加入排程。
  • 錯誤處理:目前以回傳日誌方式呈現,可擴充為錯誤通知或記錄檔。

常見問題與注意事項

  • 複製後的文章不會自動翻譯內容,僅複製原文內容,需後續人工或機器翻譯。
  • Polylang API 函式必須存在,否則功能無法使用。
  • ACF 複製需確保 ACF 外掛已啟用且函式存在。

完整程式碼

<?php
/**
 * Universal Polylang Bulk Copy Tool (Final Version - FIXED)
 * - 獨立在「工具 → Bulk Copy Translations」底下(不綁每個 post type)
 * - 任意 Post Type 下拉選單選擇
 * - 來源語言固定 zh
 * - 目標語言下拉選(en / ja,可自行擴充)
 * - 單筆測試 / 批次處理
 * - 已存在翻譯自動跳過
 * - Polylang 官方流程:wp_insert_post + pll_set_post_language + pll_save_post_translations
 * - taxonomy / featured image / ACF 全欄位複製(ACF 安全寫法)
 *

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

define( 'IR_SOURCE_LANG', 'zh' ); // 固定來源語言(Polylang code)

/* =========================
 * 共用:設定語言(官方 API)
 * ========================= */
function ir_set_lang_official( $post_id, $lang_code ) {
    if ( function_exists( 'pll_set_post_language' ) ) {
        pll_set_post_language( $post_id, $lang_code );
    }
}

/* =========================
 * 後台選單(獨立於 post type)
 * ========================= */
add_action( 'admin_menu', function () {
    add_submenu_page(
        'tools.php',                         // 掛在「工具」
        'Bulk Copy Translations (Official)', // 頁面標題
        'Bulk Copy Translations',            // 左側選單名稱
        'manage_options',                    // 權限
        'ir-bulk-translations',              // slug
        'ir_bulk_translations_page'          // callback
    );
});

/* =========================
 * 後台頁面
 * ========================= */
function ir_bulk_translations_page() {

    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( '沒有權限。' );
    }

    // Polylang API 檢查
    if ( ! function_exists( 'pll_set_post_language' ) || ! function_exists( 'pll_save_post_translations' ) ) {
        echo '<div class="notice notice-error"><p>Polylang API 不存在,請確認 Polylang 是否啟用。</p></div>';
        return;
    }

    // 允許的目標語言(依照 Polylang 的 code)
    $allowed_target_langs = [ 'en', 'ja' ];

    // 可選 post types(public)
    $post_types = get_post_types( [ 'public' => true ], 'objects' );

    // 預設值
    $current_post_type = isset( $_POST['ir_post_type'] ) ? sanitize_text_field( wp_unslash( $_POST['ir_post_type'] ) ) : 'post';
    if ( ! isset( $post_types[ $current_post_type ] ) ) {
        $current_post_type = 'post';
    }

    $current_target = isset( $_POST['ir_target_lang'] ) ? sanitize_text_field( wp_unslash( $_POST['ir_target_lang'] ) ) : 'en';
    if ( ! in_array( $current_target, $allowed_target_langs, true ) ) {
        $current_target = 'en';
    }

  
    echo '<h1>通用翻譯複製工具(Polylang 官方流程)</h1>';
    echo '<p>來源語言固定為:<code>' . esc_html( IR_SOURCE_LANG ) . '</code></p>';
    echo '<p style="color:#b32d2e;"><strong>⚠️ 執行前請先備份資料庫</strong></p>';

    echo '<style>
        .ir-box{background:#fff;border:1px solid #ccd0d4;padding:12px 14px;margin:12px 0;}
        .ir-row{margin:10px 0;}
        .ir-row label{display:inline-block;min-width:90px;font-weight:600;}
        .ir-pre{background:#fff;border:1px solid #ccd0d4;padding:10px;max-height:520px;overflow:auto;white-space:pre-wrap;}
    </style>';

    /* =========================
     * 單筆測試
     * ========================= */
    echo '<div class="ir-box">';
    echo '<div style="margin-top:0;">單筆測試</div>';
    echo '<form method="post">';
    wp_nonce_field( 'ir_single' );

    echo '<div class="ir-row"><label>Post Type</label> ';
    echo '<select name="ir_post_type">';
    foreach ( $post_types as $pt ) {
        printf(
            '<option value="%s"%s>%s (%s)</option>',
            esc_attr( $pt->name ),
            selected( $current_post_type, $pt->name, false ),
            esc_html( $pt->labels->singular_name ),
            esc_html( $pt->name )
        );
    }
    echo '</select></div>';

    echo '<div class="ir-row"><label>文章 ID</label> ';
    echo '<input type="number" name="ir_test_id" required style="width:160px;"></div>';

    echo '<div class="ir-row"><label>目標語言</label> ';
    echo '<select name="ir_target_lang">';
    foreach ( $allowed_target_langs as $lang ) {
        printf(
            '<option value="%s"%s>%s</option>',
            esc_attr( $lang ),
            selected( $current_target, $lang, false ),
            esc_html( strtoupper( $lang ) )
        );
    }
    echo '</select></div>';

    submit_button( '測試複製', 'secondary', 'ir_single_run' );
    echo '</form>';
    echo '</div>';

    /* =========================
     * 批次處理
     * ========================= */
    echo '<div class="ir-box">';
    echo '<div style="margin-top:0;">批次複製(來源語言:' . esc_html( IR_SOURCE_LANG ) . ')</div>';
    echo '<form method="post">';
    wp_nonce_field( 'ir_bulk' );

    echo '<div class="ir-row"><label>Post Type</label> ';
    echo '<select name="ir_post_type">';
    foreach ( $post_types as $pt ) {
        printf(
            '<option value="%s"%s>%s (%s)</option>',
            esc_attr( $pt->name ),
            selected( $current_post_type, $pt->name, false ),
            esc_html( $pt->labels->singular_name ),
            esc_html( $pt->name )
        );
    }
    echo '</select></div>';

    echo '<div class="ir-row"><label>目標語言</label> ';
    echo '<select name="ir_target_lang">';
    foreach ( $allowed_target_langs as $lang ) {
        printf(
            '<option value="%s"%s>%s</option>',
            esc_attr( $lang ),
            selected( $current_target, $lang, false ),
            esc_html( strtoupper( $lang ) )
        );
    }
    echo '</select></div>';

    submit_button( '執行批次複製', 'primary', 'ir_bulk_run' );
    echo '</form>';
    echo '</div>';

    /* =========================
     * 執行
     * ========================= */
    if ( isset( $_POST['ir_single_run'] ) ) {
        check_admin_referer( 'ir_single' );

        $post_id   = isset( $_POST['ir_test_id'] ) ? absint( $_POST['ir_test_id'] ) : 0;
        $target    = $current_target;
        $post_type = $current_post_type;

        echo '<div>單筆結果</div>';
        $log = ir_clone_post( $post_id, $target, $post_type );

        echo '<pre class="ir-pre">' . esc_html( implode( "\n", $log ) ) . '</pre>';
    }

    if ( isset( $_POST['ir_bulk_run'] ) ) {
        check_admin_referer( 'ir_bulk' );

        $target    = $current_target;
        $post_type = $current_post_type;

        echo '<div>批次結果(Post Type:' . esc_html( $post_type ) . ',目標語言:' . esc_html( strtoupper( $target ) ) . ')</div>';

        $posts = get_posts( [
            'post_type'      => $post_type,
            'posts_per_page' => -1,
            'post_status'    => 'any',
            'lang'           => IR_SOURCE_LANG, // Polylang 的語言 query var
            'fields'         => 'ids',
        ] );

        if ( empty( $posts ) ) {
            echo '<p>找不到來源語言(' . esc_html( IR_SOURCE_LANG ) . ')的文章。</p>';
        } else {
            $output = [];
            foreach ( $posts as $id ) {
                $output = array_merge( $output, ir_clone_post( $id, $target, $post_type ) );
            }
            echo '<pre class="ir-pre">' . esc_html( implode( "\n", $output ) ) . '</pre>';
        }
    }


}

/* =========================
 * 核心:複製一篇文章 → 目標語言翻譯
 * ========================= */
function ir_clone_post( $source_id, $target_lang, $post_type ) {

    $log = [];

    if ( ! $source_id ) {
        return [ '❌ 文章 ID 不可為 0' ];
    }

    $src = get_post( $source_id );
    if ( ! $src ) {
        return [ "❌ 找不到文章 {$source_id}" ];
    }

    // 確保來源文章屬於選定的 post type(避免拿錯)
    if ( $src->post_type !== $post_type ) {
        return [ "❌ 文章 {$source_id} 的 post_type 是 {$src->post_type},不是你選的 {$post_type},已中止。" ];
    }

    // Step 1) 確保原文語言
    ir_set_lang_official( $source_id, IR_SOURCE_LANG );
    $log[] = "Step 1) 設定原文語言:{$source_id} → " . IR_SOURCE_LANG;

    // 取得翻譯群組
    $translations = function_exists( 'pll_get_post_translations' )
        ? pll_get_post_translations( $source_id )
        : [];

    if ( empty( $translations[ IR_SOURCE_LANG ] ) ) {
        $translations[ IR_SOURCE_LANG ] = $source_id;
    }

    // 已存在目標語言 → 跳過
    if ( ! empty( $translations[ $target_lang ] ) ) {
        $existing = (int) $translations[ $target_lang ];
        $log[] = "⚠️ 已存在 {$target_lang} 翻譯(ID {$existing}),跳過。";

        // debug 狀態
        if ( function_exists( 'pll_get_post_language' ) ) {
            $lang_debug = [];
            foreach ( $translations as $code => $pid ) {
                $lang_debug[] = $code . ':' . $pid . '(' . pll_get_post_language( $pid ) . ')';
            }
            $log[] = '🧪 目前語言狀態:' . implode( ', ', $lang_debug );
        }

        return $log;
    }

    // Step 2) 建立新文章
    $new_id = wp_insert_post( [
        'post_type'      => $post_type,
        'post_status'    => $src->post_status,
        'post_title'     => $src->post_title,
        'post_content'   => $src->post_content,
        'post_excerpt'   => $src->post_excerpt,
        'post_author'    => $src->post_author,
        'post_date'      => $src->post_date,
        'post_date_gmt'  => $src->post_date_gmt,
        'menu_order'     => $src->menu_order,
        'post_parent'    => $src->post_parent,
    ], true );

    if ( is_wp_error( $new_id ) ) {
        $log[] = '❌ wp_insert_post 錯誤:' . $new_id->get_error_message();
        return $log;
    }
    $log[] = "Step 2) 建立新文章:{$new_id}";

    // Step 3) 設定新文章語言
    ir_set_lang_official( $new_id, $target_lang );
    $log[] = "Step 3) 設定新文章語言:{$new_id} → {$target_lang}";

    // Step 4) 儲存翻譯關聯
    $translations[ $target_lang ] = $new_id;
    pll_save_post_translations( $translations );
    $log[] = 'Step 4) 儲存翻譯關聯:' . wp_json_encode( $translations );

    // Step 5) 複製 taxonomy(排除語言 taxonomy)
    $taxes = get_object_taxonomies( $post_type );
    $taxes = array_diff( $taxes, [ 'language', 'pll_language' ] );

    foreach ( $taxes as $tax ) {
        $terms = wp_get_object_terms( $source_id, $tax, [ 'fields' => 'ids' ] );
        if ( ! is_wp_error( $terms ) ) {
            wp_set_object_terms( $new_id, $terms, $tax, false );
        }
    }
    $log[] = 'Step 5) Taxonomy 複製完成';

    // Step 6) 複製特色圖片
    $thumb = get_post_thumbnail_id( $source_id );
    if ( $thumb ) {
        set_post_thumbnail( $new_id, $thumb );
        $log[] = 'Step 6) Featured Image 複製完成';
    } else {
        $log[] = 'Step 6) 原文沒有 Featured Image';
    }

    // Step 7) 複製 ACF 全欄位(官方安全寫法)
    if ( function_exists( 'get_field_objects' ) && function_exists( 'update_field' ) ) {
        $fields = get_field_objects( $source_id );
        $count  = 0;

        if ( $fields ) {
            foreach ( $fields as $field ) {
                // 用 field key 複製最穩(不怕改欄位 name)
                update_field( $field['key'], $field['value'], $new_id );
                $count++;
            }
        }

        $log[] = "Step 7) ACF 全欄位複製完成({$count} 個欄位)";
    } else {
        $log[] = 'Step 7) ACF API 不存在(get_field_objects / update_field),跳過 ACF 複製';
    }

    // 最後:語言檢查
    if ( function_exists( 'pll_get_post_language' ) ) {
        $src_lang_real = pll_get_post_language( $source_id );
        $new_lang_real = pll_get_post_language( $new_id );
        $log[] = "🧪 實際語言檢查:原文 {$source_id} 語言:{$src_lang_real};新文 {$new_id} 語言:{$new_lang_real}";
    }

    $log[] = "✅ 完成:原文 {$source_id} → {$target_lang} 翻譯 {$new_id}";
    return $log;
}