<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>WordPress &#8211; 小豬日常</title>
	<atom:link href="https://piglife.tw/tag/wordpress/feed/" rel="self" type="application/rss+xml" />
	<link>https://piglife.tw</link>
	<description>Hello World，一個紀錄生活與學習的地方</description>
	<lastBuildDate>Thu, 01 Jan 2026 22:20:43 +0000</lastBuildDate>
	<language>zh-TW</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9</generator>

<image>
	<url>https://piglife.tw/wp-content/uploads/2017/10/cropped-logo-1-32x32.png</url>
	<title>WordPress &#8211; 小豬日常</title>
	<link>https://piglife.tw</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>在 LearnDash 課程列表新增已報名學生數欄位並優化查詢效能</title>
		<link>https://piglife.tw/technical-notes/learndash-enrolled-count-column/</link>
					<comments>https://piglife.tw/technical-notes/learndash-enrolled-count-column/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Thu, 01 Jan 2026 22:20:43 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[LearnDash]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[WP_User_Query]]></category>
		<category><![CDATA[後台自訂欄位]]></category>
		<category><![CDATA[快取優化]]></category>
		<category><![CDATA[課程管理]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/learndash-enrolled-count-column/</guid>

					<description><![CDATA[本文介紹如何在 LearnDash 後台課程列表新增已報名學生數欄位，並透過 WP_User_Que...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>這段程式碼主要解決 LearnDash 後台課程列表無法直接查看每門課程已報名學生數的問題。對於管理大量課程與學員的教學平台管理員來說，快速掌握報名狀況非常重要。本文適合熟悉 WordPress 與 LearnDash 且想自訂後台介面與效能優化的工程師或自學者。</p>
<h2 class="wp-block-heading">新增自訂欄位到課程列表</h2>
<p>透過 WordPress 提供的 <code>manage_edit-{$post_type}_columns</code> 過濾器，我們在 sfwd-courses（LearnDash 課程）後台列表中插入一個「已報名學生」欄位。此欄位被放置在標題欄位後面，方便管理者一目了然。</p>
<pre><code class="lang-php language-php php">add_filter(&#039;manage_edit-sfwd-courses_columns&#039;, function ($columns) {
    $new = [];
    foreach ($columns as $k =&gt; $v) {
        $new[$k] = $v;
        if ($k === &#039;title&#039;) {
            $new[&#039;ld_enrolled_count&#039;] = &#039;已報名學生&#039;;
        }
    }
    if (!isset($new[&#039;ld_enrolled_count&#039;])) {
        $new[&#039;ld_enrolled_count&#039;] = &#039;已報名學生&#039;;
    }
    return $new;
}, 20);</code></pre>
<h2 class="wp-block-heading">顯示欄位內容與計數邏輯</h2>
<p>使用 <code>manage_sfwd-courses_posts_custom_column</code> 動作鉤子，根據課程 ID 呼叫自訂函式取得報名學生數並輸出。計數邏輯透過 WP_User<em>Query 查詢所有有 <code>usermeta</code> 中 `course</em>{course_id}_access_from` 欄位存在的使用者數量，這是 LearnDash 判斷學員是否有課程存取權的標準做法。</p>
<pre><code class="lang-php language-php php">add_action(&#039;manage_sfwd-courses_posts_custom_column&#039;, function ($column, $post_id) {
    if ($column !== &#039;ld_enrolled_count&#039;)
        return;

    $count = vs_ld_get_enrolled_count($post_id);

    echo esc_html((string) $count);
}, 10, 2);</code></pre>
<h3 class="wp-block-heading">快取機制提升效能</h3>
<p>為避免課程列表頁面每筆課程都執行重複且昂貴的資料庫查詢，使用 WordPress 內建快取（wp_cache）暫存結果 10 分鐘。這樣可以大幅減少對資料庫的負擔，避免後台卡頓。</p>
<pre><code class="lang-php language-php php">function vs_ld_get_enrolled_count($course_id)
{
    $course_id = (int) $course_id;
    if ($course_id &lt;= 0)
        return 0;

    $cache_key = &#039;course_enrolled_&#039; . $course_id;
    $cached = wp_cache_get($cache_key, &#039;ld_course_enroll_count&#039;);
    if ($cached !== false) {
        return (int) $cached;
    }

    $meta_key = &#039;course_&#039; . $course_id . &#039;_access_from&#039;;

    $uq = new WP_User_Query([
        &#039;fields&#039; =&gt; &#039;ID&#039;,
        &#039;number&#039; =&gt; 1,
        &#039;paged&#039; =&gt; 1,
        &#039;count_total&#039; =&gt; true,
        &#039;meta_query&#039; =&gt; [
            [
                &#039;key&#039; =&gt; $meta_key,
                &#039;compare&#039; =&gt; &#039;EXISTS&#039;,
            ],
        ],
    ]);

    $count = (int) $uq-&gt;get_total();

    wp_cache_set($cache_key, $count, &#039;ld_course_enroll_count&#039;, 10 * MINUTE_IN_SECONDS);

    return $count;
}</code></pre>
<h2 class="wp-block-heading">課程更新時清除快取</h2>
<p>為確保報名數字不會因快取而過時，當課程內容被更新時，會自動刪除對應快取，讓下一次查詢能取得最新資料。</p>
<pre><code class="lang-php language-php php">add_action(&#039;save_post_sfwd-courses&#039;, function ($post_id) {
    if (defined(&#039;DOING_AUTOSAVE&#039;) &amp;&amp; DOING_AUTOSAVE)
        return;
    $post_id = (int) $post_id;
    wp_cache_delete(&#039;course_enrolled_&#039; . $post_id, &#039;ld_course_enroll_count&#039;);
}, 20);</code></pre>
<h2 class="wp-block-heading">實務應用與延伸</h2>
<p>這種方式適合需要在 LearnDash 後台快速掌握各課程報名狀況的管理者，尤其是課程數量龐大時，快取機制可有效減少資料庫負擔。未來可延伸加入排序功能，或結合 AJAX 即時更新報名數，提升使用體驗。</p>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>快取時間設定過短會增加資料庫查詢頻率，過長可能導致數據不即時，需依實際需求調整。</li>
<li>使用 WP_User_Query 查詢大量用戶時，仍需注意資料庫效能，建議搭配適當索引。</li>
<li>本範例假設 LearnDash 使用標準 usermeta key，若有自訂存取邏輯需調整查詢條件。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
if (!defined(&#039;ABSPATH&#039;))
    exit;

/**
 * LearnDash：後台課程清單新增「已報名學生數」欄位
 * - post_type: sfwd-courses
 * - 計數依據：usermeta key = course_{course_id}_access_from EXISTS
 * - 有短暫快取，避免清單頁卡爆
 */

/** 1) 加欄位 */
add_filter(&#039;manage_edit-sfwd-courses_columns&#039;, function ($columns) {
    // 插在標題後面（你也可以改位置）
    $new = [];
    foreach ($columns as $k =&gt; $v) {
        $new[$k] = $v;
        if ($k === &#039;title&#039;) {
            $new[&#039;ld_enrolled_count&#039;] = &#039;已報名學生&#039;;
        }
    }
    if (!isset($new[&#039;ld_enrolled_count&#039;])) {
        $new[&#039;ld_enrolled_count&#039;] = &#039;已報名學生&#039;;
    }
    return $new;
}, 20);

/** 2) 顯示欄位內容 */
add_action(&#039;manage_sfwd-courses_posts_custom_column&#039;, function ($column, $post_id) {
    if ($column !== &#039;ld_enrolled_count&#039;)
        return;

    $count = vs_ld_get_enrolled_count($post_id);

    echo esc_html((string) $count);
}, 10, 2);

/** 3) 計數函式（含快取） */
function vs_ld_get_enrolled_count($course_id)
{
    $course_id = (int) $course_id;
    if ($course_id &lt;= 0)
        return 0;

    // 快取 10 分鐘（避免列表頁每一列都打一次 users table）
    $cache_key = &#039;course_enrolled_&#039; . $course_id;
    $cached = wp_cache_get($cache_key, &#039;ld_course_enroll_count&#039;);
    if ($cached !== false) {
        return (int) $cached;
    }

    // LearnDash 最常用的課程存取 key
    $meta_key = &#039;course_&#039; . $course_id . &#039;_access_from&#039;;

    // 用 WP_User_Query 計數 meta_key EXISTS 的使用者
    $uq = new WP_User_Query([
        &#039;fields&#039; =&gt; &#039;ID&#039;,
        &#039;number&#039; =&gt; 1,        // 只要 count_total，不要真的撈一堆 ID
        &#039;paged&#039; =&gt; 1,
        &#039;count_total&#039; =&gt; true,
        &#039;meta_query&#039; =&gt; [
            [
                &#039;key&#039; =&gt; $meta_key,
                &#039;compare&#039; =&gt; &#039;EXISTS&#039;,
            ],
        ],
    ]);

    $count = (int) $uq-&gt;get_total();

    wp_cache_set($cache_key, $count, &#039;ld_course_enroll_count&#039;, 10 * MINUTE_IN_SECONDS);

    return $count;
}

/** 4) 當課程更新時，清掉該課程快取（避免顯示太久舊數字） */
add_action(&#039;save_post_sfwd-courses&#039;, function ($post_id) {
    if (defined(&#039;DOING_AUTOSAVE&#039;) &amp;&amp; DOING_AUTOSAVE)
        return;
    $post_id = (int) $post_id;
    wp_cache_delete(&#039;course_enrolled_&#039; . $post_id, &#039;ld_course_enroll_count&#039;);
}, 20);</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/learndash-enrolled-count-column/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WordPress 自訂文章類型批次內容關鍵字取代工具實作</title>
		<link>https://piglife.tw/technical-notes/wordpress-solution-content-replace-2/</link>
					<comments>https://piglife.tw/technical-notes/wordpress-solution-content-replace-2/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Mon, 29 Dec 2025 22:21:12 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[後台擴充]]></category>
		<category><![CDATA[批次替換]]></category>
		<category><![CDATA[自訂文章類型]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/wordpress-solution-content-replace-2/</guid>

					<description><![CDATA[介紹如何在 WordPress 自訂文章類型 solution 的後台新增批次內容關鍵字替換工具，透...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在管理 WordPress 自訂文章類型（Custom Post Type）時，常會遇到需要批次替換文章內容或摘要中的特定字串的需求。手動逐篇修改不僅耗時，也容易出錯。這段程式碼示範如何在 WordPress 後台為名為 solution 的自訂文章類型新增一個子選單，提供一個簡單介面讓管理員輸入舊字串與新字串，並自動搜尋所有 solution 文章的內容與摘要進行批次替換。</p>
<p>這篇文章適合有基本 WordPress 開發經驗，想要了解如何擴充後台功能並操作自訂文章內容的工程師與自學者。</p>
<h2 class="wp-block-heading">新增後台子選單</h2>
<p>使用 <code>add_action(&#039;admin_menu&#039;, ...)</code> 來掛載函式，先判斷 solution 文章類型是否存在，避免錯誤。接著用 <code>add_submenu_page</code> 在 solution 文章列表下新增「內容關鍵字取代」的子選單，權限設定為可編輯文章的用戶。</p>
<pre><code class="lang-php language-php php">add_action(&#039;admin_menu&#039;, function () {
    if (!post_type_exists(&#039;solution&#039;)) {
        return;
    }

    add_submenu_page(
        &#039;edit.php?post_type=solution&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;edit_posts&#039;,
        &#039;solution-content-replace&#039;,
        &#039;solution_content_replace_page&#039;
    );
});</code></pre>
<p>這段程式碼確保只有在 solution 文章類型存在時才新增選單，避免後台出現無效連結。</p>
<h2 class="wp-block-heading">後台頁面與表單設計</h2>
<p><code>solution_content_replace_page</code> 函式負責渲染後台頁面。首先檢查使用者權限，防止未授權存取。頁面包含一個表單，讓使用者輸入「舊字串」與「新字串」，並使用 WordPress 的 nonce 機制防止 CSRF 攻擊。</p>
<pre><code class="lang-php language-php php">if (!current_user_can(&#039;edit_posts&#039;)) {
    wp_die(&#039;沒有權限。&#039;);
}

// 表單中使用 wp_nonce_field 產生安全碼</code></pre>
<p>表單送出後會執行字串替換邏輯。</p>
<h2 class="wp-block-heading">批次搜尋與替換邏輯說明</h2>
<p>當表單送出且 nonce 驗證通過後，程式會取得輸入的舊字串與新字串，並用 <code>WP_Query</code> 撈出所有 solution 文章的 ID。</p>
<pre><code class="lang-php language-php php">$query = new WP_Query([
    &#039;post_type&#039;      =&gt; &#039;solution&#039;,
    &#039;post_status&#039;    =&gt; &#039;any&#039;,
    &#039;posts_per_page&#039; =&gt; -1,
    &#039;fields&#039;         =&gt; &#039;ids&#039;,
]);</code></pre>
<p>接著逐篇文章讀取內容（<code>post_content</code>）與摘要（<code>post_excerpt</code>），利用 <code>mb_strpos</code>（若可用）或 <code>strpos</code> 檢查舊字串是否存在。若存在，則用 <code>str_replace</code> 替換。</p>
<pre><code class="lang-php language-php php">if ($content_pos !== false) {
    $updated_content = str_replace($old, $new, $content);
    if ($updated_content !== $content) {
        $update_args[&#039;post_content&#039;] = $updated_content;
        $status_parts[] = &#039;內容&#039;;
    }
}</code></pre>
<p>替換完成後，只有在內容或摘要確實有變動時才呼叫 <code>wp_update_post</code> 進行更新，避免不必要的資料庫寫入。</p>
<h2 class="wp-block-heading">結果呈現與使用者體驗</h2>
<p>執行完替換後，頁面會顯示替換結果列表，包括文章 ID、標題、替換狀態（內容、摘要或兩者）以及快速編輯連結，方便管理者後續檢查與微調。</p>
<p>若未輸入舊字串，會顯示警告；若找不到符合條件的文章，則提示資訊，提升使用者體驗。</p>
<h2 class="wp-block-heading">實務應用與優化建議</h2>
<p>此工具適合用於需要大量文字修正的場景，例如品牌名稱變更、產品名稱更新或錯字修正。未來可擴充功能，如：</p>
<ul>
<li>支援正規表達式替換</li>
<li>限制替換範圍（例如只替換內容或摘要）</li>
<li>增加替換前預覽功能</li>
<li>加入替換記錄與回滾機制</li>
</ul>
<p>此外，批次更新大量文章時，建議搭配分批處理避免伺服器超時。</p>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>請確認使用者權限設定正確，避免無權限使用此工具。</li>
<li>替換字串為空白時，會將舊字串刪除，請謹慎操作。</li>
<li>使用 <code>mb_strpos</code> 可避免多字節字串判斷錯誤，若環境不支援會退回 <code>strpos</code>。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">
&lt;?php
// 在 solution post type 底下新增子選單：內容關鍵字取代
add_action(&#039;admin_menu&#039;, function () {
    if (!post_type_exists(&#039;solution&#039;)) {
        return;
    }

    add_submenu_page(
        &#039;edit.php?post_type=solution&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;edit_posts&#039;,
        &#039;solution-content-replace&#039;,
        &#039;solution_content_replace_page&#039;
    );
});

// 後台頁面
function solution_content_replace_page() {
    if (!current_user_can(&#039;edit_posts&#039;)) {
        wp_die(&#039;沒有權限。&#039;);
    }

    $old = &#039;&#039;;
    $new = &#039;&#039;;
    $results = [];
    $executed = false;

    if (isset($_POST[&#039;solution_replace_submit&#039;])) {
        check_admin_referer(&#039;solution_replace_action&#039;, &#039;solution_replace_nonce&#039;);

        $old = isset($_POST[&#039;old_keyword&#039;]) ? sanitize_text_field($_POST[&#039;old_keyword&#039;]) : &#039;&#039;;
        $new = isset($_POST[&#039;new_keyword&#039;]) ? sanitize_text_field($_POST[&#039;new_keyword&#039;]) : &#039;&#039;;

        $executed = true;

        if ($old !== &#039;&#039;) {
            $query = new WP_Query([
                &#039;post_type&#039;      =&gt; &#039;solution&#039;,
                &#039;post_status&#039;    =&gt; &#039;any&#039;,
                &#039;posts_per_page&#039; =&gt; -1,
                &#039;fields&#039;         =&gt; &#039;ids&#039;,
            ]);

            foreach ($query-&gt;posts as $post_id) {
                $post = get_post($post_id);
                if (!$post) {
                    continue;
                }

                $content = $post-&gt;post_content;
                $excerpt = $post-&gt;post_excerpt;

                // 檢查舊字串是否存在（內容＋摘要）
                if (function_exists(&#039;mb_strpos&#039;)) {
                    $content_pos = ($content !== &#039;&#039;) ? mb_strpos($content, $old) : false;
                    $excerpt_pos = ($excerpt !== &#039;&#039;) ? mb_strpos($excerpt, $old) : false;
                } else {
                    $content_pos = ($content !== &#039;&#039;) ? strpos($content, $old) : false;
                    $excerpt_pos = ($excerpt !== &#039;&#039;) ? strpos($excerpt, $old) : false;
                }

                if ($content_pos !== false || $excerpt_pos !== false) {
                    $update_args = [
                        &#039;ID&#039; =&gt; $post_id,
                    ];
                    $status_parts = [];

                    // 內容替換
                    if ($content_pos !== false) {
                        $updated_content = str_replace($old, $new, $content);
                        if ($updated_content !== $content) {
                            $update_args[&#039;post_content&#039;] = $updated_content;
                            $status_parts[] = &#039;內容&#039;;
                        }
                    }

                    // 摘要替換
                    if ($excerpt_pos !== false) {
                        $updated_excerpt = str_replace($old, $new, $excerpt);
                        if ($updated_excerpt !== $excerpt) {
                            $update_args[&#039;post_excerpt&#039;] = $updated_excerpt;
                            $status_parts[] = &#039;摘要&#039;;
                        }
                    }

                    // 真的有需要更新才存
                    if (!empty($status_parts)) {
                        wp_update_post($update_args);

                        $results[] = [
                            &#039;id&#039;     =&gt; $post_id,
                            &#039;title&#039;  =&gt; $post-&gt;post_title,
                            &#039;status&#039; =&gt; &#039;已替換：&#039; . implode(&#039;、&#039;, $status_parts),
                        ];
                    }
                }
            }
            wp_reset_postdata();
        }
    }
    ?&gt;

&lt;h1&gt;Solution &ndash; 內容關鍵字取代&lt;/h1&gt;

&lt;p&gt;此工具會掃描所有 &lt;code&gt;solution</code> 文章的內容與摘要，將符合的字串批次替換。</p>

        
            

            <table class="form-table">

<tr>

<th><label for="old_keyword">要搜尋的字（舊字串）</label></th>

<td>
                        &lt;input type=&quot;text&quot; id=&quot;old_keyword&quot; name=&quot;old_keyword&quot; class=&quot;regular-text&quot;
                               value=&quot;"
                               placeholder="例如：舊字串"&gt;
                    </td>
                </tr>

<tr>

<th><label for="new_keyword">替換成（新字串）</label></th>

<td>
                        &lt;input type=&quot;text&quot; id=&quot;new_keyword&quot; name=&quot;new_keyword&quot; class=&quot;regular-text&quot;
                               value=&quot;"
                               placeholder="例如：新字串（可空白）"&gt;
                    </td>
                </tr>
            </table>

            
        

        

<h3>取代結果</h3>

            
                <div class="notice notice-warning"><p>請輸入要搜尋的舊字串。</p></div>

            
                <div class="notice notice-info"><p>沒有任何文章的內容或摘要包含「」。</p></div>

            
                <table class="widefat striped">

<thead>

<tr>

<th>ID</th>

<th>標題</th>

<th>狀態</th>

<th>編輯連結</th>
                    </tr>
                    </thead>

<tbody>
                    

<tr>

<td></td>

<td></td>

<td></td>

<td>&lt;a href=&quot;" class="button button-small"&gt;編輯</a></td>
                        </tr>
                    
                    </tbody>
                </table>
            

        

    &lt;?php
}</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/wordpress-solution-content-replace-2/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WooCommerce 後台訂單自訂欄位資料儲存實作說明</title>
		<link>https://piglife.tw/technical-notes/woocommerce-order-meta-save/</link>
					<comments>https://piglife.tw/technical-notes/woocommerce-order-meta-save/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Sun, 28 Dec 2025 22:20:33 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[woocommerce]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[後台欄位儲存]]></category>
		<category><![CDATA[訂單自訂欄位]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/woocommerce-order-meta-save/</guid>

					<description><![CDATA[說明如何在 WooCommerce 後台訂單編輯頁面保存自訂欄位資料，透過安全且兼容新版訂單存儲的方...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在 WooCommerce 訂單後台管理時，常會需要額外新增自訂欄位以收集特定資訊，例如客戶的聯絡電話。這段程式碼示範如何在訂單編輯頁面保存自訂欄位資料，確保資料能正確存入訂單元資料中。適合已熟悉 WooCommerce 且想擴充後台訂單欄位功能的工程師或自學者。</p>
<h2 class="wp-block-heading">為什麼需要自訂欄位儲存機制</h2>
<p>WooCommerce 預設訂單資料結構無法涵蓋所有業務需求，因此經常會透過自訂欄位來擴充。這些欄位必須在後台訂單編輯時能夠被正確讀取與保存，否則資料會遺失。</p>
<h2 class="wp-block-heading">使用 woocommerce_process_shop_order_meta 鉤子</h2>
<p>這個 action 鉤子會在 WooCommerce 處理訂單後台編輯表單時觸發，適合用來攔截並保存自訂欄位資料。</p>
<pre><code class="lang-php language-php php">add_action(&#039;woocommerce_process_shop_order_meta&#039;, function ($order_id) {
    // 權限檢查，避免非授權使用者修改訂單
    if (!current_user_can(&#039;edit_shop_order&#039;, $order_id))
        return;

    $key = &#039;_shipping_phone&#039;; // 自訂欄位名稱

    // 確認表單有送出該欄位
    if (!isset($_POST[$key]))
        return;

    // 清理輸入資料，避免 XSS 或其他注入風險
    $value = wc_clean(wp_unslash($_POST[$key]));

    // 取得訂單物件
    $order = wc_get_order($order_id);
    if (!$order)
        return;

    // 使用 WC_Order API 更新訂單元資料
    $order-&gt;update_meta_data($key, $value);
    $order-&gt;save(); // 必須呼叫 save() 才會寫入資料庫
}, 50);</code></pre>
<h3 class="wp-block-heading">關鍵說明</h3>
<ul>
<li><code>current_user_can</code> 用來確保只有有編輯訂單權限的使用者能執行更新。</li>
<li><code>wc_clean</code> 和 <code>wp_unslash</code> 是 WordPress 與 WooCommerce 提供的安全函式，確保輸入資料安全。</li>
<li>使用 <code>update_meta_data</code> 與 <code>save</code> 是 WooCommerce 推薦的寫入方式，兼容新版 HPOS（高效訂單存儲系統）與舊版 postmeta。</li>
</ul>
<h2 class="wp-block-heading">實務應用與延伸</h2>
<p>此方法可擴充至任何自訂欄位，只要對應修改 <code>$key</code> 與表單名稱即可。實務中，還可搭配後台欄位輸入介面（如使用 <code>woocommerce_admin_order_data_after_billing_address</code> 鉤子）來完整實作自訂欄位的讀寫。</p>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>忘記呼叫 <code>$order-&gt;save()</code> 將導致資料無法寫入。</li>
<li>欄位名稱建議加底線開頭避免與 WooCommerce 原生欄位衝突。</li>
<li>權限檢查不可省略，避免安全問題。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
add_action(&#039;woocommerce_process_shop_order_meta&#039;, function ($order_id) {
    if (!current_user_can(&#039;edit_shop_order&#039;, $order_id))
        return;

    $key = &#039;_shipping_phone&#039;;

    if (!isset($_POST[$key]))
        return;

    $value = wc_clean(wp_unslash($_POST[$key]));

    $order = wc_get_order($order_id);
    if (!$order)
        return;

    $order-&gt;update_meta_data($key, $value);
    $order-&gt;save();
}, 50);</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/woocommerce-order-meta-save/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WordPress 自動將文章 slug 設為文章 ID 的實作技巧</title>
		<link>https://piglife.tw/technical-notes/wordpress-post-slug-id/</link>
					<comments>https://piglife.tw/technical-notes/wordpress-post-slug-id/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Sat, 27 Dec 2025 22:20:37 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[save_post]]></category>
		<category><![CDATA[slug]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[排程文章]]></category>
		<category><![CDATA[文章 ID]]></category>
		<category><![CDATA[網址管理]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/wordpress-post-slug-id/</guid>

					<description><![CDATA[本文介紹如何在 WordPress 中自動將文章 slug 設為文章 ID，避免重複 slug 並確...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在 WordPress 中，文章的 slug（網址別名）預設是根據標題自動產生，或由使用者手動設定。有時候為了簡化網址管理或避免重複 slug，會想將 slug 固定為文章的 ID。這段程式碼示範如何在文章發佈或排程時，自動將 slug 設為文章 ID，適合需要統一網址格式的開發者或網站管理者。</p>
<h2 class="wp-block-heading">為什麼要用文章 ID 當 slug？</h2>
<p>文章 ID 是 WordPress 中每篇文章的唯一識別碼，使用 ID 當 slug 可以避免重複 slug 的問題，也方便在程式中直接以 ID 取用文章，提升網址的穩定性與一致性。</p>
<h2 class="wp-block-heading">程式碼解析</h2>
<h3 class="wp-block-heading">1. 使用 save_post Hook</h3>
<p>程式碼利用 WordPress 的 <code>save_post</code> action，這個鉤子會在文章儲存時觸發，能即時修改文章資料。</p>
<pre><code class="lang-php language-php php">add_action(&#039;save_post&#039;, function ($post_id, $post, $update) {
    // 程式內容
}, 20, 3);</code></pre>
<p>這裡設定優先權為 20，並且接受三個參數：文章 ID、文章物件、是否為更新。</p>
<h3 class="wp-block-heading">2. 基本保護條件</h3>
<p>避免對修訂版本（revision）或自動儲存（autosave）執行，減少不必要的處理。</p>
<pre><code class="lang-php language-php php">if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return;
if (!is_object($post)) return;</code></pre>
<h3 class="wp-block-heading">3. 限制只對文章類型生效</h3>
<p>確保只對 <code>post</code> 文章類型操作，不影響其他自訂文章類型。</p>
<pre><code class="lang-php language-php php">if ($post-&gt;post_type !== &#039;post&#039;) return;</code></pre>
<h3 class="wp-block-heading">4. 限制文章狀態</h3>
<p>只對「已發佈（publish）」與「排程（future）」的文章改寫 slug，避免草稿或其他狀態執行。</p>
<pre><code class="lang-php language-php php">$allowed_status = [&#039;publish&#039;, &#039;future&#039;];
if (!in_array($post-&gt;post_status, $allowed_status, true)) return;</code></pre>
<h3 class="wp-block-heading">5. 判斷是否已是目標 slug</h3>
<p>如果 slug 已經是文章 ID，則不再重寫，避免重複操作。</p>
<pre><code class="lang-php language-php php">$target_slug = (string) $post_id;
if ($post-&gt;post_name === $target_slug) return;</code></pre>
<h3 class="wp-block-heading">6. 防止無限迴圈</h3>
<p><code>wp_update_post</code> 會再次觸發 <code>save_post</code>，容易造成無限迴圈。使用靜態變數 <code>$running</code> 作為旗標，避免重複執行。</p>
<pre><code class="lang-php language-php php">static $running = false;
if ($running) return;
$running = true;</code></pre>
<h3 class="wp-block-heading">7. 更新 slug</h3>
<p>呼叫 <code>wp_update_post</code> 更新文章的 <code>post_name</code> 欄位為文章 ID。</p>
<pre><code class="lang-php language-php php">wp_update_post([
    &#039;ID&#039;        =&gt; $post_id,
    &#039;post_name&#039; =&gt; $target_slug,
]);
$running = false;</code></pre>
<h2 class="wp-block-heading">實務應用與延伸</h2>
<ul>
<li>適合網站需要簡潔、唯一的網址結構，或是避免標題相同導致 slug 重複的情況。</li>
<li>可擴充支援更多文章類型或狀態，只要調整條件判斷即可。</li>
<li>若需保留原本 slug，可改成在自訂欄位或其他欄位存 ID。</li>
<li>注意排程文章的 slug 會在排程時間前就設定完成。</li>
</ul>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>無限迴圈問題是此類操作常見的陷阱，務必使用旗標避免。</li>
<li>使用文章 ID 當 slug 可能不利 SEO，需評估是否符合網站策略。</li>
<li>若網站有使用快取或重寫規則，更新 slug 後可能需要清除快取。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
/**
 * Post 發佈時固定把 slug 設為文章 ID
 * - 只針對 post（文章）
 * - 避免無限迴圈
 * - 若已是 ID slug 就不重寫
 * - 支援 publish / future（排程）/ 更新時維持
 */

add_action(&#039;save_post&#039;, function ($post_id, $post, $update) {

    // 1) 基本保護
    if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return;
    if (!is_object($post)) return;

    // 2) 只針對文章 post
    if ($post-&gt;post_type !== &#039;post&#039;) return;

    // 3) 只在這些狀態下固定（你也可以只留 publish）
    $allowed_status = [&#039;publish&#039;, &#039;future&#039;]; // future = 排程
    if (!in_array($post-&gt;post_status, $allowed_status, true)) return;

    // 4) 目標 slug = 文章 ID
    $target_slug = (string) $post_id;

    // 5) 已經是目標 slug 就不做事
    if ($post-&gt;post_name === $target_slug) return;

    // 6) 防止 save_post -&gt; wp_update_post -&gt; save_post 無限迴圈
    static $running = false;
    if ($running) return;
    $running = true;

    // 7) 更新 slug
    wp_update_post([
        &#039;ID&#039;        =&gt; $post_id,
        &#039;post_name&#039; =&gt; $target_slug,
    ]);

    $running = false;

}, 20, 3);</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/wordpress-post-slug-id/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WordPress Polylang 多語翻譯批次複製工具實作解析</title>
		<link>https://piglife.tw/technical-notes/wordpress-polylang-bulk-copy/</link>
					<comments>https://piglife.tw/technical-notes/wordpress-polylang-bulk-copy/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Wed, 24 Dec 2025 22:21:27 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[acf]]></category>
		<category><![CDATA[Polylang]]></category>
		<category><![CDATA[taxonomy]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[多語翻譯]]></category>
		<category><![CDATA[批次複製]]></category>
		<category><![CDATA[特色圖片]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/wordpress-polylang-bulk-copy/</guid>

					<description><![CDATA[介紹一款基於 Polylang 官方 API 的 WordPress 多語翻譯批次複製工具，支援任意...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在多語網站開發中，使用 Polylang 外掛管理翻譯文章是一種常見做法。但當需要將大量文章從某一語言批次複製到另一語言時，手動操作效率低且容易出錯。這段程式碼提供一個獨立於特定文章類型的批次翻譯複製工具，適合有基礎 WordPress 與 Polylang 使用經驗的工程師或自學者，幫助快速複製文章並保持翻譯關聯。</p>
<h2 class="wp-block-heading">工具功能與設計架構</h2>
<h3 class="wp-block-heading">1. 固定來源語言與多目標語言選擇</h3>
<p>程式碼中定義常數 <code>IR_SOURCE_LANG</code> 為來源語言（此處固定為中文 zh），目標語言則可從下拉選單選擇（預設有英文 en、日文 ja），方便擴充其他語言。</p>
<h3 class="wp-block-heading">2. 獨立後台介面</h3>
<p>透過 <code>add_submenu_page</code> 將工具掛載於 WordPress 後台「工具」選單下，並提供兩種操作模式：</p>
<ul>
<li>單筆測試：指定文章 ID，快速測試複製功能。</li>
<li>批次複製：依選定文章類型，批次複製該語言下所有文章。</li>
</ul>
<h3 class="wp-block-heading">3. 使用 Polylang 官方 API 確保語言設定與翻譯關聯</h3>
<p>核心複製流程依序使用 <code>wp_insert_post</code> 建立新文章，<code>pll_set_post_language</code> 設定語言，最後用 <code>pll_save_post_translations</code> 儲存翻譯群組關聯，確保與 Polylang 外掛的正確整合。</p>
<h2 class="wp-block-heading">核心複製流程解析</h2>
<h3 class="wp-block-heading">Step 1: 確認來源文章與語言</h3>
<p>先取得來源文章，確認文章類型與來源語言是否符合設定，避免誤複製。</p>
<pre><code class="lang-php language-php php">ir_set_lang_official( $source_id, IR_SOURCE_LANG );</code></pre>
<p>這行確保來源文章語言正確。</p>
<h3 class="wp-block-heading">Step 2: 建立新文章</h3>
<p>使用 <code>wp_insert_post</code> 複製文章標題、內容、狀態、作者、日期等基本欄位，確保新文章與原文一致。</p>
<h3 class="wp-block-heading">Step 3: 設定新文章語言</h3>
<p>新文章建立後，設定為目標語言，保持語言一致性。</p>
<h3 class="wp-block-heading">Step 4: 儲存翻譯關聯</h3>
<p>將新文章加入翻譯群組，讓 Polylang 知道這是原文的翻譯版本。</p>
<h3 class="wp-block-heading">Step 5: 複製分類法（Taxonomy）</h3>
<p>複製原文文章所屬的分類與標籤，但排除語言相關的 taxonomy，避免衝突。</p>
<h3 class="wp-block-heading">Step 6: 複製特色圖片</h3>
<p>如果原文有設定特色圖片，將同一張圖片設定給新文章，保持視覺一致性。</p>
<h3 class="wp-block-heading">Step 7: 複製 ACF 自訂欄位</h3>
<p>利用 Advanced Custom Fields (ACF) 官方 API 安全複製所有自訂欄位，避免欄位名稱變動造成錯誤。</p>
<h2 class="wp-block-heading">實務應用與優化建議</h2>
<ul>
<li><strong>資料備份</strong>：批次操作前務必備份資料庫，避免誤操作造成資料遺失。</li>
<li><strong>語言擴充</strong>：可依需求擴充 <code>$allowed_target_langs</code> 陣列，支援更多語言。</li>
<li><strong>效能考量</strong>：批次複製大量文章時，可能造成伺服器負擔，可分批執行或加入排程。</li>
<li><strong>錯誤處理</strong>：目前以回傳日誌方式呈現，可擴充為錯誤通知或記錄檔。</li>
</ul>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>複製後的文章不會自動翻譯內容，僅複製原文內容，需後續人工或機器翻譯。</li>
<li>Polylang API 函式必須存在，否則功能無法使用。</li>
<li>ACF 複製需確保 ACF 外掛已啟用且函式存在。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code>&lt;?php
/**
 * Universal Polylang Bulk Copy Tool (Final Version - FIXED)
 * - 獨立在「工具 &rarr; 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( &#039;ABSPATH&#039; ) ) {
    exit;
}

define( &#039;IR_SOURCE_LANG&#039;, &#039;zh&#039; ); // 固定來源語言（Polylang code）

/* =========================
 * 共用：設定語言（官方 API）
 * ========================= */
function ir_set_lang_official( $post_id, $lang_code ) {
    if ( function_exists( &#039;pll_set_post_language&#039; ) ) {
        pll_set_post_language( $post_id, $lang_code );
    }
}

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

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

    if ( ! current_user_can( &#039;manage_options&#039; ) ) {
        wp_die( &#039;沒有權限。&#039; );
    }

    // Polylang API 檢查
    if ( ! function_exists( &#039;pll_set_post_language&#039; ) || ! function_exists( &#039;pll_save_post_translations&#039; ) ) {
        echo &#039;&lt;div class=&quot;notice notice-error&quot;&gt;&lt;p&gt;Polylang API 不存在，請確認 Polylang 是否啟用。&lt;/p&gt;&lt;/div&gt;&#039;;
        return;
    }

    // 允許的目標語言（依照 Polylang 的 code）
    $allowed_target_langs = [ &#039;en&#039;, &#039;ja&#039; ];

    // 可選 post types（public）
    $post_types = get_post_types( [ &#039;public&#039; =&gt; true ], &#039;objects&#039; );

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

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

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

    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 '<div class="ir-box">';
    echo '<div style="margin-top:0">單筆測試</div>';
    echo '';
    wp_nonce_field( 'ir_single' );

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

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

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

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

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

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

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

    submit_button( '執行批次複製', 'primary', 'ir_bulk_run' );
    echo '';
    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'      =&gt; $post_type,
            'posts_per_page' =&gt; -1,
            'post_status'    =&gt; 'any',
            'lang'           =&gt; IR_SOURCE_LANG, // Polylang 的語言 query var
            'fields'         =&gt; '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-&gt;post_type !== $post_type ) {
        return [ "❌ 文章 {$source_id} 的 post_type 是 {$src-&gt;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 =&gt; $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'      =&gt; $post_type,
        'post_status'    =&gt; $src-&gt;post_status,
        'post_title'     =&gt; $src-&gt;post_title,
        'post_content'   =&gt; $src-&gt;post_content,
        'post_excerpt'   =&gt; $src-&gt;post_excerpt,
        'post_author'    =&gt; $src-&gt;post_author,
        'post_date'      =&gt; $src-&gt;post_date,
        'post_date_gmt'  =&gt; $src-&gt;post_date_gmt,
        'menu_order'     =&gt; $src-&gt;menu_order,
        'post_parent'    =&gt; $src-&gt;post_parent,
    ], true );

    if ( is_wp_error( $new_id ) ) {
        $log[] = '❌ wp_insert_post 錯誤：' . $new_id-&gt;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' =&gt; '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' ) &amp;&amp; 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;
}</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/wordpress-polylang-bulk-copy/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WordPress 後台為自訂文章類型新增內容關鍵字搜尋功能</title>
		<link>https://piglife.tw/technical-notes/wordpress-solution-content-search/</link>
					<comments>https://piglife.tw/technical-notes/wordpress-solution-content-search/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Mon, 22 Dec 2025 22:20:38 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[wp_query]]></category>
		<category><![CDATA[內容關鍵字搜尋]]></category>
		<category><![CDATA[後台搜尋]]></category>
		<category><![CDATA[自訂文章類型]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/wordpress-solution-content-search/</guid>

					<description><![CDATA[介紹如何在 WordPress 自訂文章類型 solution 的後台新增內容關鍵字搜尋功能，透過 ...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在 WordPress 後台管理自訂文章類型（此處為 solution）時，若想快速搜尋文章內容中是否包含特定關鍵字，系統預設的搜尋功能可能無法精準或方便達成此需求。這段程式碼示範如何為 solution 文章類型新增一個後台子選單，提供內容關鍵字搜尋功能，適合需要管理大量文章並快速定位內容的開發者與網站管理員。</p>
<h2 class="wp-block-heading">新增後台子選單</h2>
<p>使用 <code>admin_menu</code> action，先檢查 solution 文章類型是否存在，避免錯誤。接著透過 <code>add_submenu_page</code> 在 solution 列表底下新增「內容關鍵字搜尋」子選單，設定權限為 <code>edit_posts</code>，確保只有具備編輯權限的使用者能操作。</p>
<pre><code class="lang-php language-php php">add_action( &#039;admin_menu&#039;, function () {
    if ( ! post_type_exists( &#039;solution&#039; ) ) {
        return;
    }

    add_submenu_page(
        &#039;edit.php?post_type=solution&#039;,
        &#039;內容關鍵字搜尋&#039;,
        &#039;內容關鍵字搜尋&#039;,
        &#039;edit_posts&#039;,
        &#039;solution-content-search&#039;,
        &#039;solution_content_search_page_render&#039;
    );
} );</code></pre>
<h2 class="wp-block-heading">搜尋頁面渲染與表單處理</h2>
<p><code>solution_content_search_page_render</code> 函式負責渲染搜尋表單與結果頁面。首先檢查使用者權限，避免未授權存取。接著判斷是否有表單送出，並使用 WordPress 的 nonce 機制驗證安全性。</p>
<p>關鍵字透過 <code>sanitize_text_field</code> 與 <code>wp_unslash</code> 清理，避免 XSS 與注入風險。</p>
<pre><code class="lang-php language-php php">if ( isset( $_POST[&#039;solution_content_search_submit&#039;] ) ) {
    check_admin_referer( &#039;solution_content_search_action&#039;, &#039;solution_content_search_nonce&#039; );

    $keyword  = isset( $_POST[&#039;solution_keyword&#039;] ) ? sanitize_text_field( wp_unslash( $_POST[&#039;solution_keyword&#039;] ) ) : &#039;&#039;;
    $searched = true;

    if ( $keyword !== &#039;&#039; ) {
        // ...
    }
}</code></pre>
<h2 class="wp-block-heading">透過 WP_Query 及字串比對精確搜尋</h2>
<p>WordPress 內建搜尋（<code>s</code> 參數）會搜尋標題、內容等欄位，但可能不夠精準。此處先用 <code>WP_Query</code> 以關鍵字搜尋取得可能相關文章 ID，然後逐篇讀取內容，使用 <code>mb_stripos</code>（若有）或 <code>stripos</code> 做不區分大小寫的字串比對，確保內容確實包含關鍵字。</p>
<pre><code class="lang-php language-php php">$query = new WP_Query( array(
    &#039;post_type&#039;      =&gt; &#039;solution&#039;,
    &#039;post_status&#039;    =&gt; &#039;any&#039;,
    &#039;posts_per_page&#039; =&gt; -1,
    &#039;s&#039;              =&gt; $keyword,
    &#039;fields&#039;         =&gt; &#039;ids&#039;,
) );

foreach ( $query-&gt;posts as $post_id ) {
    $post = get_post( $post_id );
    if ( ! $post ) {
        continue;
    }

    $content = wp_strip_all_tags( $post-&gt;post_content );

    if ( function_exists( &#039;mb_stripos&#039; ) ) {
        $pos = mb_stripos( $content, $keyword );
    } else {
        $pos = stripos( $content, $keyword );
    }

    if ( $pos !== false ) {
        $results[] = $post_id;
    }
}
wp_reset_postdata();</code></pre>
<h2 class="wp-block-heading">搜尋結果呈現與操作介面</h2>
<p>搜尋結果會以表格形式呈現，包含文章 ID、標題（連結至編輯頁面）、前台連結與編輯按鈕，方便快速查看與編輯。若無輸入關鍵字或找不到結果，會顯示相應提示訊息。</p>
<p>表單使用 WordPress 內建的 <code>wp_nonce_field</code> 與 <code>submit_button</code>，確保安全與一致性。</p>
<h2 class="wp-block-heading">實務應用與優化方向</h2>
<p>此搜尋功能適合用於自訂文章類型管理，尤其是內容量大且需要精準定位文本的場景。未來可考慮加入分頁功能避免一次載入過多文章，或結合全文索引外掛提升搜尋效能。也可擴充搜尋範圍至自訂欄位或分類。</p>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
// 在「solution」這個 post type 底下新增一個後台子選單：內容關鍵字搜尋
add_action( &#039;admin_menu&#039;, function () {
    // 確保有這個 post type
    if ( ! post_type_exists( &#039;solution&#039; ) ) {
        return;
    }

    add_submenu_page(
        &#039;edit.php?post_type=solution&#039;,          // 父層 (solution 列表底下)
        &#039;內容關鍵字搜尋&#039;,                        // 頁面標題
        &#039;內容關鍵字搜尋&#039;,                        // 左側選單名稱
        &#039;edit_posts&#039;,                           // 權限
        &#039;solution-content-search&#039;,              // slug
        &#039;solution_content_search_page_render&#039;   // callback
    );
} );

// 後台頁面：渲染搜尋表單與結果
function solution_content_search_page_render() {
    if ( ! current_user_can( &#039;edit_posts&#039; ) ) {
        wp_die( &#039;沒有權限檢視此頁面。&#039; );
    }

    $keyword  = &#039;&#039;;
    $results  = array();
    $searched = false;

    // 處理表單送出
    if ( isset( $_POST[&#039;solution_content_search_submit&#039;] ) ) {
        check_admin_referer( &#039;solution_content_search_action&#039;, &#039;solution_content_search_nonce&#039; );

        $keyword  = isset( $_POST[&#039;solution_keyword&#039;] ) ? sanitize_text_field( wp_unslash( $_POST[&#039;solution_keyword&#039;] ) ) : &#039;&#039;;
        $searched = true;

        if ( $keyword !== &#039;&#039; ) {
            // 先用 WP_Query 找出可能包含關鍵字的文章 (全文搜尋)
            $query = new WP_Query( array(
                &#039;post_type&#039;      =&gt; &#039;solution&#039;,
                &#039;post_status&#039;    =&gt; &#039;any&#039;,
                &#039;posts_per_page&#039; =&gt; -1,
                &#039;s&#039;              =&gt; $keyword,
                &#039;fields&#039;         =&gt; &#039;ids&#039;,
            ) );

            if ( $query-&gt;have_posts() ) {
                foreach ( $query-&gt;posts as $post_id ) {
                    $post = get_post( $post_id );
                    if ( ! $post ) {
                        continue;
                    }

                    // 從內容中做字串檢查（確保 content 真的有包含關鍵字）
                    $content = wp_strip_all_tags( $post-&gt;post_content );

                    if ( function_exists( &#039;mb_stripos&#039; ) ) {
                        $pos = mb_stripos( $content, $keyword );
                    } else {
                        $pos = stripos( $content, $keyword );
                    }

                    if ( $pos !== false ) {
                        $results[] = $post_id;
                    }
                }
            }
            wp_reset_postdata();
        }
    }

    ?&gt;
    &lt;div class=&quot;wrap&quot;&gt;

&lt;h1&gt;Solution 內容關鍵字搜尋&lt;/h1&gt;

&lt;p&gt;輸入一個關鍵字，系統會掃描 &amp;lt;code&amp;gt;solution&amp;lt;/code&amp;gt;
 文章類型的內容（content），只要有包含該字串，就列出標題與編輯連結。&lt;/p&gt;

        &lt;form method=&quot;post&quot; style=&quot;margin-top: 1em; margin-bottom: 2em;&quot;&gt;
            &lt;?php wp_nonce_field( &#039;solution_content_search_action&#039;, &#039;solution_content_search_nonce&#039; ); ?&gt;

            &lt;table class=&quot;form-table&quot; role=&quot;presentation&quot;&gt;

&lt;tr&gt;
                    &lt;th scope=&quot;row&quot;&gt;
                        &lt;label for=&quot;solution_keyword&quot;&gt;關鍵字字串&lt;/label&gt;
                    &lt;/th&gt;

&lt;td&gt;
                        &lt;input
                            type=&quot;text&quot;
                            id=&quot;solution_keyword&quot;
                            name=&quot;solution_keyword&quot;
                            class=&quot;regular-text&quot;
                            value=&quot;&lt;?php echo esc_attr( $keyword ); ?&gt;&quot;
                            placeholder=&quot;例如：API、某段程式碼、特定中文句子&quot;
                        /&gt;
                    &lt;/td&gt;
                &lt;/tr&gt;
            &lt;/table&gt;

            &lt;?php submit_button( &#039;開始搜尋&#039;, &#039;primary&#039;, &#039;solution_content_search_submit&#039; ); ?&gt;
        &lt;/form&gt;

        &lt;?php if ( $searched ) : ?&gt;

&lt;h2&gt;搜尋結果&lt;/h2&gt;

            &lt;?php if ( $keyword === &#039;&#039; ) : ?&gt;
                &lt;div class=&quot;notice notice-warning&quot;&gt;&lt;p&gt;請輸入關鍵字。&lt;/p&gt;&lt;/div&gt;
            &lt;?php elseif ( empty( $results ) ) : ?&gt;
                &lt;div class=&quot;notice notice-info&quot;&gt;&lt;p&gt;沒有找到內容包含「&lt;?php echo esc_html( $keyword ); ?&gt;」的 solution 文章。&lt;/p&gt;&lt;/div&gt;
            &lt;?php else : ?&gt;

&lt;p&gt;找到 &lt;?php echo esc_html( count( $results ) ); ?&gt; 篇文章，內容中包含「&lt;?php echo esc_html( $keyword ); ?&gt;」。&lt;/p&gt;

                &lt;table class=&quot;widefat fixed striped&quot;&gt;

&lt;thead&gt;

&lt;tr&gt;
                            &lt;th scope=&quot;col&quot; style=&quot;width: 50px;&quot;&gt;ID&lt;/th&gt;
                            &lt;th scope=&quot;col&quot;&gt;標題&lt;/th&gt;
                            &lt;th scope=&quot;col&quot; style=&quot;width: 200px;&quot;&gt;前台連結&lt;/th&gt;
                            &lt;th scope=&quot;col&quot; style=&quot;width: 150px;&quot;&gt;編輯&lt;/th&gt;
                        &lt;/tr&gt;
                    &lt;/thead&gt;

&lt;tbody&gt;
                    &lt;?php foreach ( $results as $post_id ) :
                        $title     = get_the_title( $post_id );
                        $view_link = get_permalink( $post_id );
                        $edit_link = get_edit_post_link( $post_id, &#039;&#039; );
                    ?&gt;

&lt;tr&gt;

&lt;td&gt;&lt;?php echo esc_html( $post_id ); ?&gt;&lt;/td&gt;

&lt;td&gt;
                                &lt;?php if ( $edit_link ) : ?&gt;
                                    &lt;a href=&quot;&lt;?php echo esc_url( $edit_link ); ?&gt;&quot;&gt;
                                        &lt;?php echo esc_html( $title ); ?&gt;
                                    &lt;/a&gt;
                                &lt;?php else : ?&gt;
                                    &lt;?php echo esc_html( $title ); ?&gt;
                                &lt;?php endif; ?&gt;
                            &lt;/td&gt;

&lt;td&gt;
                                &lt;?php if ( $view_link ) : ?&gt;
                                    &lt;a href=&quot;&lt;?php echo esc_url( $view_link ); ?&gt;&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;
                                        檢視
                                    &lt;/a&gt;
                                &lt;?php else : ?&gt;
                                    -
                                &lt;?php endif; ?&gt;
                            &lt;/td&gt;

&lt;td&gt;
                                &lt;?php if ( $edit_link ) : ?&gt;
                                    &lt;a href=&quot;&lt;?php echo esc_url( $edit_link ); ?&gt;&quot; class=&quot;button button-small&quot;&gt;
                                        編輯文章
                                    &lt;/a&gt;
                                &lt;?php else : ?&gt;
                                    -
                                &lt;?php endif; ?&gt;
                            &lt;/td&gt;
                        &lt;/tr&gt;
                    &lt;?php endforeach; ?&gt;
                    &lt;/tbody&gt;
                &lt;/table&gt;
            &lt;?php endif; ?&gt;
        &lt;?php endif; ?&gt;
    &lt;/div&gt;
    &lt;?php
}</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/wordpress-solution-content-search/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WordPress 自訂文章類型批次內容關鍵字取代工具實作</title>
		<link>https://piglife.tw/technical-notes/wordpress-solution-content-replace/</link>
					<comments>https://piglife.tw/technical-notes/wordpress-solution-content-replace/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Sun, 21 Dec 2025 22:20:56 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[後台功能]]></category>
		<category><![CDATA[批次替換]]></category>
		<category><![CDATA[自訂文章類型]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/wordpress-solution-content-replace/</guid>

					<description><![CDATA[介紹如何在 WordPress 自訂文章類型 solution 中新增後台子選單，實作批次搜尋並替換...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在 WordPress 後台管理自訂文章類型（Custom Post Type）時，有時需要快速批次更新文章內容中的特定字串。這段程式碼示範如何在名為 <code>solution</code> 的自訂文章類型新增一個子選單，提供後台管理者輸入舊字串與新字串，並批次替換所有相關文章內容中的文字。適合有基本 WordPress 開發經驗，想要自訂後台功能以提升內容維護效率的工程師或自學者。</p>
<h2 class="wp-block-heading">新增子選單介面</h2>
<p>利用 <code>admin_menu</code> action 新增子選單，並限定該選單只在 <code>solution</code> 文章類型下顯示。權限設定為 <code>edit_posts</code>，確保只有有編輯文章權限的使用者能操作。</p>
<pre><code class="lang-php language-php php">add_action(&#039;admin_menu&#039;, function () {
    if (!post_type_exists(&#039;solution&#039;)) {
        return;
    }

    add_submenu_page(
        &#039;edit.php?post_type=solution&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;edit_posts&#039;,
        &#039;solution-content-replace&#039;,
        &#039;solution_content_replace_page&#039;
    );
});</code></pre>
<p>這段程式碼確保子選單只會在 <code>solution</code> 文章類型的管理頁面出現，並且點擊後會呼叫 <code>solution_content_replace_page</code> 函式來渲染頁面。</p>
<h2 class="wp-block-heading">後台頁面與表單處理</h2>
<p><code>solution_content_replace_page</code> 函式負責顯示輸入表單與處理替換邏輯。首先檢查使用者權限，避免未授權存取。接著使用 WordPress 的 Nonce 機制保障表單安全。</p>
<pre><code class="lang-php language-php php">if (!current_user_can(&#039;edit_posts&#039;)) {
    wp_die(&#039;沒有權限。&#039;);
}

if (isset($_POST[&#039;solution_replace_submit&#039;])) {
    check_admin_referer(&#039;solution_replace_action&#039;, &#039;solution_replace_nonce&#039;);

    $old = isset($_POST[&#039;old_keyword&#039;]) ? sanitize_text_field($_POST[&#039;old_keyword&#039;]) : &#039;&#039;;
    $new = isset($_POST[&#039;new_keyword&#039;]) ? sanitize_text_field($_POST[&#039;new_keyword&#039;]) : &#039;&#039;;

    // 進行批次替換
}</code></pre>
<p>使用者輸入的舊字串與新字串會經過 <code>sanitize_text_field</code> 清理，避免 XSS 或其他安全問題。</p>
<h2 class="wp-block-heading">批次搜尋與替換邏輯</h2>
<p>當舊字串不為空時，使用 <code>WP_Query</code> 撈取所有 <code>solution</code> 文章的 ID，並逐篇讀取內容。若文章內容包含舊字串，則使用 <code>str_replace</code> 替換後，呼叫 <code>wp_update_post</code> 更新文章。</p>
<pre><code class="lang-php language-php php">$query = new WP_Query([
    &#039;post_type&#039;      =&gt; &#039;solution&#039;,
    &#039;post_status&#039;    =&gt; &#039;any&#039;,
    &#039;posts_per_page&#039; =&gt; -1,
    &#039;fields&#039;         =&gt; &#039;ids&#039;,
]);

foreach ($query-&gt;posts as $post_id) {
    $post = get_post($post_id);
    if (!$post) {
        continue;
    }

    $content = $post-&gt;post_content;

    if (mb_strpos($content, $old) !== false) {
        $updated_content = str_replace($old, $new, $content);

        wp_update_post([
            &#039;ID&#039;           =&gt; $post_id,
            &#039;post_content&#039; =&gt; $updated_content
        ]);

        $results[] = [
            &#039;id&#039;     =&gt; $post_id,
            &#039;title&#039;  =&gt; $post-&gt;post_title,
            &#039;status&#039; =&gt; &#039;已替換&#039;
        ];
    }
}
wp_reset_postdata();</code></pre>
<h2 class="wp-block-heading">結果呈現與使用者體驗</h2>
<p>頁面會根據執行狀態顯示不同訊息：</p>
<ul>
<li>若未輸入舊字串，提醒使用者必須填寫。</li>
<li>若找不到包含舊字串的文章，顯示資訊提示。</li>
<li>若有替換成功的文章，列出文章 ID、標題、狀態與編輯連結，方便管理者後續檢視。</li>
</ul>
<p>這樣的設計讓使用者能清楚知道批次操作的結果，並快速跳轉編輯。</p>
<h2 class="wp-block-heading">實務應用與優化建議</h2>
<p>此功能適合內容量大且需定期文字修正的網站，如產品說明、技術文件等。未來可擴充：</p>
<ul>
<li>支援正則表達式替換，提高靈活度。</li>
<li>加入替換前後內容差異預覽，降低誤替換風險。</li>
<li>加入分頁或批次處理，避免大量文章一次更新造成伺服器負擔。</li>
</ul>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>請確認使用者權限設定正確，避免誤用。</li>
<li>替換操作無法還原，建議先備份資料庫。</li>
<li>文字替換為純字串，不支援 HTML 或多語系複雜處理。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
// 在 solution post type 底下新增子選單：內容關鍵字取代
add_action(&#039;admin_menu&#039;, function () {
    if (!post_type_exists(&#039;solution&#039;)) {
        return;
    }

    add_submenu_page(
        &#039;edit.php?post_type=solution&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;內容關鍵字取代&#039;,
        &#039;edit_posts&#039;,
        &#039;solution-content-replace&#039;,
        &#039;solution_content_replace_page&#039;
    );
});

// 後台頁面
function solution_content_replace_page() {
    if (!current_user_can(&#039;edit_posts&#039;)) {
        wp_die(&#039;沒有權限。&#039;);
    }

    $old = &#039;&#039;;
    $new = &#039;&#039;;
    $results = [];
    $executed = false;

    if (isset($_POST[&#039;solution_replace_submit&#039;])) {
        check_admin_referer(&#039;solution_replace_action&#039;, &#039;solution_replace_nonce&#039;);

        $old = isset($_POST[&#039;old_keyword&#039;]) ? sanitize_text_field($_POST[&#039;old_keyword&#039;]) : &#039;&#039;;
        $new = isset($_POST[&#039;new_keyword&#039;]) ? sanitize_text_field($_POST[&#039;new_keyword&#039;]) : &#039;&#039;;

        $executed = true;

        if ($old !== &#039;&#039;) {
            $query = new WP_Query([
                &#039;post_type&#039;      =&gt; &#039;solution&#039;,
                &#039;post_status&#039;    =&gt; &#039;any&#039;,
                &#039;posts_per_page&#039; =&gt; -1,
                &#039;fields&#039;         =&gt; &#039;ids&#039;,
            ]);

            foreach ($query-&gt;posts as $post_id) {
                $post = get_post($post_id);
                if (!$post) {
                    continue;
                }

                $content = $post-&gt;post_content;

                // 檢查舊字串是否存在
                if (mb_strpos($content, $old) !== false) {
                    // 替換
                    $updated_content = str_replace($old, $new, $content);

                    // 更新文章
                    wp_update_post([
                        &#039;ID&#039;           =&gt; $post_id,
                        &#039;post_content&#039; =&gt; $updated_content
                    ]);

                    $results[] = [
                        &#039;id&#039;     =&gt; $post_id,
                        &#039;title&#039;  =&gt; $post-&gt;post_title,
                        &#039;status&#039; =&gt; &#039;已替換&#039;
                    ];
                }
            }
            wp_reset_postdata();
        }
    }
    ?&gt;

&lt;div class=&quot;wrap&quot;&gt;

&lt;h2&gt;Solution &ndash; 內容關鍵字取代&lt;/h2&gt;

        &lt;form method=&quot;post&quot;&gt;
            &lt;?php wp_nonce_field(&#039;solution_replace_action&#039;, &#039;solution_replace_nonce&#039;); ?&gt;

            &lt;table class=&quot;form-table&quot;&gt;

&lt;tr&gt;

&lt;th&gt;&lt;label for=&quot;old_keyword&quot;&gt;要搜尋的字（舊字串）&lt;/label&gt;&lt;/th&gt;

&lt;td&gt;
                        &lt;input type=&quot;text&quot; id=&quot;old_keyword&quot; name=&quot;old_keyword&quot; class=&quot;regular-text&quot;
                               value=&quot;&lt;?php echo esc_attr($old); ?&gt;&quot;
                               placeholder=&quot;例如：舊字串&quot;&gt;
                    &lt;/td&gt;
                &lt;/tr&gt;

&lt;tr&gt;

&lt;th&gt;&lt;label for=&quot;new_keyword&quot;&gt;替換成（新字串）&lt;/label&gt;&lt;/th&gt;

&lt;td&gt;
                        &lt;input type=&quot;text&quot; id=&quot;new_keyword&quot; name=&quot;new_keyword&quot; class=&quot;regular-text&quot;
                               value=&quot;&lt;?php echo esc_attr($new); ?&gt;&quot;
                               placeholder=&quot;例如：新字串（可空白）&quot;&gt;
                    &lt;/td&gt;
                &lt;/tr&gt;
            &lt;/table&gt;

            &lt;?php submit_button(&#039;開始批次取代&#039;, &#039;primary&#039;, &#039;solution_replace_submit&#039;); ?&gt;
        &lt;/form&gt;

        &lt;?php if ($executed): ?&gt;

&lt;h2&gt;取代結果&lt;/h2&gt;

            &lt;?php if ($old === &#039;&#039;): ?&gt;
                &lt;div class=&quot;notice notice-warning&quot;&gt;&lt;p&gt;請輸入要搜尋的舊字串。&lt;/p&gt;&lt;/div&gt;

            &lt;?php elseif (empty($results)): ?&gt;
                &lt;div class=&quot;notice notice-info&quot;&gt;&lt;p&gt;沒有任何文章包含「&lt;?php echo esc_html($old); ?&gt;」。&lt;/p&gt;&lt;/div&gt;

            &lt;?php else: ?&gt;
                &lt;table class=&quot;widefat striped&quot;&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;ID&lt;/th&gt;

&lt;th&gt;標題&lt;/th&gt;

&lt;th&gt;狀態&lt;/th&gt;

&lt;th&gt;編輯連結&lt;/th&gt;
                    &lt;/tr&gt;
                    &lt;/thead&gt;

&lt;tbody&gt;
                    &lt;?php foreach ($results as $r): ?&gt;

&lt;tr&gt;

&lt;td&gt;&lt;?php echo esc_html($r[&#039;id&#039;]); ?&gt;&lt;/td&gt;

&lt;td&gt;&lt;?php echo esc_html($r[&#039;title&#039;]); ?&gt;&lt;/td&gt;

&lt;td&gt;&lt;?php echo esc_html($r[&#039;status&#039;]); ?&gt;&lt;/td&gt;

&lt;td&gt;&lt;a href=&quot;&lt;?php echo get_edit_post_link($r[&#039;id&#039;]); ?&gt;&quot; class=&quot;button button-small&quot;&gt;編輯&lt;/a&gt;&lt;/td&gt;
                        &lt;/tr&gt;
                    &lt;?php endforeach; ?&gt;
                    &lt;/tbody&gt;
                &lt;/table&gt;
            &lt;?php endif; ?&gt;

        &lt;?php endif; ?&gt;
    &lt;/div&gt;

    &lt;?php
}</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/wordpress-solution-content-replace/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>使用 Polylang 官方流程批量複製 WordPress 文章翻譯</title>
		<link>https://piglife.tw/technical-notes/polylang-bulk-copy-translations/</link>
					<comments>https://piglife.tw/technical-notes/polylang-bulk-copy-translations/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Sat, 20 Dec 2025 22:21:10 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[acf]]></category>
		<category><![CDATA[Polylang]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[wp_insert_post]]></category>
		<category><![CDATA[多語系]]></category>
		<category><![CDATA[翻譯複製]]></category>
		<category><![CDATA[自訂文章類型]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/polylang-bulk-copy-translations/</guid>

					<description><![CDATA[介紹如何使用 Polylang 官方 API 批量複製 WordPress 自訂文章類型的內容，建立...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>這段 PHP 程式碼是為了解決 WordPress 使用 Polylang 多語系外掛時，如何批量將特定文章類型的內容從一種語言複製到另一種語言，並建立正確的翻譯關聯。適合需要管理大量多語系內容的網站管理員或開發者，尤其是想自動化翻譯文章複製流程的技術人員。</p>
<h2 class="wp-block-heading">Polylang 官方 API 的使用背景</h2>
<p>Polylang 提供了多個官方函式來管理文章語言與翻譯關聯，如 <code>pll_set_post_language</code> 設定文章語言，<code>pll_save_post_translations</code> 儲存翻譯群組，這是官方推薦的做法，能確保多語系資料完整且一致。</p>
<h2 class="wp-block-heading">後台介面設計與操作流程</h2>
<h3 class="wp-block-heading">後台子選單與頁面</h3>
<p>程式碼透過 <code>add_submenu_page</code> 在自訂文章類型「investor_relations」的管理頁面下新增子選單，提供單筆測試與批量複製兩種操作介面。使用者可選擇目標語言（目前限制為英文與日文），並執行對應動作。</p>
<h3 class="wp-block-heading">權限與安全檢查</h3>
<p>頁面載入時會檢查使用者是否有 <code>manage_options</code> 權限，並確認 Polylang API 函式存在，避免外掛未啟用或權限不足導致錯誤。</p>
<h2 class="wp-block-heading">核心功能：複製文章並建立翻譯關聯</h2>
<h3 class="wp-block-heading">1. 確認來源文章及語言設定</h3>
<p>函式 <code>ir_clone_post_official</code> 會先取得來源文章，並用 <code>pll_set_post_language</code> 強制設定來源文章語言，確保語言標記正確。</p>
<pre><code class="lang-php language-php php">ir_set_lang_official( $source_id, $source_lang );</code></pre>
<h3 class="wp-block-heading">2. 檢查目標語言翻譯是否已存在</h3>
<p>利用 <code>pll_get_post_translations</code> 取得該文章的所有語言版本，若目標語言已存在翻譯文章，則跳過建立，避免重複。</p>
<h3 class="wp-block-heading">3. 建立新文章（翻譯版本）</h3>
<p>使用 WordPress 官方函式 <code>wp_insert_post</code> 複製文章內容與屬性，並設定新文章的語言為目標語言。</p>
<pre><code class="lang-php language-php php">$new_id = wp_insert_post([...]);
ir_set_lang_official( $new_id, $target_lang );</code></pre>
<h3 class="wp-block-heading">4. 儲存翻譯關聯</h3>
<p>將來源文章與新文章的語言關聯資料更新至 Polylang，確保前台語言切換功能正常。</p>
<pre><code class="lang-php language-php php">$translations[ $target_lang ] = $new_id;
pll_save_post_translations( $translations );</code></pre>
<h3 class="wp-block-heading">5. 複製分類法與特色圖片</h3>
<p>排除語言分類法後，複製其他自訂分類法的關聯，並複製特色圖片，保持內容一致性。</p>
<h3 class="wp-block-heading">6. 複製 ACF 自訂欄位資料</h3>
<p>若有安裝 Advanced Custom Fields 外掛，會將所有欄位資料一併複製，包含上傳檔案，避免資料遺失。</p>
<h2 class="wp-block-heading">實務應用與優化建議</h2>
<ul>
<li><strong>備份資料庫</strong>：執行批量複製前務必備份，避免誤操作導致資料錯亂。</li>
<li><strong>單筆測試</strong>：先用單筆測試功能確保流程正常，再進行批量操作。</li>
<li><strong>擴充語言</strong>：可依需求新增允許的目標語言陣列。</li>
<li><strong>錯誤處理</strong>：可加強錯誤回報與日誌紀錄，方便除錯與維護。</li>
</ul>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>Polylang API 函式不存在時，需確認外掛是否啟用。</li>
<li>複製文章時，若有其他外掛影響文章資料，可能需要額外處理。</li>
<li>語言分類法不應被複製，以免覆寫 Polylang 的語言設定。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
/**
 * Investor Relations 單語言批量翻譯複製工具（最終版）
 * - 完全走 Polylang 官方流程：
 *   wp_insert_post + pll_set_post_language + pll_save_post_translations
 * - 自動跳過「已經有目標語言翻譯」的文章
 */

define( &#039;IR_SOURCE_LANG&#039;, &#039;zh&#039; );                  // 來源語言：中文
define( &#039;IR_POST_TYPE&#039;, &#039;investor_relations&#039; );    // Post type：investor_relations

/* =========================
 * 共用：設定語言（官方 API）
 * ========================= */
function ir_set_lang_official( $post_id, $lang_code ) {
    if ( function_exists( &#039;pll_set_post_language&#039; ) ) {
        pll_set_post_language( $post_id, $lang_code );
    }
}

/* =========================
 * 後台子選單
 * ========================= */
add_action( &#039;admin_menu&#039;, function () {
    add_submenu_page(
        &#039;edit.php?post_type=&#039; . IR_POST_TYPE,
        &#039;Bulk Copy Translations (Official)&#039;,
        &#039;Bulk Copy Translations (Official)&#039;,
        &#039;manage_options&#039;,
        &#039;ir-bulk-translations-official&#039;,
        &#039;ir_bulk_translations_official_page&#039;
    );
});

/* =========================
 * 後台頁面 + 控制流程
 * ========================= */
function ir_bulk_translations_official_page() {

    // 允許的目標語言（依照 Polylang 的 code）
    $allowed_target_langs = [ &#039;en&#039;, &#039;ja&#039; ];

    if ( ! current_user_can( &#039;manage_options&#039; ) ) {
        wp_die( &#039;沒有權限。&#039; );
    }

    if ( ! function_exists( &#039;pll_set_post_language&#039; ) || ! function_exists( &#039;pll_save_post_translations&#039; ) ) {
        echo &#039;&lt;div class=&quot;notice notice-error&quot;&gt;&lt;p&gt;Polylang API 不存在，請確認外掛是否啟用。&lt;/p&gt;&lt;/div&gt;&#039;;
        return;
    }

    // 目前選擇的目標語言（預設 en）
    $current_target = isset( $_POST[&#039;ir_target_lang&#039;] )
        ? sanitize_text_field( $_POST[&#039;ir_target_lang&#039;] )
        : &#039;en&#039;;

    if ( ! in_array( $current_target, $allowed_target_langs, true ) ) {
        $current_target = &#039;en&#039;;
    }

    echo &#039;
&lt;h1&gt;Investor Relations 翻譯複製工具（官方流程版）&lt;/h1&gt;&#039;;
    echo &#039;
&lt;p&gt;來源語言固定為：&lt;code&gt;&#039; . esc_html( IR_SOURCE_LANG ) . &#039;</code></p>';
    echo '<p style="color:red"><strong>⚠️ 執行前務必先備份資料庫！</strong></p>';

    echo '
.ir-lang-select{margin-left:8px;}';

    /* ------------ 單筆測試表單 ------------- */
    echo '
<p>單筆文章測試</p>';
    echo '';
    wp_nonce_field( 'ir_single_test_official' );
    echo '<label>輸入文章 ID：<input type="number" name="ir_test_id"></label>';

    echo '<span class="ir-lang-select">目標語言：';
    echo '';
    foreach ( $allowed_target_langs as $code ) {
        printf(
            '%s',
            esc_attr( $code ),
            selected( $current_target, $code, false ),
            esc_html( strtoupper( $code ) )
        );
    }
    echo '</span> ';

    submit_button( '測試複製此文章', 'secondary', 'ir_single_test', false );
    echo '';

    /* ------------ 批次表單 ------------- */
    echo '
<p>批量複製所有中文文章</p>';
    echo '';
    wp_nonce_field( 'ir_bulk_official' );

    echo '<span>目標語言：';
    echo '';
    foreach ( $allowed_target_langs as $code ) {
        printf(
            '%s',
            esc_attr( $code ),
            selected( $current_target, $code, false ),
            esc_html( strtoupper( $code ) )
        );
    }
    echo '</span> ';

    submit_button( '執行批次複製（請先單筆測試）', 'primary', 'ir_bulk_run', false );
    echo '';

    /* ------------ 執行動作 ------------- */

    // 單筆
    if ( isset( $_POST['ir_single_test'] ) ) {
        check_admin_referer( 'ir_single_test_official' );
        $post_id = isset( $_POST['ir_test_id'] ) ? intval( $_POST['ir_test_id'] ) : 0;
        $target  = isset( $_POST['ir_target_lang'] ) ? sanitize_text_field( $_POST['ir_target_lang'] ) : 'en';
        ir_run_single_copy_official( $post_id, $target );
    }

    // 批次
    if ( isset( $_POST['ir_bulk_run'] ) ) {
        check_admin_referer( 'ir_bulk_official' );
        $target = isset( $_POST['ir_target_lang'] ) ? sanitize_text_field( $_POST['ir_target_lang'] ) : 'en';
        ir_run_bulk_copy_official( $target );
    }

}</code></pre>
<pre><code class="lang-php language-php php">function ir_run_single_copy_official( $post_id, $target_lang ) {
    echo &#039;
&lt;h3&gt;單筆測試結果&lt;/h3&gt;&#039;;

    if ( ! $post_id || ! get_post( $post_id ) ) {
        echo &#039;&lt;p style=&quot;color:red;&quot;&gt;找不到文章 ID：&#039; . esc_html( $post_id ) . &#039;&lt;/p&gt;&#039;;
        return;
    }

    $report = ir_clone_post_official( $post_id, $target_lang );

    echo &#039;&lt;pre style=&quot;background:white; padding:10px; border:1px solid #ccc;&quot;&gt;&#039;;
    echo esc_html( implode( &quot;\n&quot;, $report ) );
    echo &#039;&lt;/pre&gt;&#039;;
}

/* =========================
 * 批量處理所有中文文章（官方流程）
 * ========================= */
function ir_run_bulk_copy_official( $target_lang ) {
    $posts = get_posts( [
        &#039;post_type&#039;      =&gt; IR_POST_TYPE,
        &#039;posts_per_page&#039; =&gt; -1,
        &#039;post_status&#039;    =&gt; &#039;any&#039;,
        &#039;lang&#039;           =&gt; IR_SOURCE_LANG, // Polylang 的語言 query var
        &#039;fields&#039;         =&gt; &#039;ids&#039;,
    ] );

    echo &#039;
&lt;h2&gt;批次執行結果（目標語言：&#039; . esc_html( strtoupper( $target_lang ) ) . &#039;）&lt;/h2&gt;&#039;;

    if ( empty( $posts ) ) {
        echo &#039;
&lt;p&gt;沒有找到來源語言（&#039; . esc_html( IR_SOURCE_LANG ) . &#039;）的文章。&lt;/p&gt;&#039;;
        return;
    }

    $output = [];

    foreach ( $posts as $post_id ) {
        $report  = ir_clone_post_official( $post_id, $target_lang );
        $output  = array_merge( $output, $report );
    }

    echo &#039;&lt;pre style=&quot;background:white; max-height:450px; overflow:auto; padding:10px; border:1px solid #ccc;&quot;&gt;&#039;;
    echo esc_html( implode( &quot;\n&quot;, $output ) );
    echo &#039;&lt;/pre&gt;&#039;;
}

/* =========================
 * 核心：官方流程複製一篇文章 &rarr; 目標語言翻譯
 * ========================= */
function ir_clone_post_official( $source_id, $target_lang ) {
    $report = [];

    $source_post = get_post( $source_id );
    if ( ! $source_post ) {
        $report[] = &quot;❌ 原文 {$source_id} 不存在&quot;;
        return $report;
    }

    $source_lang = IR_SOURCE_LANG;

    // Step 1) 先確保原文語言正確
    ir_set_lang_official( $source_id, $source_lang );
    $report[] = &quot;Step 1) 設定原文語言：{$source_id} &rarr; {$source_lang}&quot;;

    // 取得現有翻譯 group
    $translations = function_exists( &#039;pll_get_post_translations&#039; )
        ? pll_get_post_translations( $source_id )
        : [];

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

    // ✅ 若已存在該語言翻譯 &rarr; 自動跳過
    if ( ! empty( $translations[ $target_lang ] ) ) {
        $existing = $translations[ $target_lang ];
        $report[] = &quot;⚠️ 原文 {$source_id} 已有 {$target_lang} 翻譯（ID {$existing}），跳過建立新文章。&quot;;

        // 做個語言 debug
        $lang_debug = [];
        foreach ( $translations as $code =&gt; $pid ) {
            $lang_debug[] = $code . &#039;:&#039; . $pid . &#039;(&#039; . pll_get_post_language( $pid ) . &#039;)&#039;;
        }
        $report[] = &#039;🧪 目前語言狀態：&#039; . implode( &#039;, &#039;, $lang_debug );

        return $report;
    }

    // Step 2) 使用 wp_insert_post 建立新文章（官方建議做法）
    $new_id = wp_insert_post( [
        &#039;post_type&#039;      =&gt; $source_post-&gt;post_type,
        &#039;post_status&#039;    =&gt; $source_post-&gt;post_status,
        &#039;post_title&#039;     =&gt; $source_post-&gt;post_title,
        &#039;post_content&#039;   =&gt; $source_post-&gt;post_content,
        &#039;post_excerpt&#039;   =&gt; $source_post-&gt;post_excerpt,
        &#039;post_author&#039;    =&gt; $source_post-&gt;post_author,
        &#039;post_date&#039;      =&gt; $source_post-&gt;post_date,
        &#039;post_date_gmt&#039;  =&gt; $source_post-&gt;post_date_gmt,
        &#039;menu_order&#039;     =&gt; $source_post-&gt;menu_order,
    ], true );

    if ( is_wp_error( $new_id ) ) {
        $report[] = &quot;❌ wp_insert_post 錯誤：&quot; . $new_id-&gt;get_error_message();
        return $report;
    }
    $report[] = &quot;Step 2) 建立新文章：{$new_id}&quot;;

    // Step 3) 官方建議：先設定新文章語言
    ir_set_lang_official( $new_id, $target_lang );
    $report[] = &quot;Step 3) 設定新文章語言：{$new_id} &rarr; {$target_lang}&quot;;

    // Step 4) 官方建議：建立翻譯關聯（保留既有語言）
    $translations[ $target_lang ] = $new_id;
    pll_save_post_translations( $translations );
    $report[] = &quot;Step 4) 儲存翻譯關聯：&quot; . json_encode( $translations );

    // Step 5) 複製 taxonomy（排除語言 taxonomy，避免覆寫語言）
    $taxes = get_object_taxonomies( IR_POST_TYPE );
    $taxes = array_diff( $taxes, [ &#039;language&#039;, &#039;pll_language&#039; ] );

    foreach ( $taxes as $tax ) {
        $terms = wp_get_object_terms( $source_id, $tax, [ &#039;fields&#039; =&gt; &#039;ids&#039; ] );
        if ( ! is_wp_error( $terms ) ) {
            wp_set_object_terms( $new_id, $terms, $tax, false );
        }
    }

    // Step 6) 複製特色圖片
    $thumb = get_post_thumbnail_id( $source_id );
    if ( $thumb ) {
        set_post_thumbnail( $new_id, $thumb );
    }

    // Step 7) 複製 ACF 欄位（包含上傳檔案）
    if ( function_exists( &#039;get_field_objects&#039; ) &amp;&amp; function_exists( &#039;update_field&#039; ) ) {
        $fields = get_field_objects( $source_id );
        if ( $fields ) {
            foreach ( $fields as $field ) {
                update_field( $field[&#039;key&#039;], $field[&#039;value&#039;], $new_id );
            }
        }
    }

    // 最後：實際語言檢查
    $src_lang_real = pll_get_post_language( $source_id );
    $new_lang_real = pll_get_post_language( $new_id );

    $report[] = &quot;🧪 實際語言檢查：原文 {$source_id} 語言：{$src_lang_real}；新文 {$new_id} 語言：{$new_lang_real}&quot;;
    $report[] = &quot;✅ 完成：原文 {$source_id} &rarr; {$target_lang} 翻譯 {$new_id}&quot;;

    return $report;
}</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/polylang-bulk-copy-translations/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>使用 MemberPress 交易完成鉤子自動寄送訂閱確認信</title>
		<link>https://piglife.tw/technical-notes/memberpress-transaction-email/</link>
					<comments>https://piglife.tw/technical-notes/memberpress-transaction-email/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Sat, 20 Dec 2025 03:46:29 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[MemberPress]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[wp_mail]]></category>
		<category><![CDATA[交易完成]]></category>
		<category><![CDATA[自動寄信]]></category>
		<category><![CDATA[訂閱通知]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/memberpress-transaction-email/</guid>

					<description><![CDATA[介紹如何利用 MemberPress 交易完成事件自動寄送訂閱方案付款成功的確認信，包含交易資料判斷...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>這段程式碼示範如何在 WordPress 使用 MemberPress 交易完成事件鉤子，自動寄送訂閱方案的確認電子郵件。適合使用 MemberPress 管理會員訂閱，並希望自動化寄送付款成功通知的工程師或自學者。</p>
<h2 class="wp-block-heading">交易完成事件監聽</h2>
<p>透過 <code>add_action</code> 監聽 <code>mepr-event-transaction-completed</code> 事件，當交易完成時觸發自訂函式。函式內部先檢查事件與交易物件有效性，避免錯誤執行。</p>
<pre><code class="lang-php language-php php">add_action(&#039;mepr-event-transaction-completed&#039;, function($event) {
  if(!is_object($event)) return;

  $txn = $event-&gt;get_data();
  if(!is_object($txn)) return;
  // ...
});</code></pre>
<h2 class="wp-block-heading">交易資料擷取與條件判斷</h2>
<p>取得交易相關資訊如使用者 ID、方案 ID、交易 ID、付款狀態與金額，並做以下條件過濾：</p>
<ul>
<li>使用者、方案、交易 ID 必須有效</li>
<li>交易狀態必須為 <code>complete</code></li>
<li>可選擇只寄首次付款通知（此範例預設為每次付款皆寄）</li>
<li>防止重複寄送，透過交易文章 meta 標記</li>
</ul>
<p>這些判斷確保只有有效且未重複的交易會觸發郵件寄送。</p>
<h2 class="wp-block-heading">根據方案選擇郵件模板</h2>
<p>根據方案 ID（範例中為 626 月付方案與 639 年付方案）選擇對應的郵件主旨與 HTML 內容。郵件內容包含隱藏文字供郵件摘要使用，以及簡潔的訂閱資訊與操作連結。</p>
<pre><code class="lang-php language-php php">if($membership_id === 626) {
  $subject = $subject_626;
  $message = $message_626;
} elseif($membership_id === 639) {
  $subject = $subject_639;
  $message = $message_639;
} else {
  return;
}</code></pre>
<h2 class="wp-block-heading">寄送郵件與防重複標記</h2>
<p>使用 WordPress 內建的 <code>wp_mail</code> 函式寄送 HTML 格式郵件，並在成功寄出後，透過 <code>update_post_meta</code> 設定交易文章的自訂 meta，避免重複寄信。</p>
<pre><code class="lang-php language-php php">$headers = [&#039;Content-Type: text/html; charset=UTF-8&#039;];
$sent = wp_mail($user-&gt;user_email, $subject, $message, $headers);

if($sent) {
  update_post_meta($txn_id, &#039;_custom_plan_email_sent&#039;, &#039;yes&#039;);
}</code></pre>
<h2 class="wp-block-heading">實務應用與優化建議</h2>
<ul>
<li>可依照不同方案擴充更多模板，提升客製化體驗</li>
<li>建議搭配會員資料完整性檢查，確保郵件內容正確</li>
<li>若有多語系需求，可整合翻譯函式動態產生郵件內容</li>
<li>透過設定參數控制是否只寄首次付款通知，靈活調整行銷策略</li>
</ul>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>確認 MemberPress 交易狀態名稱是否與程式碼一致，避免漏寄</li>
<li>郵件內容中圖片 URL 請替換成實際可用路徑</li>
<li>測試時注意交易狀態與交易金額，避免誤寄測試郵件</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
add_action(&#039;mepr-event-transaction-completed&#039;, function($event) {
  if(!is_object($event)) return;

  // true = 只寄首次；false = 每次扣款都寄
  $SEND_FIRST_PAYMENT_ONLY = false;

  $txn = $event-&gt;get_data();
  if(!is_object($txn)) return;

  // 交易基本資訊
  $user_id       = intval($txn-&gt;user_id ?? 0);
  $membership_id = intval($txn-&gt;product_id ?? 0);
  $txn_id        = intval($txn-&gt;id ?? 0); // MemberPress 交易文章 ID
  $total         = floatval($txn-&gt;total ?? 0);
  $status        = strval($txn-&gt;status ?? &#039;&#039;);

  if($user_id &lt;= 0 || $membership_id &lt;= 0 || $txn_id &lt;= 0) return;

  // 僅完成交易才寄（雙保險）
  if($status !== &#039;complete&#039;) return;

  // 避免 $0 交易（試用 / 全額折扣）也發信
//   if($total &lt;= 0) return;

  // 只寄首次付款？
  if($SEND_FIRST_PAYMENT_ONLY === true &amp;&amp; method_exists($txn,&#039;is_first_payment&#039;) &amp;&amp; !$txn-&gt;is_first_payment()) {
    return;
  }

  // 防重複寄送：用交易 ID 做旗標
  if(get_post_meta($txn_id, &#039;_custom_plan_email_sent&#039;, true) === &#039;yes&#039;) {
    return;
  }

  $user = get_user_by(&#039;id&#039;, $user_id);
  if(!$user || empty($user-&gt;user_email)) return;

  $product_name = get_the_title($membership_id);

  // ===== 月付 626 =====
  $subject_626 = &#039;感謝您購買 &#039;.$product_name;
  $message_626 = &lt;&lt;&lt;HTML
&lt;div style=&quot;display: none; overflow: hidden; line-height: 1px; opacity: 0; max-height: 0; max-width: 0;&quot;&gt;你已成功訂閱 。你的訂閱方案將會自動續訂：月付 $129，可隨時取消。&lt;/div&gt;
&lt;table style=&quot;width: 100%; background: #f5f6f8; margin: 0; padding: 0;&quot; role=&quot;presentation&quot;&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;
&lt;table style=&quot;width: 600px; margin: 0 auto;&quot; role=&quot;presentation&quot;&gt;
&lt;tr&gt;&lt;td style=&quot;padding: 0px 24px; background: #ffffff;&quot; align=&quot;left&quot;&gt;
&lt;img src=&quot;&quot; width=&quot;200&quot; /&gt;
&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;padding: 32px 24px; background: #ffffff; font-family: Helvetica, Arial, &#039;PingFang TC&#039;, &#039;Microsoft JhengHei&#039;, sans-serif; color: #111827;&quot;&gt;
&lt;table role=&quot;presentation&quot; width=&quot;100%&quot;&gt;
&lt;tr&gt;&lt;td valign=&quot;top&quot; width=&quot;6&quot;&gt;
&lt;div style=&quot;width: 3px; height: 22px; background: #39A297; border-radius: 2px; margin-top: 6px;&quot;&gt;&lt;/div&gt;
&lt;/td&gt;
&lt;td style=&quot;padding-left: 10px;&quot;&gt;
&lt;p style=&quot;font-size: 16px; line-height: 1.75; margin: 0;&quot;&gt;你已成功訂閱 &lt;strong&gt;&lt;/strong&gt; 。&lt;/p&gt;
&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;

&lt;div style=&quot;height: 16px;&quot;&gt;&lt;/div&gt;
&lt;p style=&quot;font-size: 16px; line-height: 1.75; margin: 0;&quot;&gt;你的訂閱方案將會自動續訂：&lt;strong&gt;月付 $129&lt;/strong&gt;&lt;br&gt;你可以隨時取消。&lt;/p&gt;

&lt;div style=&quot;height: 16px;&quot;&gt;&lt;/div&gt;
&lt;a style=&quot;background: #39A297; border-radius: 8px; padding: 12px 18px; display: inline-block; color: #ffffff; text-decoration: none;&quot; href=&quot;https://.com/account/&quot; target=&quot;_blank&quot;&gt;點擊這裡登入&lt;/a&gt;

&lt;div style=&quot;height: 28px;&quot;&gt;&lt;/div&gt;
&lt;p style=&quot;font-size: 14px; color: #6b7280;&quot;&gt;如有任何問題，請於聯絡我們留言你的需求。&lt;/p&gt;
&lt;p style=&quot;font-size: 16px;&quot;&gt; 團隊&lt;/p&gt;
&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
HTML;

  // ===== 年付 639 =====
  $subject_639 = &#039;感謝您購買 &#039;.$product_name;
  $message_639 = &lt;&lt;&lt;HTML
&lt;div style=&quot;display: none;overflow: hidden;line-height: 1px;opacity: 0;max-height: 0;max-width: 0&quot;&gt;你已成功訂閱 。你的訂閱方案將會自動續訂：年付 $1,290，可隨時取消。&lt;/div&gt;
&lt;table style=&quot;width: 100%;background: #f5f6f8;margin: 0;padding: 0&quot; role=&quot;presentation&quot;&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;
&lt;table style=&quot;width: 600px;margin: 0 auto&quot; role=&quot;presentation&quot;&gt;
&lt;tr&gt;&lt;td style=&quot;padding: 0 24px;background: #ffffff&quot; align=&quot;left&quot;&gt;
&lt;img src=&quot;https://.com/wp-content/uploads/2025/10/forGoogleLogo.png&quot; width=&quot;200&quot; /&gt;
&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;padding: 32px 24px;background: #ffffff;font-family: Helvetica, Arial, &#039;PingFang TC&#039;, &#039;Microsoft JhengHei&#039;, sans-serif;color: #111827&quot;&gt;
&lt;table role=&quot;presentation&quot; width=&quot;100%&quot;&gt;&lt;tr&gt;&lt;td valign=&quot;top&quot; width=&quot;6&quot;&gt;
&lt;div style=&quot;width: 3px;height: 22px;background: #16a34a;border-radius: 2px;margin-top: 6px&quot;&gt;&lt;/div&gt;
&lt;/td&gt;
&lt;td style=&quot;padding-left: 10px&quot;&gt;
&lt;p style=&quot;font-size: 16px;line-height: 1.75;margin: 0&quot;&gt;你已成功訂閱 &lt;strong&gt;&lt;/strong&gt; 。&lt;/p&gt;
&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;

&lt;div style=&quot;height: 16px&quot;&gt;&lt;/div&gt;
&lt;p style=&quot;font-size: 16px;line-height: 1.75;margin: 0&quot;&gt;你的訂閱方案將會自動續訂：&lt;strong&gt;年付 $1,290&lt;/strong&gt;
&lt;br /&gt;你可以隨時取消。&lt;/p&gt;

&lt;div style=&quot;height: 16px&quot;&gt;&lt;/div&gt;
&lt;p style=&quot;font-size: 16px;line-height: 1.75;margin: 0&quot;&gt;別忘了到會員資訊裡留下你的收件資料，&lt;br /&gt;讓我們能夠把贈品正確地寄送到你手上。&lt;/p&gt;

&lt;div style=&quot;height: 16px&quot;&gt;&lt;/div&gt;
&lt;a style=&quot;background: #39A297;border-radius: 8px;padding: 12px 18px;display: inline-block;color: #ffffff;text-decoration: none&quot; href=&quot;#&quot; target=&quot;_blank&quot;&gt;設定你的收件資料&lt;/a&gt;

&lt;div style=&quot;height: 28px&quot;&gt;&lt;/div&gt;
&lt;p style=&quot;font-size: 14px;color: #6b7280;&quot;&gt;如有任何問題，請於聯絡我們留言你的需求。&lt;/p&gt;
&lt;p style=&quot;font-size: 16px;&quot;&gt; 團隊&lt;/p&gt;
&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
HTML;

  // 套用對應模板
  if($membership_id === 626) {
    $subject = $subject_626;
    $message = $message_626;
  } elseif($membership_id === 639) {
    $subject = $subject_639;
    $message = $message_639;
  } else {
    return;
  }

  // 寄送
  $headers = [&#039;Content-Type: text/html; charset=UTF-8&#039;];
  $sent = wp_mail($user-&gt;user_email, $subject, $message, $headers);

  // 設定防重複旗標（只有寄成功才標記）
  if($sent) {
    update_post_meta($txn_id, &#039;_custom_plan_email_sent&#039;, &#039;yes&#039;);
  }
});</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/memberpress-transaction-email/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>MemberPress 會員列表新增電話、地址與即時複製功能教學</title>
		<link>https://piglife.tw/technical-notes/memberpress-members-extra-columns/</link>
					<comments>https://piglife.tw/technical-notes/memberpress-members-extra-columns/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Fri, 19 Dec 2025 05:29:55 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[AJAX]]></category>
		<category><![CDATA[MemberPress]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[後台擴充]]></category>
		<category><![CDATA[會員管理]]></category>
		<category><![CDATA[複製功能]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/memberpress-members-extra-columns/</guid>

					<description><![CDATA[介紹如何在 MemberPress 會員列表中新增電話、地址、贈品寄送狀態欄位，並實作即時切換與一鍵...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>這段 PHP 程式碼主要用於 WordPress 的 MemberPress 外掛會員管理頁面，目的是在會員列表中新增電話、地址、贈品寄送狀態及一鍵複製欄位。適合需要擴充會員管理功能、提升後台操作效率的工程師與自學者。</p>
<h2 class="wp-block-heading">新增會員列表欄位表頭</h2>
<p>透過 <code>mepr-admin-members-cols</code> 過濾器，將電話、地址、贈品已寄送與複製按鈕四個欄位加入會員列表的表頭。這樣可以讓後台管理員一目了然看到更多會員資訊。</p>
<pre><code class="lang-php language-php php">add_filter(&#039;mepr-admin-members-cols&#039;, function($cols){
  $cols[&#039;mepr_phone&#039;]   = __(&#039;電話&#039;, &#039;memberpress&#039;);
  $cols[&#039;mepr_address&#039;] = __(&#039;地址&#039;, &#039;memberpress&#039;);
  $cols[&#039;mepr_sent&#039;]    = __(&#039;贈品已寄送&#039;, &#039;memberpress&#039;);
  $cols[&#039;mepr_copy&#039;]    = __(&#039;複製&#039;, &#039;memberpress&#039;);
  return $cols;
});</code></pre>
<h2 class="wp-block-heading">渲染會員列表每列的欄位內容</h2>
<p>使用 <code>mepr_members_list_table_row</code> 過濾器，依照欄位名稱輸出對應的會員資料：</p>
<ul>
<li>電話與地址從使用者 meta 取得，地址欄位會截斷顯示並加上完整 tooltip。</li>
<li>贈品已寄送欄位為 checkbox，狀態可即時切換並透過 AJAX 更新。</li>
<li>複製欄位提供一鍵複製會員姓名、電話與地址的功能，方便快速取得會員聯絡資訊。</li>
</ul>
<pre><code class="lang-php language-php php">add_filter(&#039;mepr_members_list_table_row&#039;, function($attributes, $rec, $column_name, $column_display_name){
  $user = get_user_by(&#039;login&#039;, $rec-&gt;username);
  if(!$user){ return; }

  $first = get_user_meta($user-&gt;ID, &#039;first_name&#039;, true);
  $last  = get_user_meta($user-&gt;ID, &#039;last_name&#039;, true);
  $full  = trim(implode(&#039; &#039;, array_filter([$first, $last])));
  if($full === &#039;&#039;) { $full = $user-&gt;display_name ?: $user-&gt;user_login; }

  $phone = get_user_meta($user-&gt;ID, &#039;mepr_phone&#039;, true);
  $addr  = get_user_meta($user-&gt;ID, &#039;mepr_address&#039;, true);

  if($column_name === &#039;mepr_phone&#039;){
    echo &#039;&lt;td &#039;.$attributes.&#039;&gt;&#039;.esc_html($phone ?: &#039;&mdash;&#039;).&#039;&lt;/td&gt;&#039;;
  }

  if($column_name === &#039;mepr_address&#039;){
    $short = mb_strimwidth(wp_strip_all_tags((string)$addr), 0, 80, &#039;&hellip;&#039;, &#039;UTF-8&#039;);
    echo &#039;&lt;td &#039;.$attributes.&#039; title=&quot;&#039;.esc_attr($addr).&#039;&quot;&gt;&#039;.esc_html($short ?: &#039;&mdash;&#039;).&#039;&lt;/td&gt;&#039;;
  }

  if($column_name === &#039;mepr_sent&#039;){
    $checked = get_user_meta($user-&gt;ID, &#039;mepr_sent&#039;, true) ? &#039;checked&#039; : &#039;&#039;;
    $nonce   = wp_create_nonce(&#039;mepr_sent_nonce&#039;);
    echo &#039;&lt;td &#039;.$attributes.&#039; style=&quot;text-align:center&quot;&gt;
      &lt;label style=&quot;display:inline-flex;align-items:center;gap:.4em;cursor:pointer;&quot;&gt;
        &lt;input type=&quot;checkbox&quot; class=&quot;mepr-sent-toggle&quot;
               data-user=&quot;&#039;.esc_attr($user-&gt;ID).&#039;&quot;
               data-nonce=&quot;&#039;.esc_attr($nonce).&#039;&quot; &#039;.$checked.&#039; /&gt;
        &lt;span&gt;&#039;.($checked ? esc_html__(&#039;Yes&#039;,&#039;memberpress&#039;) : esc_html__(&#039;No&#039;,&#039;memberpress&#039;)).&#039;&lt;/span&gt;
      &lt;/label&gt;
    &lt;/td&gt;&#039;;
  }

  if($column_name === &#039;mepr_copy&#039;){
    $copy_text = &quot;Full Name: {$full}\nPhone: &quot; . ($phone ?: &#039;&#039;) . &quot;\nAddress: &quot; . ($addr ? wp_strip_all_tags($addr) : &#039;&#039;);
    echo &#039;&lt;td &#039;.$attributes.&#039; style=&quot;text-align:center&quot;&gt;
      &lt;button type=&quot;button&quot; class=&quot;button button-small mepr-copy-btn&quot;
              data-copy=&quot;&#039;.esc_attr($copy_text).&#039;&quot;
              title=&quot;&#039;.esc_attr__(&#039;Copy Full Name / Phone / Address&#039;,&#039;memberpress&#039;).&#039;&quot;&gt;
        &#039;.esc_html__(&#039;複製&#039;,&#039;memberpress&#039;).&#039;
      &lt;/button&gt;
    &lt;/td&gt;&#039;;
  }
}, 10, 4);</code></pre>
<h2 class="wp-block-heading">後台頁尾加入 JavaScript 強化互動</h2>
<p>透過 <code>admin_print_footer_scripts</code> 動作，在會員頁面插入 JavaScript：</p>
<ul>
<li>切換「贈品已寄送」checkbox 時，使用 AJAX 非同步更新後端資料，並即時更新 UI 狀態。</li>
<li>複製按鈕則利用 Clipboard API 或備援方案將會員資訊複製到剪貼簿，並顯示複製成功提示。</li>
</ul>
<p>此設計提升管理者操作便利性，避免頻繁刷新頁面。</p>
<h2 class="wp-block-heading">AJAX 後端處理贈品寄送狀態更新</h2>
<p>使用 <code>wp_ajax_mepr_toggle_sent</code> 來驗證權限與 nonce，確保安全後更新使用者 meta。若驗證失敗或使用者不存在，會回傳錯誤訊息。</p>
<pre><code class="lang-php language-php php">add_action(&#039;wp_ajax_mepr_toggle_sent&#039;, function(){
  if( ! current_user_can(&#039;manage_options&#039;) ){
    wp_send_json_error([&#039;message&#039; =&gt; &#039;無權限&#039;], 403);
  }
  if( ! check_ajax_referer(&#039;mepr_sent_nonce&#039;, &#039;_wpnonce&#039;, false) ){
    wp_send_json_error([&#039;message&#039; =&gt; &#039;Nonce 驗證失敗&#039;], 400);
  }
  $user_id = isset($_POST[&#039;user_id&#039;]) ? absint($_POST[&#039;user_id&#039;]) : 0;
  $value   = isset($_POST[&#039;value&#039;]) ? sanitize_text_field($_POST[&#039;value&#039;]) : &#039;0&#039;;
  if( !$user_id || ! get_user_by(&#039;id&#039;, $user_id) ){
    wp_send_json_error([&#039;message&#039; =&gt; &#039;使用者不存在&#039;], 404);
  }
  update_user_meta($user_id, &#039;mepr_sent&#039;, $value === &#039;1&#039; ? 1 : 0);
  wp_send_json_success([&#039;nonce&#039; =&gt; wp_create_nonce(&#039;mepr_sent_nonce&#039;)]);
});</code></pre>
<h2 class="wp-block-heading">簡單的後台樣式調整</h2>
<p>為了讓新增欄位版面更整齊，使用 <code>admin_head</code> 動作插入 CSS，調整欄寬與置中對齊。</p>
<h2 class="wp-block-heading">實務應用與延伸</h2>
<p>這套擴充方案適合需要在 MemberPress 後台快速查看會員詳細聯絡資訊並標記贈品寄送狀態的情境。未來可延伸加入更多欄位或改為前端 AJAX 分頁載入，提升大會員數的效能。</p>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>AJAX 權限與 nonce 驗證不可少，避免安全漏洞。</li>
<li>複製功能在非 HTTPS 或不支援 Clipboard API 的環境會退回使用 textarea 方案。</li>
<li>地址欄位截斷避免版面過長，但完整資料仍可透過 tooltip 查看。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">
&lt;?php
/**
 * MemberPress Members 列表欄位：電話/地址/贈品已寄送/複製（可即時切換）
 * 放 WPCode（PHP Snippet，Run Everywhere）
 */

/* === 1) 新增欄位表頭（mepr-admin-members-cols） === */
add_filter(&#039;mepr-admin-members-cols&#039;, function($cols){
  $cols[&#039;mepr_phone&#039;]   = __(&#039;電話&#039;, &#039;memberpress&#039;);
  $cols[&#039;mepr_address&#039;] = __(&#039;地址&#039;, &#039;memberpress&#039;);
  $cols[&#039;mepr_sent&#039;]    = __(&#039;贈品已寄送&#039;, &#039;memberpress&#039;);
  $cols[&#039;mepr_copy&#039;]    = __(&#039;複製&#039;, &#039;memberpress&#039;); // 一鍵複製按鈕
  return $cols;
});

/* === 2) 渲染每一列內容（mepr_members_list_table_row） === */
add_filter(&#039;mepr_members_list_table_row&#039;, function($attributes, $rec, $column_name, $column_display_name){
  $user = get_user_by(&#039;login&#039;, $rec-&gt;username);
  if(!$user){ return; }

  // 共用：取 Full Name / Phone / Address
  $first = get_user_meta($user-&gt;ID, &#039;first_name&#039;, true);
  $last  = get_user_meta($user-&gt;ID, &#039;last_name&#039;, true);
  $full  = trim(implode(&#039; &#039;, array_filter([$first, $last])));
  if($full === &#039;&#039;) { $full = $user-&gt;display_name ?: $user-&gt;user_login; }

  $phone = get_user_meta($user-&gt;ID, &#039;mepr_phone&#039;, true);
  $addr  = get_user_meta($user-&gt;ID, &#039;mepr_address&#039;, true);

  if($column_name === &#039;mepr_phone&#039;){
    echo &#039;&lt;td &#039;.$attributes.&#039;&gt;&#039;.esc_html($phone ?: &#039;&mdash;&#039;).&#039;&lt;/td&gt;&#039;;
  }

  if($column_name === &#039;mepr_address&#039;){
    $short = mb_strimwidth(wp_strip_all_tags((string)$addr), 0, 80, &#039;&hellip;&#039;, &#039;UTF-8&#039;);
    echo &#039;&lt;td &#039;.$attributes.&#039; title=&quot;&#039;.esc_attr($addr).&#039;&quot;&gt;&#039;.esc_html($short ?: &#039;&mdash;&#039;).&#039;&lt;/td&gt;&#039;;
  }

  if($column_name === &#039;mepr_sent&#039;){
    $checked = get_user_meta($user-&gt;ID, &#039;mepr_sent&#039;, true) ? &#039;checked&#039; : &#039;&#039;;
    $nonce   = wp_create_nonce(&#039;mepr_sent_nonce&#039;);
    echo &#039;&lt;td &#039;.$attributes.&#039; style=&quot;text-align:center&quot;&gt;
      &lt;label style=&quot;display:inline-flex;align-items:center;gap:.4em;cursor:pointer;&quot;&gt;
        &lt;input type=&quot;checkbox&quot; class=&quot;mepr-sent-toggle&quot;
               data-user=&quot;&#039;.esc_attr($user-&gt;ID).&#039;&quot;
               data-nonce=&quot;&#039;.esc_attr($nonce).&#039;&quot; &#039;.$checked.&#039; /&gt;
        &lt;span&gt;&#039;.($checked ? esc_html__(&#039;Yes&#039;,&#039;memberpress&#039;) : esc_html__(&#039;No&#039;,&#039;memberpress&#039;)).&#039;&lt;/span&gt;
      &lt;/label&gt;
    &lt;/td&gt;&#039;;
  }

  if($column_name === &#039;mepr_copy&#039;){
    $copy_text = &quot;Full Name: {$full}\nPhone: &quot; . ($phone ?: &#039;&#039;) . &quot;\nAddress: &quot; . ($addr ? wp_strip_all_tags($addr) : &#039;&#039;);
    echo &#039;&lt;td &#039;.$attributes.&#039; style=&quot;text-align:center&quot;&gt;
      &lt;button type=&quot;button&quot; class=&quot;button button-small mepr-copy-btn&quot;
              data-copy=&quot;&#039;.esc_attr($copy_text).&#039;&quot;
              title=&quot;&#039;.esc_attr__(&#039;Copy Full Name / Phone / Address&#039;,&#039;memberpress&#039;).&#039;&quot;&gt;
        &#039;.esc_html__(&#039;複製&#039;,&#039;memberpress&#039;).&#039;
      &lt;/button&gt;
    &lt;/td&gt;&#039;;
  }
}, 10, 4);

/* === 3) 後台頁尾插入 JS（僅 Members 頁） === */
add_action(&#039;admin_print_footer_scripts&#039;, function(){
  if( ! is_admin() ) return;
  if( ! (isset($_GET[&#039;page&#039;]) &amp;&amp; $_GET[&#039;page&#039;] === &#039;memberpress-members&#039;) ) return;

  $ajax_url = admin_url(&#039;admin-ajax.php&#039;);
  ?&gt;
  &lt;script&gt;
  (function(){
    // 已寄送：切換寫入
    function setSentLabel(td, checked){
      var s = td.querySelector(&#039;span&#039;);
      if(s){ s.textContent = checked ? &#039;Yes&#039; : &#039;No&#039;; }
    }

    document.addEventListener(&#039;change&#039;, function(e){
      var el = e.target;
      if(!el.classList.contains(&#039;mepr-sent-toggle&#039;)) return;

      var td    = el.closest(&#039;td&#039;);
      var uid   = el.getAttribute(&#039;data-user&#039;);
      var nonce = el.getAttribute(&#039;data-nonce&#039;);
      var val   = el.checked ? &#039;1&#039; : &#039;0&#039;;
      setSentLabel(td, el.checked); // 先行更新 UI

      var xhr = new XMLHttpRequest();
      var fd = new FormData();
      fd.append(&#039;action&#039;, &#039;mepr_toggle_sent&#039;);
      fd.append(&#039;user_id&#039;, uid);
      fd.append(&#039;value&#039;, val);
      fd.append(&#039;_wpnonce&#039;, nonce);
      xhr.open(&#039;POST&#039;, &#039;&lt;?php echo esc_js($ajax_url); ?&gt;&#039;, true);
      xhr.onreadystatechange = function(){
        if(xhr.readyState === 4){
          try{
            var res = JSON.parse(xhr.responseText||&#039;{}&#039;);
            if(!res.success){
              el.checked = !el.checked; setSentLabel(td, el.checked);
              alert(res.data &amp;&amp; res.data.message ? res.data.message : &#039;更新失敗&#039;);
            }else if(res.data &amp;&amp; res.data.nonce){
              el.setAttribute(&#039;data-nonce&#039;, res.data.nonce);
            }
          }catch(err){
            el.checked = !el.checked; setSentLabel(td, el.checked);
            alert(&#039;伺服器回應格式錯誤&#039;);
          }
        }
      };
      xhr.send(fd);
    }, false);

    // 一鍵複製
    document.addEventListener(&#039;click&#039;, async function(e){
      var btn = e.target.closest(&#039;.mepr-copy-btn&#039;);
      if(!btn) return;
      var text = btn.getAttribute(&#039;data-copy&#039;) || &#039;&#039;;
      try{
        if(navigator.clipboard &amp;&amp; window.isSecureContext){
          await navigator.clipboard.writeText(text);
        }else{
          var ta = document.createElement(&#039;textarea&#039;);
          ta.value = text;
          ta.style.position = &#039;fixed&#039;;
          ta.style.opacity = &#039;0&#039;;
          document.body.appendChild(ta);
          ta.focus();
          ta.select();
          document.execCommand(&#039;copy&#039;);
          document.body.removeChild(ta);
        }
        btn.classList.add(&#039;mepr-copied&#039;);
        btn.textContent = &#039;已複製&#039;;
        setTimeout(function(){ btn.classList.remove(&#039;mepr-copied&#039;); btn.textContent = &#039;複製&#039;; }, 1200);
      }catch(err){
        alert(&#039;複製失敗，請手動複製。\n\n&#039; + text);
      }
    }, false);
  })();
  &lt;/script&gt;
  &lt;?php
});

/* === 4) AJAX 後端：儲存 mepr_sent === */
add_action(&#039;wp_ajax_mepr_toggle_sent&#039;, function(){
  if( ! current_user_can(&#039;manage_options&#039;) ){
    wp_send_json_error([&#039;message&#039; =&gt; &#039;無權限&#039;], 403);
  }
  if( ! check_ajax_referer(&#039;mepr_sent_nonce&#039;, &#039;_wpnonce&#039;, false) ){
    wp_send_json_error([&#039;message&#039; =&gt; &#039;Nonce 驗證失敗&#039;], 400);
  }
  $user_id = isset($_POST[&#039;user_id&#039;]) ? absint($_POST[&#039;user_id&#039;]) : 0;
  $value   = isset($_POST[&#039;value&#039;]) ? sanitize_text_field($_POST[&#039;value&#039;]) : &#039;0&#039;;
  if( !$user_id || ! get_user_by(&#039;id&#039;, $user_id) ){
    wp_send_json_error([&#039;message&#039; =&gt; &#039;使用者不存在&#039;], 404);
  }
  update_user_meta($user_id, &#039;mepr_sent&#039;, $value === &#039;1&#039; ? 1 : 0);
  wp_send_json_success([&#039;nonce&#039; =&gt; wp_create_nonce(&#039;mepr_sent_nonce&#039;)]);
});

/* === 5) 簡單樣式 === */
add_action(&#039;admin_head&#039;, function(){
  if( ! (isset($_GET[&#039;page&#039;]) &amp;&amp; $_GET[&#039;page&#039;] === &#039;memberpress-members&#039;) ) return;
  echo &#039;
&lt;style&gt;
    th#mepr_phone{width:140px}
    th#mepr_address{width:260px}
    th#mepr_sent{width:110px;text-align:center}
    th#mepr_copy{width:90px;text-align:center}
    td.column-mepr_sent, td.column-mepr_copy{text-align:center}
    .mepr-copy-btn.button-small{line-height:22px;height:24px;padding:0 8px}
    .mepr-copy-btn.mepr-copied{opacity:.8}
  &lt;/style&gt;&#039;;
});</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/memberpress-members-extra-columns/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>自訂 Gutenberg 編輯器中文章區塊最大寬度的實作說明</title>
		<link>https://piglife.tw/technical-notes/custom-gutenberg-editor-width-2/</link>
					<comments>https://piglife.tw/technical-notes/custom-gutenberg-editor-width-2/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Thu, 18 Dec 2025 22:50:25 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[css]]></category>
		<category><![CDATA[Gutenberg]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/custom-gutenberg-editor-width-2/</guid>

					<description><![CDATA[介紹如何透過 WordPress 後台掛載自訂 CSS，限制 Gutenberg 編輯器中文章區塊的...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在使用 WordPress 的 Gutenberg 編輯器時，預設的區塊寬度可能不符合特定網站設計需求，尤其是針對文章（post）類型。這段程式碼示範如何透過後台樣式調整，限制文章區塊的最大寬度，讓編輯介面更貼近前端顯示效果，適合想優化編輯體驗的開發者與自學者。</p>
<h2 class="wp-block-heading">為什麼要調整 Gutenberg 編輯器的區塊寬度？</h2>
<p>Gutenberg 編輯器的區塊寬度預設較寬，可能導致編輯時排版與前端顯示不一致，影響內容編輯的直觀感受。透過限制最大寬度，可以讓編輯時的視覺效果更接近實際網站呈現，減少排版錯誤。</p>
<h2 class="wp-block-heading">程式碼解析</h2>
<h3 class="wp-block-heading">1. 透過 admin_head 動作掛載自訂樣式</h3>
<pre><code class="lang-php language-php php">add_action( &#039;admin_head&#039;, &#039;custom_post_gutenberg_editor_width&#039; );</code></pre>
<p>這行程式碼將自訂函式掛載到 WordPress 後台的 head 區塊，確保樣式只在後台生效。</p>
<h3 class="wp-block-heading">2. 目標限定為文章(post)類型的區塊</h3>
<pre><code class="lang-css language-css css">.post-type-post .wp-block {
    max-width: 600px !important;
}</code></pre>
<p>這段 CSS 限制所有文章類型的區塊最大寬度為 600px，避免編輯器區塊過寬。</p>
<h3 class="wp-block-heading">3. 調整 &#8220;wide&#8221; 對齊方式的寬度</h3>
<pre><code class="lang-css language-css css">.post-type-post .wp-block[data-align=&quot;wide&quot;] {
    max-width: 600px !important;
}</code></pre>
<p>將 &#8220;wide&#8221; 對齊的區塊同樣限制在 600px，避免因預設寬度過寬而影響排版。</p>
<h3 class="wp-block-heading">4. 保留 &#8220;full&#8221; 對齊的全寬效果</h3>
<pre><code class="lang-css language-css css">.post-type-post .wp-block[data-align=&quot;full&quot;] {
    max-width: none !important;
}</code></pre>
<p>讓 &#8220;full&#8221; 對齊的區塊維持編輯器全寬，符合使用者預期的全屏展示。</p>
<h2 class="wp-block-heading">實務應用與延伸</h2>
<ul>
<li>可依需求調整 max-width 數值，配合網站前端設計寬度。</li>
<li>若需針對其他自訂文章類型，也可複製此函式並修改 .post-type-post 選擇器。</li>
<li>若想更細緻控制不同區塊類型寬度，可增加更多 CSS 選擇器與規則。</li>
</ul>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>使用 !important 是為了覆蓋 Gutenberg 預設樣式，避免無效。</li>
<li>此方法只影響後台編輯器，不會改變前端顯示。</li>
<li>若有快取或 CSS 優先權問題，請清除快取或調整掛載優先順序。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">&lt;?php
function custom_post_gutenberg_editor_width() {
    echo &#039;
&lt;style&gt;
        /* Target ONLY the standard &quot;post&quot; post type in the editor */
        .post-type-post .wp-block {
            max-width: 600px !important; 
        }

        /* Optionally, set the &quot;wide&quot; alignment to the same narrow width */
        .post-type-post .wp-block[data-align=&quot;wide&quot;] {
            max-width: 600px !important; 
        }

        /* Ensure &quot;full&quot; alignment still goes full width of the editor screen */
        .post-type-post .wp-block[data-align=&quot;full&quot;] {
            max-width: none !important;
        }
    &lt;/style&gt;&#039;;
}
add_action( &#039;admin_head&#039;, &#039;custom_post_gutenberg_editor_width&#039; );</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/custom-gutenberg-editor-width-2/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WordPress 結合 MemberPress 與 ACF 自動更新文章存取權限狀態</title>
		<link>https://piglife.tw/technical-notes/wordpress-memberpress-acf-access-status/</link>
					<comments>https://piglife.tw/technical-notes/wordpress-memberpress-acf-access-status/#respond</comments>
		
		<dc:creator><![CDATA[小豬]]></dc:creator>
		<pubDate>Wed, 17 Dec 2025 22:20:39 +0000</pubDate>
				<category><![CDATA[技術筆記]]></category>
		<category><![CDATA[acf]]></category>
		<category><![CDATA[MemberPress]]></category>
		<category><![CDATA[WordPress]]></category>
		<guid isPermaLink="false">https://piglife.tw/technical-notes/wordpress-memberpress-acf-access-status/</guid>

					<description><![CDATA[介紹如何在 WordPress 結合 MemberPress 與 ACF，自動更新文章的存取權限狀態...]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">前言</h2>
<p>在使用 WordPress 搭配會員管理外掛 MemberPress 時，常見需求是判斷文章是否有設定存取權限，並將此狀態同步到自訂欄位（ACF）中，方便前端或查詢邏輯使用。這段程式碼示範如何在文章儲存時自動檢查 MemberPress 的存取規則，並更新 ACF 的 True/False 欄位，適合熟悉 WordPress 開發並使用 MemberPress 與 ACF 的工程師。</p>
<h2 class="wp-block-heading">文章儲存時自動同步存取權限</h2>
<p>透過 <code>save_post</code> 鉤子，在文章儲存或更新時觸發檢查。程式碼會先排除自動儲存與修訂版本，避免重複執行。接著確認文章類型是否為支援的 <code>post</code> 或 <code>page</code>，並檢查 MemberPress 與 ACF 是否存在。</p>
<h3 class="wp-block-heading">重要程式碼片段</h3>
<pre><code class="lang-php language-php php">if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
    return;
}

$post_type = get_post_type($post_id);
if (!in_array($post_type, array(&#039;post&#039;, &#039;page&#039;))) {
    return;
}

if (!class_exists(&#039;MeprRule&#039;) || !function_exists(&#039;update_field&#039;)) {
    return;
}</code></pre>
<p>接著使用 <code>MeprRule::get_rules($post)</code> 取得該文章的 MemberPress 存取規則，判斷是否有規則存在，並更新 ACF 的 <code>ks_post_access</code> 欄位。</p>
<pre><code class="lang-php language-php php">$rules = MeprRule::get_rules($post);
$has_rule = !empty($rules);

update_field(&#039;ks_post_access&#039;, $has_rule, $post_id);</code></pre>
<h2 class="wp-block-heading">當 MemberPress 規則變更時批次更新所有文章</h2>
<p>除了單篇文章儲存時更新，也會在 MemberPress 規則新增、修改或刪除時觸發批次更新所有文章的存取狀態，確保資料一致。</p>
<pre><code class="lang-php language-php php">function ks_update_all_posts_access_status() {
    if (!function_exists(&#039;update_field&#039;)) {
        return;
    }
    $posts = get_posts(array(
        &#039;post_type&#039; =&gt; &#039;post&#039;,
        &#039;post_status&#039; =&gt; &#039;publish&#039;,
        &#039;numberposts&#039; =&gt; -1,
        &#039;fields&#039; =&gt; &#039;ids&#039;
    ));
    foreach ($posts as $post_id) {
        $post = get_post($post_id);
        $rules = MeprRule::get_rules($post);
        $has_rule = !empty($rules);
        update_field(&#039;ks_post_access&#039;, $has_rule, $post_id);
    }
}</code></pre>
<h2 class="wp-block-heading">後台介面批次更新功能</h2>
<p>為方便管理，新增 WordPress 後台子選單，讓管理者可以手動觸發更新所有文章的存取權限狀態，並提供操作說明。</p>
<pre><code class="lang-php language-php php">function ks_add_bulk_update_access_status() {
    add_submenu_page(
        &#039;edit.php&#039;,
        &#039;Update Access Status&#039;,
        &#039;Update Access Status&#039;,
        &#039;manage_options&#039;,
        &#039;ks-bulk-update-access-status&#039;,
        &#039;ks_bulk_update_access_status_page&#039;
    );
}
add_action(&#039;admin_menu&#039;, &#039;ks_add_bulk_update_access_status&#039;);</code></pre>
<p>介面中包含按鈕與確認提示，確保操作安全。</p>
<h2 class="wp-block-heading">實務應用與延伸</h2>
<ul>
<li>透過 ACF 的 <code>ks_post_access</code> 欄位，前端查詢可以輕鬆過濾有存取限制的文章。</li>
<li>可結合 GreenShift Query Loop 或其他查詢工具，提升前端顯示效率。</li>
<li>若會員規則較複雜，建議優化批次更新邏輯，避免效能瓶頸。</li>
<li>可加入快取機制或非同步處理，減少儲存時的阻塞。</li>
</ul>
<h2 class="wp-block-heading">常見問題與注意事項</h2>
<ul>
<li>請確保已建立名為 <code>ks_post_access</code> 的 True/False ACF 欄位，且欄位名稱與程式碼一致。</li>
<li>若 MemberPress 或 ACF 外掛未啟用，程式會自動跳過，避免錯誤。</li>
<li>批次更新文章時，若文章數量龐大，可能會耗時較久，建議在低流量時段執行。</li>
</ul>
<h2 class="wp-block-heading">完整程式碼</h2>
<pre><code class="lang-php language-php php">
&lt;?php
// 在文章儲存/更新時自動檢查並更新 ACF 欄位
function ks_update_mepr_access_status($post_id) {
    // 避免自動儲存時執行
    if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
        return;
    }

    // 檢查是否為支援的文章類型
    $post_type = get_post_type($post_id);
    if (!in_array($post_type, array(&#039;post&#039;, &#039;page&#039;))) {
        return;
    }

    // 檢查是否有 MemberPress 和 ACF
    if (!class_exists(&#039;MeprRule&#039;) || !function_exists(&#039;update_field&#039;)) {
        return;
    }

    $post = get_post($post_id);
    if (!$post) {
        return;
    }

    // 檢查該文章是否有 MemberPress 規則
    $rules = MeprRule::get_rules($post);
    $has_rule = !empty($rules);

    // 更新 ACF 欄位
    update_field(&#039;ks_post_access&#039;, $has_rule, $post_id);

    // 可選：記錄 log 用於除錯
    error_log(&quot;KS Post ID: $post_id - Access Rule Status: &quot; . ($has_rule ? &#039;Yes&#039; : &#039;No&#039;));
}

// 掛載到文章儲存的 hook
add_action(&#039;save_post&#039;, &#039;ks_update_mepr_access_status&#039;, 20, 1);

// 也可以掛載到 MemberPress 規則更新時
add_action(&#039;mepr-rule-saved&#039;, &#039;ks_update_all_posts_access_status&#039;);
add_action(&#039;mepr-rule-deleted&#039;, &#039;ks_update_all_posts_access_status&#039;);

// 當 MemberPress 規則變更時，更新所有相關文章
function ks_update_all_posts_access_status() {
    if (!function_exists(&#039;update_field&#039;)) {
        return;
    }

    $posts = get_posts(array(
        &#039;post_type&#039; =&gt; &#039;post&#039;,
        &#039;post_status&#039; =&gt; &#039;publish&#039;,
        &#039;numberposts&#039; =&gt; -1,
        &#039;fields&#039; =&gt; &#039;ids&#039;
    ));

    foreach ($posts as $post_id) {
        $post = get_post($post_id);
        $rules = MeprRule::get_rules($post);
        $has_rule = !empty($rules);

        update_field(&#039;ks_post_access&#039;, $has_rule, $post_id);
    }
}

// 管理後台增加批量更新功能
function ks_add_bulk_update_access_status() {
    add_submenu_page(
        &#039;edit.php&#039;,
        &#039;Update Access Status&#039;,
        &#039;Update Access Status&#039;,
        &#039;manage_options&#039;,
        &#039;ks-bulk-update-access-status&#039;,
        &#039;ks_bulk_update_access_status_page&#039;
    );
}
add_action(&#039;admin_menu&#039;, &#039;ks_add_bulk_update_access_status&#039;);

function ks_bulk_update_access_status_page() {
    if (isset($_POST[&#039;ks_update_all&#039;])) {
        ks_update_all_posts_access_status();
        echo &#039;&lt;div class=&quot;notice notice-success&quot;&gt;&lt;p&gt;所有文章的存取權限狀態已更新完成！&lt;/p&gt;&lt;/div&gt;&#039;;
    }

    ?&gt;
    &lt;div class=&quot;wrap&quot;&gt;

&lt;h1&gt;KS 更新文章存取權限狀態&lt;/h1&gt;

&lt;p&gt;此功能會檢查所有文章的 MemberPress 存取權限設定，並更新對應的 ACF 欄位。&lt;/p&gt;

        &lt;form method=&quot;post&quot;&gt;
            &lt;input type=&quot;submit&quot; name=&quot;ks_update_all&quot; class=&quot;button-primary&quot; value=&quot;更新所有文章狀態&quot; 
                   onclick=&quot;return confirm(&#039;確定要更新所有文章的存取權限狀態嗎？&#039;);&quot;&gt;
        &lt;/form&gt;
    &lt;/div&gt;
    &lt;?php
}</code></pre>]]></content:encoded>
					
					<wfw:commentRss>https://piglife.tw/technical-notes/wordpress-memberpress-acf-access-status/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
