PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / trunk
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz vtrunk
2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / compatibility / multilingual / string-backfill.php
sureforms / inc / compatibility / multilingual Last commit date
providers 3 weeks ago multilingual-manager.php 2 months ago string-backfill.php 3 weeks ago string-collector.php 3 weeks ago string-translator.php 3 weeks ago
string-backfill.php
393 lines
1 <?php
2 /**
3 * Multilingual String Backfill.
4 *
5 * Registers String Packages for forms that already existed before the
6 * multilingual integration became active. Without this, a form's strings only
7 * reach the multilingual provider when the form is next re-saved, so existing
8 * forms stay invisible to WPML until manually opened and saved again.
9 *
10 * @package sureforms.
11 * @since 2.12.3
12 */
13
14 namespace SRFM\Inc\Compatibility\Multilingual;
15
16 use SRFM\Inc\Helper;
17 use SRFM\Inc\Traits\Get_Instance;
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit; // Exit if accessed directly.
21 }
22
23 /**
24 * String_Backfill.
25 *
26 * One-time (per {@see self::SCHEMA_VERSION}) pass that runs
27 * {@see String_Collector::collect()} for every existing form once a multilingual
28 * provider is active, so their String Packages are registered without needing a
29 * manual re-save.
30 *
31 * @since 2.12.3
32 */
33 class String_Backfill {
34 use Get_Instance;
35
36 /**
37 * Option that records the schema version the backfill last completed for.
38 *
39 * @since 2.12.3
40 */
41 public const DONE_OPTION = 'srfm_wpml_backfill_done';
42
43 /**
44 * Backfill schema version. Bump this ONLY when the set of strings registered
45 * by String_Collector changes, to force a one-time re-backfill. Deliberately
46 * NOT tied to SRFM_VER, so ordinary plugin releases don't re-enqueue a job
47 * per form on every update.
48 *
49 * @since 2.12.3
50 */
51 public const SCHEMA_VERSION = '2';
52
53 /**
54 * Option holding the in-progress run lock: [ 'schema' => string, 'started' => int ].
55 *
56 * Separate from DONE_OPTION on purpose. DONE_OPTION means "the whole pass finished";
57 * this means "a pass is currently running". Conflating them meant a run that died
58 * mid-way looked complete forever.
59 *
60 * @since 2.12.3
61 */
62 public const LOCK_OPTION = 'srfm_wpml_backfill_lock';
63
64 /**
65 * How long a run lock is honoured before it is treated as stale and reclaimed.
66 *
67 * Bounds the failure mode where a worker dies without chaining: the next admin load
68 * after this window restarts the pass, and the per-form markers make the replay cheap.
69 *
70 * @since 2.12.3
71 */
72 public const LOCK_TTL = 3600;
73
74 /**
75 * Action Scheduler hook that backfills a single form.
76 *
77 * @since 2.12.3
78 */
79 public const HOOK = 'srfm_wpml_backfill_form';
80
81 /**
82 * Action Scheduler hook that backfills one page of forms and chains the next.
83 *
84 * @since 2.12.3
85 */
86 public const HOOK_BATCH = 'srfm_wpml_backfill_batch';
87
88 /**
89 * Per-form meta key recording the schema version a form was last backfilled for.
90 *
91 * Makes the pass idempotent per form, so a replayed or partially-failed batch
92 * never re-registers packages it already handled.
93 *
94 * @since 2.12.3
95 */
96 public const FORM_MARKER = '_srfm_wpml_backfilled';
97
98 /**
99 * Forms processed per batch action.
100 *
101 * @since 2.12.3
102 */
103 private const BATCH_SIZE = 50;
104
105 /**
106 * Constructor. Schedules the backfill on admin load and handles each queued form.
107 *
108 * @since 2.12.3
109 */
110 public function __construct() {
111 add_action( 'admin_init', [ $this, 'maybe_schedule' ] );
112 add_action( self::HOOK_BATCH, [ $this, 'backfill_batch' ], 10, 1 );
113 // Void wrapper: backfill_one() returns a bool for the batch's progress accounting,
114 // and an action callback must not return a value. HOOK stays registered so any
115 // per-form actions still pending from the previous implementation drain cleanly.
116 add_action( self::HOOK, [ $this, 'handle_backfill_action' ], 10, 1 );
117 }
118
119 /**
120 * Queue a one-time backfill of all existing forms when a provider is active.
121 *
122 * Bails when the request is not a privileged admin page load, when no
123 * multilingual provider is active, when the backfill has already completed for
124 * the current schema version, or when Action Scheduler is unavailable.
125 *
126 * Enqueues ONE paginating batch action rather than one action per form. Action
127 * Scheduler's `$unique` flag matches on (status, hook, group_id) only — it does
128 * NOT consider `args` — so a per-form fan-out with `$unique = true` inserted the
129 * first form and then silently wrote 0 rows for every subsequent one, leaving
130 * exactly one form backfilled per site. A single self-chaining batch action never
131 * has more than one pending row for this hook, so uniqueness is irrelevant and
132 * the query stays bounded.
133 *
134 * @since 2.12.3
135 * @return void
136 */
137 public function maybe_schedule(): void {
138 // Only a genuine, privileged admin page load may start this. `admin_init`
139 // also fires inside admin-ajax.php, which is reachable unauthenticated, and
140 // this pass is state-changing and touches every form.
141 if ( wp_doing_ajax() || wp_doing_cron() || ! Helper::current_user_can() ) {
142 return;
143 }
144
145 if ( ! Multilingual_Manager::get_instance()->provider()->is_active() ) {
146 return;
147 }
148
149 if ( self::SCHEMA_VERSION === get_option( self::DONE_OPTION ) ) {
150 return;
151 }
152
153 if ( ! function_exists( 'as_enqueue_async_action' ) ) {
154 return;
155 }
156
157 // A run already in flight (a fresh, non-stale lock) — don't start a second chain.
158 if ( $this->is_run_in_flight() ) {
159 return;
160 }
161
162 // A stale lock left by a worker that died without chaining. Clear it so the
163 // atomic add_option() below can re-take it.
164 delete_option( self::LOCK_OPTION );
165
166 // Take the lock atomically BEFORE enqueuing. add_option() is an INSERT that returns
167 // false when the row already exists, so two concurrent admin requests can't both
168 // pass is_run_in_flight() and both start a chain — exactly one wins. Completion is
169 // NOT recorded here; that only happens when the final page comes back empty.
170 // autoload=false: this admin-only marker never needs to load on the front end.
171 $acquired = add_option(
172 self::LOCK_OPTION,
173 [
174 'schema' => self::SCHEMA_VERSION,
175 'started' => time(),
176 ],
177 '',
178 false
179 );
180
181 if ( ! $acquired ) {
182 // Another request won the race.
183 return;
184 }
185
186 // Verify the action actually got queued. as_enqueue_async_action() returns 0 when
187 // the insert wrote no rows, silently and without raising — so an unchecked call
188 // could leave the pass never started with the lock held.
189 $action_id = as_enqueue_async_action( self::HOOK_BATCH, [ 'paged' => 1 ], 'srfm' );
190
191 if ( empty( $action_id ) ) {
192 // Release immediately so the next admin load retries rather than waiting out
193 // the whole lock TTL.
194 delete_option( self::LOCK_OPTION );
195
196 /**
197 * Fires when the backfill's first batch action could not be queued.
198 *
199 * @since 2.12.3
200 * @param string $schema_version The schema version being backfilled.
201 */
202 do_action( 'srfm_wpml_backfill_enqueue_failed', self::SCHEMA_VERSION );
203 }
204 }
205
206 /**
207 * Backfill one page of forms, then chain the next page.
208 *
209 * Runs String_Collector directly per form (rather than enqueuing a child action
210 * each) and re-enqueues itself for the following page until a page comes back
211 * empty. Each form is marked with self::FORM_MARKER on success, so a batch that
212 * dies part-way can be replayed without re-registering packages it already did.
213 *
214 * @param mixed $paged 1-based page number.
215 * @since 2.12.3
216 * @return void
217 */
218 public function backfill_batch( $paged = 1 ): void {
219 // Args arrive from Action Scheduler, so the type is not guaranteed.
220 $paged = is_numeric( $paged ) ? max( 1, (int) $paged ) : 1;
221
222 // The provider can be deactivated between scheduling and execution. Abandon the
223 // run and release the lock WITHOUT recording completion, so a later admin load
224 // restarts the pass once a provider is active again.
225 if ( ! Multilingual_Manager::get_instance()->provider()->is_active() ) {
226 delete_option( self::LOCK_OPTION );
227
228 /**
229 * Fires when a backfill batch aborts because no multilingual provider is active.
230 *
231 * @since 2.12.3
232 * @param int $paged The page the run stopped on.
233 */
234 do_action( 'srfm_wpml_backfill_aborted', $paged );
235 return;
236 }
237
238 // Refresh the lock so a long multi-page run isn't reclaimed as stale mid-flight.
239 update_option(
240 self::LOCK_OPTION,
241 [
242 'schema' => self::SCHEMA_VERSION,
243 'started' => time(),
244 ],
245 false
246 );
247
248 $form_ids = get_posts(
249 [
250 'post_type' => SRFM_FORMS_POST_TYPE,
251 'post_status' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
252 'fields' => 'ids',
253 'posts_per_page' => self::BATCH_SIZE, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Bounded constant (50); batching is the point of this query.
254 'paged' => $paged,
255 'orderby' => 'ID',
256 'order' => 'ASC',
257 ]
258 );
259
260 // An empty page means every form has been walked — THIS is the only place the pass
261 // is recorded as complete, and only after all pages actually finished.
262 if ( empty( $form_ids ) || ! is_array( $form_ids ) ) {
263 update_option( self::DONE_OPTION, self::SCHEMA_VERSION, false );
264 delete_option( self::LOCK_OPTION );
265
266 /**
267 * Fires when the multilingual string backfill has completed every page.
268 *
269 * @since 2.12.3
270 * @param string $schema_version The schema version that completed.
271 */
272 do_action( 'srfm_wpml_backfill_completed', self::SCHEMA_VERSION );
273 return;
274 }
275
276 $collected = 0;
277 foreach ( $form_ids as $form_id ) {
278 if ( $this->backfill_one( (int) $form_id ) ) {
279 $collected++;
280 }
281 }
282
283 // Nothing on a non-empty page could be collected — the provider went away
284 // mid-page. Stop and release rather than chaining and marking progress.
285 if ( 0 === $collected ) {
286 delete_option( self::LOCK_OPTION );
287 do_action( 'srfm_wpml_backfill_aborted', $paged );
288 return;
289 }
290
291 if ( ! function_exists( 'as_enqueue_async_action' ) ) {
292 // Cannot chain — release the lock so the next admin load resumes. Already-marked
293 // forms are skipped, so the replay only redoes the unfinished tail.
294 delete_option( self::LOCK_OPTION );
295 return;
296 }
297
298 // One pending action at a time, so $unique is unnecessary here.
299 $next_id = as_enqueue_async_action( self::HOOK_BATCH, [ 'paged' => $paged + 1 ], 'srfm' );
300
301 if ( empty( $next_id ) ) {
302 // The chain broke. Release so a later admin load restarts; FORM_MARKER means it
303 // resumes rather than starting over.
304 delete_option( self::LOCK_OPTION );
305 do_action( 'srfm_wpml_backfill_enqueue_failed', self::SCHEMA_VERSION );
306 }
307 }
308
309 /**
310 * Action-hook entry point for a single-form backfill.
311 *
312 * Exists so backfill_one() can report success to the batch loop while the hook
313 * callback itself returns nothing.
314 *
315 * @param mixed $form_id The form post ID to backfill.
316 * @since 2.12.3
317 * @return void
318 */
319 public function handle_backfill_action( $form_id ): void {
320 $this->backfill_one( is_numeric( $form_id ) ? (int) $form_id : 0 );
321 }
322
323 /**
324 * Backfill a single form's String Package.
325 *
326 * @param int $form_id The form post ID to backfill.
327 * @since 2.12.3
328 * @since 2.12.3 Returns whether the form was (or already had been) collected, and only
329 * writes the marker on a confirmed collection.
330 * @return bool True when this form is backfilled for the current schema version.
331 */
332 public function backfill_one( int $form_id ): bool {
333 if ( $form_id <= 0 ) {
334 return false;
335 }
336
337 // Only ever touch SureForms forms. backfill_batch()'s get_posts() is already
338 // post-type-scoped, but self::HOOK stays registered for actions queued by the
339 // previous implementation and routes any numeric arg through
340 // handle_backfill_action() — so guard here too, mirroring
341 // String_Collector::on_form_delete(), so a stray do_action( self::HOOK, $id )
342 // can't write our meta onto (or run collect() against) an arbitrary post.
343 if ( SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) ) {
344 return false;
345 }
346
347 // Idempotency guard: a replayed batch must not re-register packages for forms
348 // it already processed under this schema version.
349 if ( self::SCHEMA_VERSION === get_post_meta( $form_id, self::FORM_MARKER, true ) ) {
350 return true;
351 }
352
353 // collect() returns false when the provider is inactive, in which case it did
354 // nothing. Marking the form regardless would permanently skip it — the exact
355 // failure this backfill exists to prevent — so the marker is only written on a
356 // confirmed collection.
357 if ( ! String_Collector::get_instance()->collect( $form_id ) ) {
358 return false;
359 }
360
361 update_post_meta( $form_id, self::FORM_MARKER, self::SCHEMA_VERSION );
362
363 return true;
364 }
365 /**
366 * Whether a backfill run is currently in flight for this schema version.
367 *
368 * A lock older than self::LOCK_TTL is treated as stale and ignored, which is how an
369 * interrupted run (worker died before chaining the next page, host restarted, action
370 * purged) recovers instead of stalling forever. Replaying is cheap because
371 * self::FORM_MARKER skips forms already done.
372 *
373 * @since 2.12.3
374 * @return bool
375 */
376 private function is_run_in_flight(): bool {
377 $lock = get_option( self::LOCK_OPTION );
378
379 if ( ! is_array( $lock ) ) {
380 return false;
381 }
382
383 // A lock from an older schema version is irrelevant to this pass.
384 if ( self::SCHEMA_VERSION !== ( $lock['schema'] ?? '' ) ) {
385 return false;
386 }
387
388 $started = isset( $lock['started'] ) && is_numeric( $lock['started'] ) ? (int) $lock['started'] : 0;
389
390 return time() - $started < self::LOCK_TTL;
391 }
392 }
393