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() );
+ }
+}