Release 2.4.2: core 1.4.2 bugfix #11

Merged
wpdev merged 7 commits from hardening/2.4.2 into main 2026-09-28 18:51:40 +08:00
17 changed files with 472 additions and 88 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.4.1
* Version: 1.4.2
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.4.1' );
define( 'TMDO_VERSION', '1.4.2' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
+38
View File
@@ -7,6 +7,44 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
## [1.4.2] — 2026-09-28 — 手冊已知問題修正(hardening 2.4.2)
**性質**:修正。無 schema 版本變更;archive 表新增索引(見「升級後」)。來源:`2meet-do-manual.md`
附錄 F(F-2、F-4〜F-8、F-11)。
### Fixed
- **設定頁會說明模式為何沒有變更**:完整表單與 AJAX 區塊儲存都經 `TMDO_Mode_Manager::set()`,
過去忽略它回傳的 `WP_Error`:不安全的跳階或 Promotion Guard 拒絕時,頁面仍顯示「已儲存」、
模式卻沒變。現在完整表單在重新導向後顯示拒絕原因(每位使用者一次),AJAX 儲存以錯誤訊息回傳。(PR-135,F-2)
- **archive 不再重複歸檔**:`wpdo_archive_sweep` 略過已在 archive 表的 meta(依 `original_meta_id`),
過去同一筆 meta 每次 sweep 都再寫一列(dev20:300 列只有 150 筆不同)。archive 表新增
`idx_original_meta` 索引;`wp tmdo install` 會補上索引並刪除重複列(保留最早一列)。(PR-136,F-6)
- **entity 健康面板看得到執行中的 backfill**:批次以 `(entity, group)` 參數排程,面板過去只查
`(entity)`,永遠看不到、一直提示可開始。(PR-137,F-11)
- **停用與解除安裝清掉所有排程**:改用 `wp_unschedule_hook()`,連帶參數排程的事件
(`tmdo_drift_resync`、backfill 批次)也清除;修正錯誤的 `wpdo_user_stress_test_batch` 名稱,
補上 `wpdo_migration_tick`、`wpdo_entity_backfill_batch`、`wpdo_auto_promote_check` 與各壓測批次。
解除安裝另外刪除 `tmdo_*` option 及 `tmdo_*` transient。(PR-138,F-4、F-5)
- 解除安裝被拒絕時的提示改為指向 `wp tmdo bridge-restore-native`(過去說沒有寫回工具);post
壓測工具不再使用已退役的 `wc_product` 表。(PR-139,F-7、F-8)
### 升級後
- 每個站台執行一次 `wp tmdo install`:為 archive 表加上 `idx_original_meta` 並刪除重複的 archive
列(刪除重複只在 CLI 執行,web 啟用外掛時不做)。表很大時請在離峰時段執行。
- **未執行前 archive sweep 會暫停**(沒有索引時 NOT EXISTS 會對每筆候選掃整張 archive 表)。
sweep 只複製、不刪 postmeta,暫停不會遺失資料。
- 解除安裝的 option 清理保留 LatePoint AddOn 的 `tmdo_latepoint_db_version`;設定頁的 AJAX 錯誤訊息
不再 2.5 秒後自動消失。(PR-141,驗收修正)
### 已知、未修改
- 已歸檔的 meta 以 `meta_id` 判斷:文章還原、修改後再次丟到回收桶,archive 保留的是第一次的值。
- post 壓測工具預設仍是 `product`,但 product 已沒有 flat 表,量測會回報 `flat_table_missing`。
---
## [1.4.1] — 2026-09-28 — 2.4 驗收修正
**性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。
+5 -2
View File
@@ -45,7 +45,7 @@
}
/**
* Show a transient status message, then fade it out after 2.5s.
* Show a status message; success fades out after 2.5s, errors stay.
*
* @param {HTMLElement} statusEl
* @param {string} message
@@ -56,6 +56,9 @@
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
statusEl.classList.add( 'visible' );
clearTimeout( statusEl._wpdo_timer );
if ( isError ) {
return;
}
statusEl._wpdo_timer = setTimeout( () => {
statusEl.classList.remove( 'visible' );
}, 2500 );
@@ -92,7 +95,7 @@
if ( data.success ) {
showStatus( statusEl, i18n.saved, false );
} else {
showStatus( statusEl, i18n.error, true );
showStatus( statusEl, ( data.data && data.data.message ) || i18n.error, true );
}
} )
.catch( () => {
+81 -24
View File
@@ -398,17 +398,7 @@ class TMDO_Admin {
// v2.5.4: Entity Bridge settings (v2.11.0: post added).
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
TMDO_Mode_Manager::set( $entity_type, $new_mode );
}
}
TMDO_Mode_Manager::reset_cache();
}
self::remember_bridge_mode_refusals( self::apply_bridge_modes( self::posted_bridge_modes() ) );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
@@ -3144,6 +3134,9 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '</p></div>';
}
foreach ( self::pull_bridge_mode_refusals() as $refused ) {
echo '<div class="notice notice-error"><p>' . esc_html__( '模式未變更:', '2meet-data-optimizer' ) . esc_html( $refused ) . '</p></div>';
}
$enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled();
$email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : '';
$throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24;
@@ -3743,22 +3736,33 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
}
private static function save_section_entity_bridge(): void {
/**
* Save the Entity Bridge section (Hook Bus switch and bridge modes).
*
* @return string[] Refused changes, "<entity>:<reason>".
*/
private static function save_section_entity_bridge(): array {
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
TMDO_Mode_Manager::set( $entity_type, $new_mode );
}
}
TMDO_Mode_Manager::reset_cache();
}
$refused = self::apply_bridge_modes( self::posted_bridge_modes() );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
return $refused;
}
/**
* Bridge modes posted by the settings form, keyed by entity type ('' when absent).
*
* @return array<string, string>
*/
private static function posted_bridge_modes(): array {
$posted = array();
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$posted[ $entity_type ] = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
}
return $posted;
}
private static function save_section_hp_transient(): void {
@@ -3786,6 +3790,56 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle
/**
* Apply posted bridge modes through TMDO_Mode_Manager::set(). Empty or
* unknown modes are skipped; refused changes (unsafe jump, Promotion Guard)
* are returned instead of being dropped silently (2.4.2 PR-135).
*
* @param array<string, string> $posted Mode per entity type.
* @return string[] Refused changes, "<entity>:<reason>".
*/
public static function apply_bridge_modes( array $posted ): array {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return array();
}
$refused = array();
foreach ( $posted as $entity_type => $new_mode ) {
if ( ! TMDO_Mode_Manager::is_valid_mode( (string) $new_mode ) ) {
continue;
}
$result = TMDO_Mode_Manager::set( (string) $entity_type, (string) $new_mode );
if ( is_wp_error( $result ) ) {
$refused[] = $entity_type . ':' . $result->get_error_message();
}
}
TMDO_Mode_Manager::reset_cache();
return $refused;
}
/**
* Keep refused bridge-mode changes for the page the settings form redirects to.
*
* @param string[] $refused Refused changes.
* @return void
*/
public static function remember_bridge_mode_refusals( array $refused ): void {
if ( $refused ) {
set_transient( 'wpdo_mode_refused_' . get_current_user_id(), $refused, 5 * MINUTE_IN_SECONDS );
}
}
/**
* Refused bridge-mode changes remembered for this user, once.
*
* @return string[]
*/
public static function pull_bridge_mode_refusals(): array {
$key = 'wpdo_mode_refused_' . get_current_user_id();
$refused = get_transient( $key );
delete_transient( $key );
return is_array( $refused ) ? $refused : array();
}
/**
* AJAX: save a single settings section without a full page reload.
*/
@@ -3807,7 +3861,10 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( ! isset( $handlers[ $section ] ) ) {
wp_send_json_error( 'invalid_section', 400 );
}
self::{$handlers[ $section ]}();
$refused = self::{$handlers[ $section ]}();
if ( is_array( $refused ) && $refused ) {
wp_send_json_error( array( 'message' => __( '部分模式未變更:', '2meet-data-optimizer' ) . implode( ';', $refused ) ) );
}
wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) );
}
}
+62 -52
View File
@@ -217,6 +217,16 @@ class TMDO_Installer {
// Idempotent — only creates tables that don't already exist.
self::install_v2_tables();
// 1.4.2: archive sweeps before this release copied trashed meta daily.
// One unbounded DELETE: CLI only, never in a web activation.
if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'TMDO_Zone_Archive' ) ) {
global $wpdb;
$archive = TMDO_Zone_Archive::table();
if ( $archive === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $archive ) ) ) ) {
TMDO_Zone_Archive::remove_duplicates();
}
}
update_option( 'wpdo_db_version', self::SCHEMA_VERSION );
// Register wpdo_features with autoload=no so it doesn't inflate every page load.
@@ -771,31 +781,46 @@ class TMDO_Installer {
return $status;
}
/**
* Every cron hook the plugin schedules. Cleared with wp_unschedule_hook(),
* which also removes events scheduled with args (tmdo_drift_resync( 'post' ),
* backfill batches) that wp_clear_scheduled_hook( $hook ) leaves behind
* (2.4.2 PR-138).
*/
private const CRON_HOOKS = array(
'wpdo_warm_cleanup',
'wpdo_archive_sweep',
'wpdo_errors_gc',
'wpdo_flush_views',
'wpdo_remove_uae_plugin_dir',
'wpdo_daily_health_check',
'wpdo_snapshot_prune_daily',
'wpdo_fsm_automator_run',
'wpdo_collect_site_metrics',
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_entity_backfill_batch',
'wpdo_migration_tick',
'wpdo_auto_promote_check',
'wpdo_post_stress_test_batch',
'wpdo_stress_test_batch',
'wpdo_term_stress_test_batch',
'wpdo_comment_stress_test_batch',
);
/**
* Called on plugin deactivation. Non-destructive.
*/
public static function deactivate(): void {
wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
// v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0).
wp_clear_scheduled_hook( 'wpdo_errors_gc' );
wp_clear_scheduled_hook( 'wpdo_flush_views' );
wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' );
wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' );
wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' );
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
wp_clear_scheduled_hook( 'tmdo_drift_resync' );
wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' );
foreach ( self::CRON_HOOKS as $hook ) {
wp_unschedule_hook( $hook );
}
}
// ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
/**
@@ -967,42 +992,26 @@ class TMDO_Installer {
// Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
// or by 3rd-party hooks). Validates name pattern before delete to avoid
// accidental option removal.
$residual = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( 'wpdo_' ) . '%'
)
);
foreach ( (array) $residual as $opt ) {
if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
++$options_deleted;
// 1.4.2: tmdo_* options and their transients too (tmdo_schema_deferred,
// tmdo_entity_indexes, tmdo_cov_* …) — a reinstall saw stale ones.
// tmdo_latepoint_* belongs to the LatePoint add-on.
foreach ( array( 'wpdo_', 'tmdo_', '_transient_tmdo_', '_transient_timeout_tmdo_' ) as $prefix ) {
$residual = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( $prefix ) . '%'
)
);
foreach ( (array) $residual as $opt ) {
if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_(?!latepoint_)[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
++$options_deleted;
}
}
}
// ── 5) Clear cron events ──────────────────────────────────────────────
$crons = array(
'wpdo_warm_cleanup',
'wpdo_archive_sweep',
'wpdo_errors_gc',
'wpdo_flush_views',
'wpdo_remove_uae_plugin_dir',
'wpdo_daily_health_check',
'wpdo_snapshot_prune_daily',
'wpdo_fsm_automator_run',
'wpdo_collect_site_metrics',
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_post_stress_test_batch',
'wpdo_user_stress_test_batch',
'wpdo_term_stress_test_batch',
'wpdo_comment_stress_test_batch',
);
foreach ( $crons as $hook ) {
if ( false !== wp_next_scheduled( $hook ) ) {
wp_clear_scheduled_hook( $hook );
foreach ( self::CRON_HOOKS as $hook ) {
if ( wp_unschedule_hook( $hook ) ) {
++$crons_cleared;
}
}
@@ -1440,7 +1449,8 @@ class TMDO_Installer {
original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_post_id (post_id),
KEY idx_post_type_date (post_type, archived_at)
KEY idx_post_type_date (post_type, archived_at),
KEY idx_original_meta (original_meta_id)
) {$charset};";
foreach ( $sqls as $sql ) {
@@ -872,7 +872,6 @@ final class TMDO_Post_Stress_Tester {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_post_';
$map = array(
'product' => $prefix . 'wc_product',
'hp_listing' => $prefix . 'hp_listing_core',
'hp_request' => $prefix . 'hp_request_core',
'hp_vendor' => $prefix . 'hp_vendor_core',
@@ -1036,7 +1035,6 @@ final class TMDO_Post_Stress_Tester {
return array(
$prefix . 'wp_core',
$prefix . 'attachment',
$prefix . 'wc_product',
$prefix . 'hp_listing_core',
$prefix . 'hp_request_core',
$prefix . 'hp_vendor_core',
+14 -1
View File
@@ -325,7 +325,20 @@ final class TMDO_Entity_Health {
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
}
/**
* Whether a backfill batch is scheduled for any group of the entity. Batches
* are scheduled with (entity, group) args, so looking for (entity) alone
* never found one (2.4.2 PR-137).
*
* @param string $entity_type Entity type.
* @return bool
*/
private static function is_backfill_active( string $entity_type ): bool {
return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) );
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
if ( wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type, $group ) ) ) {
return true;
}
}
return false;
}
}
@@ -210,7 +210,15 @@ class TMDO_Zone_Archive {
global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
// Rows already archived are skipped: the sweep used to copy the same
// trashed meta again every day (2.4.2 PR-136).
$table = self::table();
// Without the index each candidate scans the archive: wait for `wp tmdo install`.
if ( ! TMDO_IS_SQLITE && ! $wpdb->get_var( "SHOW INDEX FROM `{$table}` WHERE Key_name = 'idx_original_meta'" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- internal table name
return 0;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
@@ -218,12 +226,14 @@ class TMDO_Zone_Archive {
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s
AND NOT EXISTS ( SELECT 1 FROM `{$table}` a WHERE a.original_meta_id = pm.meta_id )
LIMIT %d",
$cutoff,
$limit
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $rows ) {
return 0;
@@ -245,6 +255,21 @@ class TMDO_Zone_Archive {
return count( $entries );
}
/**
* Delete archive rows that copy the same original meta row again, keeping
* the first copy (sweeps before 1.4.2 archived trashed meta every day).
* Rows without an original meta id are left alone.
*
* @return int Rows deleted.
*/
public static function remove_duplicates(): int {
global $wpdb;
$table = self::table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$deleted = $wpdb->query( "DELETE FROM `{$table}` WHERE original_meta_id > 0 AND id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM `{$table}` WHERE original_meta_id > 0 GROUP BY original_meta_id ) AS k )" );
return is_int( $deleted ) ? $deleted : 0;
}
/**
* Get archive statistics.
*
+8 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.1
Stable tag: 1.4.1
Stable tag: 1.4.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,13 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog ==
= 1.4.2 =
* The settings page shows why a bridge-mode change was refused (unsafe jump, Promotion Guard) instead of reporting it saved
* Archive sweep skips meta already archived; `wp tmdo install` adds an index on original_meta_id and removes duplicate archive rows (the sweep waits until the index exists)
* Entity health sees a running backfill (batches are scheduled with entity and group args)
* Deactivate and uninstall clear every scheduled event, including ones scheduled with args; uninstall also removes tmdo_* options and transients
* The refused-uninstall notice points to `wp tmdo bridge-restore-native`; the post stress tester no longer uses the retired wc_product table
= 1.4.1 =
* A query ordered by its meta_key among several orderings (or by the key's name) keeps meta_key native; meta_compare_key other than '=' is not routed; a sort group stays when orderby may use it as the primary clause
* Only standalone posts.ID terms in ORDER BY become flat post_id tie-breaks (FIELD()/IN expressions from other plugins are left alone); PCRE failures keep the original SQL
+2 -1
View File
@@ -180,7 +180,7 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
}
}
if ( ! function_exists( 'wp_next_scheduled' ) ) {
function wp_next_scheduled( string $hook ): int|false { return false; }
function wp_next_scheduled( string $hook, array $args = array() ): int|false { return $GLOBALS['_wp_next_scheduled'][ $hook ][ md5( serialize( $args ) ) ] ?? false; }
}
if ( ! function_exists( 'wp_schedule_event' ) ) {
function wp_schedule_event( int $t, string $r, string $h ): bool { return true; }
@@ -188,6 +188,7 @@ if ( ! function_exists( 'wp_schedule_event' ) ) {
if ( ! function_exists( 'wp_schedule_single_event' ) ) {
function wp_schedule_single_event( int $ts, string $hook ): bool { return true; }
}
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) {
function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
}
+2 -1
View File
@@ -303,7 +303,8 @@ class DriftCheckTest extends TestCase {
}
// Literal: uninstall.php does not load TMDO_Drift_Check.
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
$this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' );
// One CRON_HOOKS list serves deactivate and uninstall (InstallerCleanupTest covers both).
$this->assertSame( 1, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'listed in CRON_HOOKS' );
}
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
@@ -68,6 +68,50 @@ class InstallerCleanupTest extends TestCase {
$GLOBALS['_wp_options'] = array();
}
/**
* Events scheduled with args (tmdo_drift_resync( 'post' ), backfill
* batches) are not cleared by wp_clear_scheduled_hook( $hook ); the user
* stress hook was misnamed and three hooks were missing (2.4.2 PR-138, F-4).
*/
private const ARG_AND_MISSED_HOOKS = array( 'tmdo_drift_resync', 'wpdo_entity_backfill_batch', 'wpdo_stress_test_batch', 'wpdo_migration_tick', 'wpdo_auto_promote_check' );
public function test_deactivate_unschedules_every_hook_whatever_its_args(): void {
$GLOBALS['_wp_unscheduled'] = array();
WPDO_Installer::deactivate();
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
}
}
public function test_uninstall_unschedules_every_hook_whatever_its_args(): void {
$GLOBALS['_wp_unscheduled'] = array();
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
}
}
/** tmdo_* options and transients were left behind; a reinstall then saw stale deferrals and indexes (F-5). */
public function test_uninstall_removes_tmdo_options_and_transients(): void {
global $wpdb;
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x', 'hivepress_settings', 'tmdo_latepoint_db_version' ) as $name ) {
$wpdb->insert( $wpdb->options, array( 'option_name' => $name, 'option_value' => '1' ) );
$GLOBALS['_wp_options'][ $name ] = '1';
}
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x' ) as $name ) {
$this->assertArrayNotHasKey( $name, $GLOBALS['_wp_options'], $name );
}
$this->assertArrayHasKey( 'hivepress_settings', $GLOBALS['_wp_options'], 'other plugins untouched' );
$this->assertArrayHasKey( 'tmdo_latepoint_db_version', $GLOBALS['_wp_options'], 'the LatePoint add-on owns it' );
}
public function test_drops_static_tables(): void {
global $wpdb;
@@ -29,7 +29,8 @@ class ZoneArchiveIntegrationTest extends TestCase {
`original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `archived_at` (`archived_at`)
KEY `archived_at` (`archived_at`),
KEY `idx_original_meta` (`original_meta_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
@@ -45,6 +46,69 @@ class ZoneArchiveIntegrationTest extends TestCase {
$GLOBALS['_wp_postmeta'] = [];
}
// ── sweep / duplicates (2.4.2 PR-136, manual F-6) ─────────────────────
/** Posts and postmeta for sweep(): post 10 trashed 200 days ago, post 11 published. */
private function seed_trashed_post(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_modified_gmt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
$old = gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS );
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (10, 'hp_listing', 'trash', '{$old}'), (11, 'hp_listing', 'publish', '{$old}')" );
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (10, 'hp_price', '5'), (10, 'hp_rating', '4'), (11, 'hp_price', '9')" );
}
private function drop_posts(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
}
private function archive_rows(): int {
global $wpdb;
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' );
}
public function test_sweep_copies_each_trashed_meta_row_once(): void {
$this->seed_trashed_post();
try {
$this->assertSame( 2, WPDO_Zone_Archive::sweep( 90, false ) );
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ), 'already archived' );
$this->assertSame( 2, $this->archive_rows() );
} finally {
$this->drop_posts();
}
}
/** Until `wp tmdo install` adds the index, NOT EXISTS would scan the archive per candidate. */
public function test_sweep_waits_for_the_original_meta_index(): void {
global $wpdb;
$this->seed_trashed_post();
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `idx_original_meta`' );
try {
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ) );
$this->assertSame( 0, $this->archive_rows() );
} finally {
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD KEY `idx_original_meta` (`original_meta_id`)' );
$this->drop_posts();
}
}
public function test_remove_duplicates_keeps_the_first_copy_of_each_meta_row(): void {
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' ); // original_meta_id 0: no identity, kept.
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' );
$this->assertSame( 2, WPDO_Zone_Archive::remove_duplicates() );
$this->assertSame( 4, $this->archive_rows() );
$this->assertSame( 0, WPDO_Zone_Archive::remove_duplicates() );
}
// ── archive / get ─────────────────────────────────────────────────────
public function test_archive_stores_plain_entry(): void {
+1
View File
@@ -315,6 +315,7 @@ if ( ! function_exists( 'get_metadata' ) ) {
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* The admin settings form saves bridge modes through TMDO_Mode_Manager::set().
* A refused change (unsafe jump, Promotion Guard) used to be dropped silently:
* the page said "saved" while the mode stayed put (2.4.2 PR-135, manual F-2).
*/
class AdminBridgeModeSaveTest extends TestCase {
public static function setUpBeforeClass(): void {
require_once TMDO_PATH . 'admin/class-tmdo-admin.php';
}
protected function setUp(): void {
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$GLOBALS['_wp_current_user_id'] = 7;
TMDO_Mode_Manager::reset_cache();
}
private function modes( array $modes ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
TMDO_Mode_Manager::reset_cache();
}
public function test_an_accepted_change_is_applied_without_refusals(): void {
$this->modes( array( 'user' => 'disabled' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'dual_write' ) );
$this->assertSame( array(), $refused );
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
}
public function test_an_unsafe_jump_is_reported_and_the_mode_kept(): void {
$this->modes( array( 'post' => 'disabled' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'post' => 'shadow_read' ) );
$this->assertCount( 1, $refused );
$this->assertStringStartsWith( 'post', $refused[0] );
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'post' ) );
}
public function test_empty_or_unknown_modes_are_ignored(): void {
$this->modes( array( 'term' => 'disabled' ) );
$this->assertSame( array(), TMDO_Admin::apply_bridge_modes( array( 'post' => '', 'term' => 'bogus' ) ) );
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'term' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_a_promotion_refused_by_the_guard_is_reported(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'user' => 'shadow_read' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'aeav_only' ) );
$this->assertCount( 1, $refused );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
}
/** The full-page form redirects after saving: refusals wait in a per-user transient, shown once. */
public function test_refusals_are_kept_for_the_next_page_view_only(): void {
TMDO_Admin::remember_bridge_mode_refusals( array( 'post:refused' ) );
$this->assertSame( array( 'post:refused' ), TMDO_Admin::pull_bridge_mode_refusals() );
$this->assertSame( array(), TMDO_Admin::pull_bridge_mode_refusals() );
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Backfill batches are scheduled per (entity, group) — REST backfill and
* TMDO_Core re-chain `wpdo_entity_backfill_batch` with both args. The health
* panel looked for (entity) alone, never found a running backfill and kept
* offering to start one; starting one resets the group's checkpoint
* (2.4.2 PR-137, manual F-11).
*/
class EntityHealthBackfillTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_next_scheduled'] = array();
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
TMDO_Entity_Registry::register_group( 'user', 'eh_one', array( array( 'key' => 'eh_a', 'type' => 'text' ) ) );
TMDO_Entity_Registry::register_group( 'user', 'eh_two', array( array( 'key' => 'eh_b', 'type' => 'text' ) ) );
}
protected function tearDown(): void {
$GLOBALS['_wp_next_scheduled'] = array();
TMDO_Entity_Registry::init();
}
private function backfill_active( string $entity_type ): bool {
$m = new ReflectionMethod( TMDO_Entity_Health::class, 'is_backfill_active' );
$m->setAccessible( true );
return (bool) $m->invoke( null, $entity_type );
}
public function test_a_backfill_scheduled_for_one_group_is_seen(): void {
$GLOBALS['_wp_next_scheduled']['wpdo_entity_backfill_batch'][ md5( serialize( array( 'user', 'eh_two' ) ) ) ] = time() + 5;
$this->assertTrue( $this->backfill_active( 'user' ) );
$this->assertFalse( $this->backfill_active( 'post' ), 'other entities are not affected' );
}
public function test_no_scheduled_batch_means_no_backfill(): void {
$this->assertFalse( $this->backfill_active( 'user' ) );
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ if ( $wpdo_blocked ) {
wp_die(
'<p>' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '</p>'
. '<ul><li>' . implode( '</li><li>', array_map( 'esc_html', $wpdo_blocked ) ) . '</li></ul>'
. '<p>' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
. '<p>' . esc_html( '請先以 wp tmdo bridge-restore-native 把資料寫回 WordPress 原生表,再解除安裝。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
);
}