From 86ac84e07cd3f3dc9a6a102fa38a245baf46a8cb Mon Sep 17 00:00:00 2001 From: wpdev Date: Mon, 28 Sep 2026 18:14:35 +0800 Subject: [PATCH 1/7] fix(2meet-data-optimizer): show why an admin bridge-mode change was refused - The settings form and the AJAX section save both went through TMDO_Mode_Manager::set() and dropped its WP_Error: an unsafe jump or a Promotion Guard refusal still said "saved" while the mode stayed put - TMDO_Admin::apply_bridge_modes() returns the refusals; the full form keeps them in a per-user transient and shows them after the redirect, the AJAX save returns them as the error message (the settings JS shows it) - Verify: AdminBridgeModeSaveTest (5 new); unit 709, integration 606, phpcs 0, stan OK - PR: PR-135 (manual F-2) --- admin/assets/wpdo-settings.js | 2 +- admin/class-tmdo-admin.php | 105 +++++++++++++++++++------ tests/unit/AdminBridgeModeSaveTest.php | 78 ++++++++++++++++++ 3 files changed, 160 insertions(+), 25 deletions(-) create mode 100644 tests/unit/AdminBridgeModeSaveTest.php diff --git a/admin/assets/wpdo-settings.js b/admin/assets/wpdo-settings.js index 8cf7cc2..6389e71 100644 --- a/admin/assets/wpdo-settings.js +++ b/admin/assets/wpdo-settings.js @@ -92,7 +92,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( () => { diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index 27827ac..eeca602 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -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 '

' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '

'; } + foreach ( self::pull_bridge_mode_refusals() as $refused ) { + echo '

' . esc_html__( '模式未變更:', '2meet-data-optimizer' ) . esc_html( $refused ) . '

'; + } $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, ":". + */ + 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 + */ + 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 $posted Mode per entity type. + * @return string[] Refused changes, ":". + */ + 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' ) ) ); } } diff --git a/tests/unit/AdminBridgeModeSaveTest.php b/tests/unit/AdminBridgeModeSaveTest.php new file mode 100644 index 0000000..ef7b8a4 --- /dev/null +++ b/tests/unit/AdminBridgeModeSaveTest.php @@ -0,0 +1,78 @@ + 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() ); + } +} From 6687d54bef5ba38f3d8761cb32d18e51a0ff7648 Mon Sep 17 00:00:00 2001 From: wpdev Date: Mon, 28 Sep 2026 18:17:47 +0800 Subject: [PATCH 2/7] fix(2meet-data-optimizer): archive sweep copies each trashed meta row once - sweep() skips meta rows already in wpdo_archive (NOT EXISTS on original_meta_id): it copied the same trashed meta again every day and wpdo_archive has no unique key (dev20: 300 rows for 150 meta rows) - TMDO_Zone_Archive::remove_duplicates() keeps the first copy per original_meta_id (rows without one are left alone); install() runs it, so `wp tmdo install` cleans existing sites - wpdo_archive gets KEY idx_original_meta for the lookup (new installs; existing sites via `wp tmdo install`). SCHEMA_VERSION is not bumped, so no web request runs dbDelta - Verify: ZoneArchiveIntegrationTest (2 new); dev20 read-only check: 150 of 300 rows would be removed; unit 709, integration 608, phpcs 0, stan OK - PR: PR-136 (manual F-6) --- includes/class-tmdo-installer.php | 12 ++++- includes/zones/class-tmdo-zone-archive.php | 21 ++++++++ .../ZoneArchiveIntegrationTest.php | 49 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index da1244a..4485e4c 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -217,6 +217,15 @@ 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. + if ( 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. @@ -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 ) { diff --git a/includes/zones/class-tmdo-zone-archive.php b/includes/zones/class-tmdo-zone-archive.php index ccce528..bed8eb3 100644 --- a/includes/zones/class-tmdo-zone-archive.php +++ b/includes/zones/class-tmdo-zone-archive.php @@ -210,7 +210,11 @@ 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(); + // 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 +222,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 +251,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. * diff --git a/tests/integration/ZoneArchiveIntegrationTest.php b/tests/integration/ZoneArchiveIntegrationTest.php index 1c12e21..462da4b 100644 --- a/tests/integration/ZoneArchiveIntegrationTest.php +++ b/tests/integration/ZoneArchiveIntegrationTest.php @@ -45,6 +45,55 @@ 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(); + } + } + + 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 { From 90877b81e054e294090f2d607e829eb010234cd4 Mon Sep 17 00:00:00 2001 From: wpdev Date: Mon, 28 Sep 2026 18:18:56 +0800 Subject: [PATCH 3/7] fix(2meet-data-optimizer): entity health sees a running backfill - Backfill batches are scheduled with (entity, group) args; the health panel looked for (entity) alone, never saw one and kept offering to start a backfill, which resets the group's checkpoint - is_backfill_active() checks every group of the entity - Unit bootstrap: wp_next_scheduled() stub honours hook + args - Verify: EntityHealthBackfillTest (2 new); unit 711, phpcs 0, stan OK - PR: PR-137 (manual F-11) --- includes/engine/class-tmdo-entity-health.php | 15 ++++++- tests/bootstrap.php | 2 +- tests/unit/EntityHealthBackfillTest.php | 44 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/unit/EntityHealthBackfillTest.php diff --git a/includes/engine/class-tmdo-entity-health.php b/includes/engine/class-tmdo-entity-health.php index e470ef7..4a5278a 100644 --- a/includes/engine/class-tmdo-entity-health.php +++ b/includes/engine/class-tmdo-entity-health.php @@ -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; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c1fbd05..9e64583 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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; } diff --git a/tests/unit/EntityHealthBackfillTest.php b/tests/unit/EntityHealthBackfillTest.php new file mode 100644 index 0000000..bf071d2 --- /dev/null +++ b/tests/unit/EntityHealthBackfillTest.php @@ -0,0 +1,44 @@ + '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' ) ); + } +} From 15096f064f6e85cc59014ca9ea3175f78a2292f4 Mon Sep 17 00:00:00 2001 From: wpdev Date: Mon, 28 Sep 2026 18:25:01 +0800 Subject: [PATCH 4/7] fix(2meet-data-optimizer): deactivate and uninstall clear every scheduled event and tmdo_* option - One CRON_HOOKS list for deactivate() and uninstall; wp_unschedule_hook() also removes events scheduled with args (tmdo_drift_resync, backfill batches). - Fix the wrong wpdo_user_stress_test_batch name; add migration tick, entity backfill, auto-promote and all stress-test batch hooks. - Uninstall sweeps tmdo_* options and _transient_(timeout_)tmdo_* rows too. - Verify: InstallerCleanupTest (3 new), unit 711, integration 611, phpcs 0 - PR: PR-138 (manual F-4, F-5) --- includes/class-tmdo-installer.php | 100 ++++++++++----------- tests/bootstrap.php | 1 + tests/integration/DriftCheckTest.php | 3 +- tests/integration/InstallerCleanupTest.php | 43 +++++++++ tests/integration/bootstrap.php | 1 + 5 files changed, 96 insertions(+), 52 deletions(-) diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 4485e4c..2b7df7d 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -780,31 +780,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 ───────────────────────────────── /** @@ -976,42 +991,25 @@ 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. + 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)_[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; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9e64583..447b53f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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; } } diff --git a/tests/integration/DriftCheckTest.php b/tests/integration/DriftCheckTest.php index c646b4c..1f416d1 100644 --- a/tests/integration/DriftCheckTest.php +++ b/tests/integration/DriftCheckTest.php @@ -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 { diff --git a/tests/integration/InstallerCleanupTest.php b/tests/integration/InstallerCleanupTest.php index b95224b..a057cd8 100644 --- a/tests/integration/InstallerCleanupTest.php +++ b/tests/integration/InstallerCleanupTest.php @@ -68,6 +68,49 @@ 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' ) 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' ); + } + public function test_drops_static_tables(): void { global $wpdb; diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 76fcb96..e7d2d4d 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -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; } } From cd5da4143e66d687c42b48be8f8115414658a475 Mon Sep 17 00:00:00 2001 From: wpdev Date: Mon, 28 Sep 2026 18:26:15 +0800 Subject: [PATCH 5/7] chore(2meet-data-optimizer): uninstall message points to bridge-restore-native; stress tester drops wc_product - The refused-uninstall notice said no tool writes the data back to native tables; wp tmdo bridge-restore-native does. - The post stress tester no longer maps product to the retired wc_product table. - Verify: unit 711, integration 611, phpcs 0 - PR: PR-139 (manual F-7, F-8) --- includes/class-tmdo-post-stress-tester.php | 2 -- uninstall.php | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/includes/class-tmdo-post-stress-tester.php b/includes/class-tmdo-post-stress-tester.php index e483656..3e20750 100644 --- a/includes/class-tmdo-post-stress-tester.php +++ b/includes/class-tmdo-post-stress-tester.php @@ -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', diff --git a/uninstall.php b/uninstall.php index c6e0a40..f1fe960 100644 --- a/uninstall.php +++ b/uninstall.php @@ -94,7 +94,7 @@ if ( $wpdo_blocked ) { wp_die( '

' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '

' . '
  • ' . implode( '
  • ', array_map( 'esc_html', $wpdo_blocked ) ) . '
' - . '

' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '

' + . '

' . esc_html( '請先以 wp tmdo bridge-restore-native 把資料寫回 WordPress 原生表,再解除安裝。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '

' ); } From c9d5a08a428ea3bb17cd905009e1f5cbafa2ae75 Mon Sep 17 00:00:00 2001 From: wpdev Date: Mon, 28 Sep 2026 18:26:52 +0800 Subject: [PATCH 6/7] chore(2meet-data-optimizer): 1.4.2 release notes and version - Version 1.4.2 (header, TMDO_VERSION, readme Stable tag and changelog) - CHANGELOG lists PR-135..139; after upgrade run wp tmdo install once per site for the archive index and duplicate cleanup - PR: PR-140 --- 2meet-data-optimizer.php | 4 ++-- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ readme.txt | 9 ++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index ec92a55..b1707cd 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -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__ ) ); diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5f5cf..03f13d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ 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 請求中做 DDL)。未執行也不影響功能,只是 sweep + 的 NOT EXISTS 檢查少了索引。 + +--- + ## [1.4.1] — 2026-09-28 — 2.4 驗收修正 **性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。 diff --git a/readme.txt b/readme.txt index f8df125..b14e9d2 100644 --- a/readme.txt +++ b/readme.txt @@ -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 +* 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 From 6f117f4e9b343c3af08251ee572ee146df44d264 Mon Sep 17 00:00:00 2001 From: wpdev Date: Mon, 28 Sep 2026 18:43:50 +0800 Subject: [PATCH 7/7] fix(2meet-data-optimizer): 1.4.2 acceptance fixes for archive sweep and uninstall - Archive sweep waits until idx_original_meta exists (a plugin update does not run dbDelta; without the index NOT EXISTS scans the archive per row) - Duplicate archive cleanup runs from WP-CLI only, never in a web activation - Uninstall keeps the LatePoint add-on's tmdo_latepoint_db_version - AJAX save errors stay on screen instead of fading after 2.5 s - Verify: 2 new integration tests; unit 711, integration 612, phpcs 0, stan OK - PR: PR-141 (2.4.2 correctness review) --- CHANGELOG.md | 12 ++++++++++-- admin/assets/wpdo-settings.js | 5 ++++- includes/class-tmdo-installer.php | 6 ++++-- includes/zones/class-tmdo-zone-archive.php | 4 ++++ readme.txt | 2 +- tests/integration/InstallerCleanupTest.php | 3 ++- .../integration/ZoneArchiveIntegrationTest.php | 17 ++++++++++++++++- 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f13d8..22ae452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,16 @@ Versioning follows [Semantic Versioning](https://semver.org/). ### 升級後 - 每個站台執行一次 `wp tmdo install`:為 archive 表加上 `idx_original_meta` 並刪除重複的 archive - 列。表很大時請在離峰時段執行(CLI,不在 web 請求中做 DDL)。未執行也不影響功能,只是 sweep - 的 NOT EXISTS 檢查少了索引。 + 列(刪除重複只在 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`。 --- diff --git a/admin/assets/wpdo-settings.js b/admin/assets/wpdo-settings.js index 6389e71..bab4d08 100644 --- a/admin/assets/wpdo-settings.js +++ b/admin/assets/wpdo-settings.js @@ -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 ); diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 2b7df7d..c5a01bd 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -218,7 +218,8 @@ class TMDO_Installer { self::install_v2_tables(); // 1.4.2: archive sweeps before this release copied trashed meta daily. - if ( class_exists( 'TMDO_Zone_Archive' ) ) { + // 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 ) ) ) ) { @@ -993,6 +994,7 @@ class TMDO_Installer { // accidental option removal. // 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( @@ -1001,7 +1003,7 @@ class TMDO_Installer { ) ); foreach ( (array) $residual as $opt ) { - if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) { + if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_(?!latepoint_)[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) { ++$options_deleted; } } diff --git a/includes/zones/class-tmdo-zone-archive.php b/includes/zones/class-tmdo-zone-archive.php index bed8eb3..21a67f0 100644 --- a/includes/zones/class-tmdo-zone-archive.php +++ b/includes/zones/class-tmdo-zone-archive.php @@ -213,6 +213,10 @@ class TMDO_Zone_Archive { // 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( diff --git a/readme.txt b/readme.txt index b14e9d2..2086be0 100644 --- a/readme.txt +++ b/readme.txt @@ -109,7 +109,7 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, = 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 +* 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 diff --git a/tests/integration/InstallerCleanupTest.php b/tests/integration/InstallerCleanupTest.php index a057cd8..be73e63 100644 --- a/tests/integration/InstallerCleanupTest.php +++ b/tests/integration/InstallerCleanupTest.php @@ -98,7 +98,7 @@ class InstallerCleanupTest extends TestCase { /** 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' ) as $name ) { + 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'; } @@ -109,6 +109,7 @@ class InstallerCleanupTest extends TestCase { $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 { diff --git a/tests/integration/ZoneArchiveIntegrationTest.php b/tests/integration/ZoneArchiveIntegrationTest.php index 462da4b..b932eb9 100644 --- a/tests/integration/ZoneArchiveIntegrationTest.php +++ b/tests/integration/ZoneArchiveIntegrationTest.php @@ -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' ); } @@ -81,6 +82,20 @@ class ZoneArchiveIntegrationTest extends TestCase { } } + /** 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 );