前言
在多語網站開發中,使用 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 ) . ''; echo '⚠️ 執行前請先備份資料庫
'; echo ' .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;} '; /* ========================= * 單筆測試 * ========================= */ echo ''; echo ''; /* ========================= * 批次處理 * ========================= */ echo '單筆測試'; echo ''; wp_nonce_field( 'ir_single' ); echo ''; echo ''; foreach ( $post_types as $pt ) { printf( '%s (%s)', esc_attr( $pt->name ), selected( $current_post_type, $pt->name, false ), esc_html( $pt->labels->singular_name ), esc_html( $pt->name ) ); } echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( $allowed_target_langs as $lang ) { printf( '%s', esc_attr( $lang ), selected( $current_target, $lang, false ), esc_html( strtoupper( $lang ) ) ); } echo ''; submit_button( '測試複製', 'secondary', 'ir_single_run' ); echo ''; echo ''; echo ''; /* ========================= * 執行 * ========================= */ 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 '批次複製(來源語言:' . esc_html( IR_SOURCE_LANG ) . ')'; echo ''; wp_nonce_field( 'ir_bulk' ); echo ''; echo ''; foreach ( $post_types as $pt ) { printf( '%s (%s)', esc_attr( $pt->name ), selected( $current_post_type, $pt->name, false ), esc_html( $pt->labels->singular_name ), esc_html( $pt->name ) ); } echo ''; echo ''; echo ''; foreach ( $allowed_target_langs as $lang ) { printf( '%s', esc_attr( $lang ), selected( $current_target, $lang, false ), esc_html( strtoupper( $lang ) ) ); } echo ''; submit_button( '執行批次複製', 'primary', 'ir_bulk_run' ); echo ''; echo '單筆結果'; $log = ir_clone_post( $post_id, $target, $post_type ); echo '' . esc_html( implode( "\n", $log ) ) . ''; } if ( isset( $_POST['ir_bulk_run'] ) ) { check_admin_referer( 'ir_bulk' ); $target = $current_target; $post_type = $current_post_type; echo '批次結果(Post Type:' . esc_html( $post_type ) . ',目標語言:' . esc_html( strtoupper( $target ) ) . ')'; $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 '找不到來源語言(' . esc_html( IR_SOURCE_LANG ) . ')的文章。
'; } else { $output = []; foreach ( $posts as $id ) { $output = array_merge( $output, ir_clone_post( $id, $target, $post_type ) ); } echo '' . esc_html( implode( "\n", $output ) ) . ''; } } } /* ========================= * 核心:複製一篇文章 → 目標語言翻譯 * ========================= */ 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; }