前言
在多語網站開發中,使用 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;
}
