PluginProbe ʕ •ᴥ•ʔ
WPML Multilingual & Multicurrency for WooCommerce / 5.5.7
WPML Multilingual & Multicurrency for WooCommerce v5.5.7
5.5.7 5.3.8 5.3.9 5.4.3 5.4.4 5.4.5 5.5.1 5.5.1.1 5.5.2.2 5.5.2.3 5.5.3 5.5.3.1 5.5.4 5.5.5 5.5.6 trunk 0.9 1.0 1.1 1.2 1.3 1.4 1.5 2.0 2.2 2.3 2.3.1 2.3.2 3.0 3.0.1 3.1 3.2 3.2.1 3.2.2 3.3 3.3.1 3.3.2 3.3.3 3.3.4 3.4 3.4.1 3.4.2 3.4.3 3.5 3.5.1 3.5.2 3.5.3 3.5.4 3.5.5 3.6 3.6.1 3.6.10 3.6.11 3.6.2 3.6.3 3.6.4 3.6.5 3.6.5.1 3.6.6 3.6.7 3.6.8 3.6.9 3.7 3.7.1 3.7.10 3.7.11 3.7.12 3.7.13 3.7.14 3.7.15 3.7.16 3.7.2 3.7.3 3.7.4 3.7.5 3.7.6 3.7.7 3.7.8 3.7.9 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.9.0 3.9.1 3.9.1.1 3.9.2 3.9.3 3.9.4 3.9.5 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.10.0 4.10.1 4.10.2 4.10.3 4.10.4 4.11.2 4.11.3.2 4.11.5 4.11.6 4.12.1 4.12.4 4.12.5 4.12.6 4.2.0 4.2.0.1 4.2.1 4.2.1.1 4.2.10 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.7.1 4.2.8 4.2.8.1 4.2.9 4.3.0 4.3.1 4.3.2 4.3.2.1 4.3.3 4.3.4 4.3.5 4.3.6 4.3.7 4.4.0 4.4.1 4.4.2 4.4.2.1 4.5.0 4.6.0 4.6.1 4.6.2 4.6.2.1 4.6.3 4.6.5 4.6.6 4.6.7 4.7.0 4.7.1 4.7.2 4.7.3 4.7.4 4.7.5 4.7.6 4.7.7 4.7.8 4.7.9 4.8.0 4.9.0 4.9.1 5.0.1 5.0.2 5.1.1 5.1.2 5.1.3 5.2.0 5.2.1 5.3.2 5.3.3.1 5.3.4 5.3.5 5.3.6 5.3.7
woocommerce-multilingual / classes / EditorScopedSync / Notices.php
woocommerce-multilingual / classes / EditorScopedSync Last commit date
EditorChangeTracker.php 1 week ago ForceUpdateEndpoint.php 1 week ago HooksFactory.php 1 week ago Mode.php 1 week ago Notices.php 1 week ago Settings.php 1 week ago SyncGate.php 1 week ago
Notices.php
418 lines
1 <?php
2
3 namespace WCML\EditorScopedSync;
4
5 class Notices implements \IWPML_Backend_Action {
6
7 const SYNC_SUMMARY_TRANSIENT_PREFIX = 'wcml_editor_scoped_last_save_';
8 const SWITCH_HINT_TRANSIENT_PREFIX = 'wcml_editor_scoped_heavy_load_';
9 const TRANSIENT_TTL = 600;
10
11 const PRIORITY_AFTER_ALL_SAVE_PROCESSING = 999;
12 const PRIORITY_BEFORE_SAVE_SUMMARY = 998;
13
14 const SWITCH_HINT_ITERATION_THRESHOLD = 120;
15
16 const SWITCH_HINT_DISMISS_KEY = 'editor_scoped_sync_switch_hint_dismissed';
17
18 private $mode;
19
20 private $woocommerce_wpml;
21
22 private $wpdb;
23
24 public function __construct( Mode $mode, \woocommerce_wpml $woocommerce_wpml, \wpdb $wpdb ) {
25 $this->mode = $mode;
26 $this->woocommerce_wpml = $woocommerce_wpml;
27 $this->wpdb = $wpdb;
28 }
29
30 public function add_hooks() {
31 if ( $this->mode->isEditorScoped() ) {
32 add_action( 'shutdown', [ $this, 'maybePersistSyncSummary' ], self::PRIORITY_AFTER_ALL_SAVE_PROCESSING );
33 add_action( 'edit_form_top', [ $this, 'maybeRenderSyncSummary' ] );
34 } else {
35 add_action( 'shutdown', [ $this, 'maybePersistSwitchHint' ], self::PRIORITY_BEFORE_SAVE_SUMMARY );
36 add_action( 'edit_form_top', [ $this, 'maybeRenderSwitchHint' ] );
37 add_action( 'admin_init', [ $this, 'maybeHandleSwitchHintDismiss' ] );
38 }
39 }
40
41 public function maybePersistSyncSummary() {
42 if ( ! $this->isProductSaveRequest() ) {
43 return;
44 }
45
46 $productId = $this->getRequestProductId();
47
48 if ( ! $productId ) {
49 return;
50 }
51
52 $edited = EditorChangeTracker::editedVariationIdsFor( $productId );
53 $deleted = EditorChangeTracker::deletedVariationIdsFor( $productId );
54
55 $existing = get_transient( self::SYNC_SUMMARY_TRANSIENT_PREFIX . $productId );
56
57 if ( is_array( $existing ) ) {
58 if ( ! empty( $existing['edited'] ) ) {
59 $edited = array_values( array_unique( array_merge( $edited, (array) $existing['edited'] ) ) );
60 }
61 if ( ! empty( $existing['deleted'] ) ) {
62 $deleted = array_values( array_unique( array_merge( $deleted, (array) $existing['deleted'] ) ) );
63 }
64 }
65
66 $allIds = get_posts( [
67 'post_type' => 'product_variation',
68 'post_parent' => $productId,
69 'fields' => 'ids',
70 'post_status' => 'any',
71 'numberposts' => -1,
72 ] );
73
74 $total = count( $allIds );
75
76 set_transient( self::SYNC_SUMMARY_TRANSIENT_PREFIX . $productId, [
77 'time' => time(),
78 'edited' => array_values( $edited ),
79 'deleted' => array_values( $deleted ),
80 'total' => $total,
81 'description_changed' => $this->changedNonVariationFields( $productId ),
82 ], self::TRANSIENT_TTL );
83 }
84
85 private function changedNonVariationFields( $productId ) {
86 $changes = EditorChangeTracker::productChangesFor( $productId );
87 $ignore = [ 'shipping_class_id', 'stock_quantity' ];
88 return array_values( array_diff( array_keys( $changes ), $ignore ) );
89 }
90
91 public function maybeRenderSyncSummary( $post = null ) {
92 $postId = $this->getEditedPostId( $post );
93
94 if ( ! $postId ) {
95 return;
96 }
97
98 if ( get_post_type( $postId ) !== 'product' ) {
99 return;
100 }
101 $summary = get_transient( self::SYNC_SUMMARY_TRANSIENT_PREFIX . $postId );
102
103 if ( ! is_array( $summary ) || empty( $summary['total'] ) ) {
104 return;
105 }
106
107 $editedCount = count( $summary['edited'] ?? [] );
108 $total = (int) $summary['total'];
109 $skippedCount = max( 0, $total - $editedCount - count( $summary['deleted'] ?? 0 ) );
110
111 if ( $skippedCount <= 0 ) {
112 delete_transient( self::SYNC_SUMMARY_TRANSIENT_PREFIX . $postId );
113 return;
114 }
115
116 $nonce = ForceUpdateEndpoint::nonce();
117 ?>
118 <style>
119 .wcml-esn-notice { background:#f0f6fc; border-left:4px solid #72aee6; padding:0; margin:15px 0; position:relative; }
120 .wcml-esn-notice details { padding:0; }
121 .wcml-esn-notice summary { padding:12px 40px 12px 16px; cursor:pointer; list-style:none; display:flex; align-items:center; gap:12px; }
122 .wcml-esn-notice summary::-webkit-details-marker { display:none; }
123 .wcml-esn-notice .chev { transition:transform 0.15s; color:#646970; font-size:18px; }
124 .wcml-esn-notice details[open] .chev { transform:rotate(90deg); }
125 .wcml-esn-notice .body { padding:0 16px 12px 44px; border-top:1px solid #c5d9ed40; }
126 .wcml-esn-notice .dismiss { position:absolute; top:10px; right:10px; background:none; border:none; cursor:pointer; color:#646970; }
127 </style>
128 <div class="wcml-esn-notice" data-product-id="<?php echo (int) $postId; ?>">
129 <button type="button" class="dismiss" onclick="this.closest('.wcml-esn-notice').remove()">×</button>
130 <details>
131 <summary>
132 <svg width="18" height="18" viewBox="0 0 20 20" fill="#2271b1"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
133 <strong style="flex:1">
134 <?php
135 if ( 0 === $editedCount ) {
136 esc_html_e( 'WPML synced translations for product-level changes only.', 'woocommerce-multilingual' );
137 } else {
138 echo esc_html( sprintf(
139 /* translators: 1: number of synced variations, 2: total variations in product */
140 __( 'WPML synced translations for %1$d of %2$d variations.', 'woocommerce-multilingual' ),
141 $editedCount,
142 $total
143 ) );
144 }
145 ?>
146 </strong>
147 <span class="chev"></span>
148 </summary>
149 <div class="body">
150 <p>
151 <?php
152 echo esc_html( sprintf(
153 /* translators: %d: number of variations that were not checked */
154 _n(
155 '%d other variation was not checked because its data didn\'t change here.',
156 '%d other variations were not checked because their data didn\'t change here.',
157 $skippedCount,
158 'woocommerce-multilingual'
159 ),
160 $skippedCount
161 ) );
162 ?>
163 </p>
164 <p>
165 <?php
166 printf(
167 /* translators: %s: "Force update all" label */
168 wp_kses_post( __( 'If you\'ve made changes <strong>outside this editor</strong> (for example, retranslating an attribute term), use <em>%s</em> below to make sure every translation is current.', 'woocommerce-multilingual' ) ),
169 esc_html__( 'Force update all', 'woocommerce-multilingual' )
170 );
171 ?>
172 </p>
173 <p>
174 <button type="button" class="button" id="wcml-esn-force-update">
175 <?php esc_html_e( 'Force update all variation translations', 'woocommerce-multilingual' ); ?>
176 </button>
177 </p>
178 <p style="margin-top:8px;color:#3c434a;">
179 <?php
180 printf(
181 /* translators: %s: link to the WCML Settings page */
182 wp_kses_post( __( 'You can switch to <em>"Always check all variations"</em> on the %s.', 'woocommerce-multilingual' ) ),
183 sprintf(
184 '<a href="%s" target="_blank" rel="noopener">%s<span class="screen-reader-text"> %s</span>&nbsp;<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;"><path d="M3.5 1.5v1H8.79L1.65 9.64l.71.71L9.5 3.21V8.5h1V1.5h-7z"/></svg></a>',
185 esc_url( admin_url( 'admin.php?page=wpml-wcml&tab=settings' ) ),
186 esc_html__( 'WCML Settings page', 'woocommerce-multilingual' ),
187 esc_html__( '(opens in a new tab)', 'woocommerce-multilingual' )
188 )
189 );
190 ?>
191 </p>
192 <p id="wcml-esn-force-update-result" style="display:none;color:#00a32a"></p>
193 </div>
194 </details>
195 </div>
196 <script>
197 (function () {
198 var btn = document.getElementById('wcml-esn-force-update');
199 if (!btn) return;
200 btn.addEventListener('click', function () {
201 btn.textContent = '<?php echo esc_js( __( 'Syncing all variations…', 'woocommerce-multilingual' ) ); ?>';
202 btn.disabled = true;
203 var data = new FormData();
204 data.append('action', '<?php echo esc_js( ForceUpdateEndpoint::ACTION ); ?>');
205 data.append('product_id', '<?php echo (int) $postId; ?>');
206 data.append('_wpnonce', '<?php echo esc_js( $nonce ); ?>');
207 fetch(ajaxurl, { method: 'POST', body: data, credentials: 'same-origin' })
208 .then(function (r) { return r.json(); })
209 .then(function (j) {
210 var out = document.getElementById('wcml-esn-force-update-result');
211 if (j && j.success) {
212 btn.textContent = '<?php echo esc_js( __( 'Done all variations synced', 'woocommerce-multilingual' ) ); ?>';
213 if (out) {
214 out.style.display = 'block';
215 out.textContent = '<?php echo esc_js( sprintf( __( 'Sync completed for product #%d.', 'woocommerce-multilingual' ), $postId ) ); ?>';
216 }
217 } else {
218 btn.textContent = '<?php echo esc_js( __( 'Failed — see console', 'woocommerce-multilingual' ) ); ?>';
219 console.error(j);
220 }
221 })
222 .catch(function (e) { console.error(e); btn.textContent = '<?php echo esc_js( __( 'Error — ', 'woocommerce-multilingual' ) ); ?>' + e; })
223 .finally(function () { btn.disabled = false; });
224 });
225 })();
226 </script>
227 <?php
228 delete_transient( self::SYNC_SUMMARY_TRANSIENT_PREFIX . $postId );
229 }
230
231 public function maybePersistSwitchHint() {
232 if ( $this->isSwitchHintDismissed() ) {
233 return;
234 }
235 if ( ! $this->isProductSaveRequest() ) {
236 return;
237 }
238
239 $productId = $this->getRequestProductId();
240
241 if ( ! $productId || get_post_type( $productId ) !== 'product' ) {
242 return;
243 }
244
245 if ( EditorChangeTracker::hasAnyVariationActivity( $productId ) ) {
246 return;
247 }
248
249 $variationCount = (int) count( get_posts( [
250 'post_type' => 'product_variation',
251 'post_parent' => $productId,
252 'fields' => 'ids',
253 'post_status' => 'any',
254 'numberposts' => -1,
255 ] ) );
256
257 if ( $variationCount < 1 ) {
258 return;
259 }
260
261 $trid = $this->wpdb->get_var( $this->wpdb->prepare(
262 "SELECT trid FROM {$this->wpdb->prefix}icl_translations WHERE element_id = %d AND element_type = %s",
263 $productId, 'post_product'
264 ) );
265
266 $translationCount = $trid ? (int) $this->wpdb->get_var( $this->wpdb->prepare(
267 "SELECT COUNT(*) FROM {$this->wpdb->prefix}icl_translations WHERE trid = %d AND element_id != %d AND element_type = %s",
268 $trid, $productId, 'post_product'
269 ) ) : 0;
270
271 if ( $translationCount < 1 ) {
272 return;
273 }
274
275 $threshold = (int) apply_filters( 'wcml_editor_scoped_heavy_load_threshold', self::SWITCH_HINT_ITERATION_THRESHOLD );
276
277 if ( ( $variationCount * $translationCount ) < $threshold ) {
278 return;
279 }
280
281 set_transient( self::SWITCH_HINT_TRANSIENT_PREFIX . $productId, [
282 'time' => time(),
283 'variation_count' => $variationCount,
284 'translation_count' => $translationCount,
285 ], self::TRANSIENT_TTL );
286 }
287
288 public function maybeRenderSwitchHint( $post = null ) {
289 if ( $this->isSwitchHintDismissed() ) {
290 return;
291 }
292
293 $postId = $this->getEditedPostId( $post );
294
295 if ( ! $postId || get_post_type( $postId ) !== 'product' ) {
296 return;
297 }
298
299 $hint = get_transient( self::SWITCH_HINT_TRANSIENT_PREFIX . $postId );
300
301 if ( ! is_array( $hint ) ) {
302 return;
303 }
304
305 $variations = (int) ( $hint['variation_count'] ?? 0 );
306 $translations = (int) ( $hint['translation_count'] ?? 0 );
307 $dismissUrl = wp_nonce_url(
308 add_query_arg( [
309 'wcml_esn_dismiss' => 'heavy_load',
310 'post' => $postId,
311 'action' => 'edit',
312 ], admin_url( 'post.php' ) ),
313 'wcml_esn_dismiss_heavy_load'
314 );
315
316 $settingsUrl = admin_url( 'admin.php?page=wpml-wcml&tab=settings' );
317 ?>
318 <style>
319 .wcml-esn-notice-a { background:#fcf9e8; border-left:4px solid #dba617; padding:12px 40px 12px 16px; margin:15px 0; position:relative; }
320 .wcml-esn-notice-a .dismiss-x { position:absolute; top:8px; right:10px; background:none; border:none; cursor:pointer; color:#646970; font-size:18px; line-height:1; }
321 .wcml-esn-notice-a p { margin:6px 0; }
322 .wcml-esn-notice-a .lede { font-weight:600; }
323 .wcml-esn-notice-a .meta { color:#3c434a; }
324 .wcml-esn-notice-a .actions { color:#646970; font-size:13px; }
325 </style>
326 <div class="wcml-esn-notice-a" data-product-id="<?php echo (int) $postId; ?>">
327 <button type="button" class="dismiss-x" onclick="this.closest('.wcml-esn-notice-a').remove()"
328 aria-label="<?php esc_attr_e( 'Dismiss', 'woocommerce-multilingual' ); ?>">×</button>
329 <p class="lede">
330 <?php
331 echo esc_html( sprintf(
332 /* translators: 1: number of variations checked, 2: number of translation languages */
333 _n(
334 'WPML verified %1$d variation × %2$d language on this save. None of them had changes that needed syncing.',
335 'WPML verified %1$d variations × %2$d languages on this save. None of them had changes that needed syncing.',
336 $variations,
337 'woocommerce-multilingual'
338 ),
339 $variations,
340 $translations
341 ) );
342 ?>
343 </p>
344 <p class="meta">
345 <?php
346 echo wp_kses_post( __( 'That\'s wasted work. Switching to <em>"Only sync variations edited in the editor"</em> would make saves like this much faster — WPML will only sync the variations you actually changed and tell you what was skipped.', 'woocommerce-multilingual' ) );
347 ?>
348 </p>
349 <p class="actions">
350 <?php
351 printf(
352 /* translators: %s: link to the WCML Settings page */
353 wp_kses_post( __( 'You can switch on the %s.', 'woocommerce-multilingual' ) ),
354 sprintf(
355 '<a href="%s" target="_blank" rel="noopener">%s<span class="screen-reader-text"> %s</span>&nbsp;<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;"><path d="M3.5 1.5v1H8.79L1.65 9.64l.71.71L9.5 3.21V8.5h1V1.5h-7z"/></svg></a>',
356 esc_url( $settingsUrl ),
357 esc_html__( 'WCML Settings page', 'woocommerce-multilingual' ),
358 esc_html__( '(opens in a new tab)', 'woocommerce-multilingual' )
359 )
360 );
361 ?>
362 <span style="margin:0 8px;color:#dcdcde;">|</span>
363 <a href="<?php echo esc_url( $dismissUrl ); ?>" style="color:#646970;">
364 <?php esc_html_e( 'Don\'t show this again on this site', 'woocommerce-multilingual' ); ?>
365 </a>
366 </p>
367 </div>
368 <?php
369 delete_transient( self::SWITCH_HINT_TRANSIENT_PREFIX . $postId );
370 }
371
372 public function maybeHandleSwitchHintDismiss() {
373 if ( ! isset( $_GET['wcml_esn_dismiss'] ) || 'heavy_load' !== $_GET['wcml_esn_dismiss'] ) {
374 return;
375 }
376
377 $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
378 if ( ! wp_verify_nonce( $nonce, 'wcml_esn_dismiss_heavy_load' ) ) {
379 return;
380 }
381
382 if ( ! current_user_can( 'manage_options' ) ) {
383 return;
384 }
385 $settings = $this->woocommerce_wpml->get_settings();
386 $settings[ self::SWITCH_HINT_DISMISS_KEY ] = '1';
387 $this->woocommerce_wpml->update_settings( $settings );
388
389 wp_safe_redirect( remove_query_arg( [ 'wcml_esn_dismiss', '_wpnonce' ] ) );
390 exit;
391 }
392
393 private function getRequestProductId(): int {
394 return isset( $_REQUEST['post_ID'] ) ? (int) $_REQUEST['post_ID']
395 : ( isset( $_REQUEST['product_id'] ) ? (int) $_REQUEST['product_id'] : 0 );
396 }
397
398 private function getEditedPostId( $post ): int {
399 return $post
400 ? (int) $post->ID
401 : ( isset( $_GET['post'] ) ? (int) $_GET['post'] : 0 );
402 }
403
404 private function isProductSaveRequest(): bool {
405 $pagenow = isset( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : '';
406 $action = isset( $_REQUEST['action'] ) ? (string) $_REQUEST['action'] : '';
407
408 $isSavingPostFromEditor = 'post.php' === $pagenow && 'editpost' === $action;
409 $isSavingVariationsFromEditor = 'woocommerce_save_variations' === $action;
410
411 return $isSavingPostFromEditor || $isSavingVariationsFromEditor;
412 }
413
414 private function isSwitchHintDismissed(): bool {
415 $settings = $this->woocommerce_wpml->get_settings();
416 return ! empty( $settings[ self::SWITCH_HINT_DISMISS_KEY ] );
417 }
418 }