PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / full-site-import / Utils / SessionData.php

SessionData.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/full-site-import/Utils/SessionData.php

754 lines 24.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Modules\FullSiteImport\Utils;
4
5 use Templately\Modules\FullSiteImport\FullSiteImport;
6
7 /**
8 * SessionData - Centralized session data management
9 *
10 * Provides path-based access to session data without recursive merging.
11 * Replaces the problematic recursive_wp_parse_args pattern.
12 */
13 class SessionData {
14
15 /**
16 * Get individual session option key
17 *
18 * @param string $session_id The session ID
19 * @return string The option key for this session
20 */
21 private static function get_session_option_key($session_id) {
22 return 'templately_session_' . $session_id;
23 }
24
25 /**
26 * Get all session data for a specific session
27 *
28 * @param string $session_id The session ID
29 * @return array The session data
30 */
31 public static function get_data($session_id): array {
32 if (empty($session_id)) {
33 return [];
34 }
35
36 // First try new individual option
37 $option_key = self::get_session_option_key($session_id);
38 $data = get_option($option_key, null);
39
40 if (is_array($data)) {
41 return $data;
42 }
43
44 // Fallback to legacy option
45 $legacy_data = get_option(FullSiteImport::SESSION_OPTION_KEY, []);
46 if (isset($legacy_data[$session_id]) && is_array($legacy_data[$session_id])) {
47 return $legacy_data[$session_id];
48 }
49
50 return [];
51 }
52
53 /**
54 * Whether this stored session's import already finished SUCCESSFULLY.
55 *
56 * The resume-replay guard (spec 042 FR-032 defense-in-depth behind the
57 * client-side fix): a finished session's `progress.*` markers make every
58 * runner skip, so "resuming" it instantly streams the OLD import's results
59 * as a fresh 'complete'. A 'failed' status is NOT completed — retry
60 * legitimately re-attaches to a failed session to skip finished steps, and
61 * an in-progress session (no status yet) is the LiteSpeed-resume case.
62 *
63 * @param string $session_id The session ID
64 * @return bool
65 */
66 public static function is_completed($session_id): bool {
67 $data = self::get_data($session_id);
68
69 return isset($data['is_import_status_handled']) && 'success' === $data['is_import_status_handled'];
70 }
71
72 /**
73 * Save full session data (overwrites existing)
74 * Use this when you need to save the entire session data object
75 *
76 * @param string $session_id The session ID
77 * @param array $data The complete session data
78 * @return bool Success status
79 */
80 public static function save($session_id, $data): bool {
81 if (empty($session_id) || !is_array($data)) {
82 return false;
83 }
84
85 $option_key = self::get_session_option_key($session_id);
86
87 // Add timestamp for expiry tracking
88 $data['_updated_at'] = time();
89
90 // Write to individual option (autoload = no for memory efficiency)
91 return update_option($option_key, $data, false);
92 }
93
94 /**
95 * Get a value at a specific path from session data
96 * Uses dot notation for nested access: "loop.progress.ClassName"
97 *
98 * @param string $session_id The session ID
99 * @param string $path Dot-notation path to the value
100 * @param mixed $default Default value if path doesn't exist
101 * @return mixed The value at the path or default
102 */
103 public static function get($session_id, $path, $default = null) {
104 $data = self::get_data($session_id);
105 $keys = explode('.', $path);
106
107 foreach ($keys as $key) {
108 if (!is_array($data) || !isset($data[$key])) {
109 return $default;
110 }
111 $data = $data[$key];
112 }
113
114 return $data;
115 }
116
117 /**
118 * Set a value at a specific path in session data (no merging)
119 * Uses dot notation for nested access: "loop.progress.ClassName"
120 *
121 * @param string $session_id The session ID
122 * @param string $path Dot-notation path to set
123 * @param mixed $value The value to set
124 * @return bool Success status
125 */
126 public static function set($session_id, $path, $value): bool {
127 if (empty($session_id)) {
128 return false;
129 }
130
131 $data = self::get_data($session_id);
132 $keys = explode('.', $path);
133 $current = &$data;
134
135 // Navigate to the target location
136 foreach ($keys as $i => $key) {
137 if ($i === count($keys) - 1) {
138 // Last key - set the value
139 $current[$key] = $value;
140 } else {
141 // Intermediate key - ensure it's an array
142 if (!isset($current[$key]) || !is_array($current[$key])) {
143 $current[$key] = [];
144 }
145 $current = &$current[$key];
146 }
147 }
148
149 // Add timestamp for expiry tracking
150 $data['_updated_at'] = time();
151
152 // Write to individual option (autoload = no for memory efficiency)
153 $option_key = self::get_session_option_key($session_id);
154 return update_option($option_key, $data, false);
155 }
156
157 /**
158 * Append a value to an array at a specific path
159 *
160 * @param string $session_id The session ID
161 * @param string $path Dot-notation path to the array
162 * @param mixed $value The value to append
163 * @return bool Success status
164 */
165 public static function append($session_id, $path, $value): bool {
166 $current = self::get($session_id, $path, []);
167
168 if (!is_array($current)) {
169 $current = [];
170 }
171
172 $current[] = $value;
173 return self::set($session_id, $path, $current);
174 }
175
176 /**
177 * Delete session data
178 *
179 * @param string $session_id The session ID
180 * @return bool Success status
181 */
182 /**
183 * Run-lock option key — one import() execution per session at a time.
184 */
185 private static function get_run_lock_key($session_id) {
186 return 'templately_session_lock_' . $session_id;
187 }
188
189 /**
190 * Acquire the per-session run lock.
191 *
192 * The client watchdog reconnects while the abandoned server request is
193 * STILL executing (an import request survives its client going away and
194 * runs to the end of its chunk, on every server) — without a lock, two
195 * import() executions interleave this class's non-atomic read-modify-write
196 * and duplicate inserts.
197 *
198 * @param string $session_id
199 * @param int $stale_after Seconds after which a lock whose holder died
200 * without its shutdown release is taken over.
201 * @return string|null Owner token, or null when a LIVE execution holds it.
202 */
203 public static function acquire_run_lock($session_id, $stale_after = 180) {
204 global $wpdb;
205
206 $key = self::get_run_lock_key($session_id);
207 $token = uniqid('', true);
208 $raw = maybe_serialize(['owner' => $token, 'heartbeat' => time()]);
209
210 // A plain INSERT, straight at the table — NOT add_option(). This used to be
211 // add_option(), described here as "the atomic primitive: it INSERTs and fails
212 // on a duplicate". It does not. Core runs
213 // `INSERT … ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)`, so a
214 // second caller arriving between the first caller's existence check and its
215 // write OVERWRITES the first caller's token and is told it succeeded, because a
216 // changed row reports affected-rows 2. Both then run import() on one session.
217 // Observed 2026-09-18: the content step re-ran on top of the finalizer, imported
218 // the navigation posts twice, and the two executions' interleaved session
219 // writes lost the old→new id map — the header and footer kept the demo site's
220 // navigation ids and the imported site had no menus.
221 //
222 // INSERT IGNORE relies on the unique index on option_name: exactly one caller
223 // gets affected-rows 1, whatever the timing.
224 $acquired = $wpdb->query( $wpdb->prepare(
225 "INSERT IGNORE INTO `{$wpdb->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')",
226 $key,
227 $raw
228 ) );
229 self::forget_cached_lock($key);
230
231 if (1 === (int) $acquired) {
232 return $token;
233 }
234
235 $existing_raw = $wpdb->get_var( $wpdb->prepare(
236 "SELECT `option_value` FROM `{$wpdb->options}` WHERE `option_name` = %s LIMIT 1",
237 $key
238 ) );
239
240 if (null === $existing_raw) {
241 // Released between our INSERT and this read. The next reconnect takes it.
242 return null;
243 }
244
245 $existing = maybe_unserialize($existing_raw);
246 $heartbeat = is_array($existing) ? (int) ($existing['heartbeat'] ?? 0) : 0;
247
248 if ($heartbeat >= time() - $stale_after) {
249 return null;
250 }
251
252 // Stale takeover — the holder hard-died (its shutdown never ran). Compare-and-
253 // swap on the exact value we judged stale, so two takeovers racing here resolve
254 // to ONE winner instead of both proceeding (which the add_option() version
255 // accepted as "the pre-lock status quo, not worse").
256 $taken = $wpdb->query( $wpdb->prepare(
257 "UPDATE `{$wpdb->options}` SET `option_value` = %s WHERE `option_name` = %s AND `option_value` = %s",
258 $raw,
259 $key,
260 $existing_raw
261 ) );
262 self::forget_cached_lock($key);
263
264 return 1 === (int) $taken ? $token : null;
265 }
266
267 /**
268 * Release the run lock — only by its owner, so a superseded request's
269 * shutdown can never delete the lock a newer execution holds.
270 *
271 * The ownership test and the delete are ONE statement: a read followed by a
272 * delete would let a stale takeover land in between and have its fresh lock
273 * deleted by the request it had just superseded.
274 */
275 public static function release_run_lock($session_id, $token): void {
276 global $wpdb;
277
278 if (empty($session_id) || empty($token)) {
279 return;
280 }
281
282 $key = self::get_run_lock_key($session_id);
283
284 // The token sits inside a serialized array, and uniqid('', true) is
285 // [0-9a-f.] only, so it is safe inside LIKE once esc_like()d and cannot
286 // match another token as a substring (they are all the same length).
287 $wpdb->query( $wpdb->prepare(
288 "DELETE FROM `{$wpdb->options}` WHERE `option_name` = %s AND `option_value` LIKE %s",
289 $key,
290 '%' . $wpdb->esc_like('"' . $token . '"') . '%'
291 ) );
292 self::forget_cached_lock($key);
293 }
294
295 /**
296 * Drop what WordPress has cached about the lock row.
297 *
298 * The lock is written with direct queries, so the options cache never hears about
299 * it. Left alone, a request that read the key BEFORE it existed keeps it in the
300 * `notoptions` list and every later get_option()/delete_option() in that request
301 * answers "no such option" about a row that is in the table.
302 */
303 private static function forget_cached_lock($key): void {
304 wp_cache_delete($key, 'options');
305
306 $notoptions = wp_cache_get('notoptions', 'options');
307 if (is_array($notoptions) && isset($notoptions[$key])) {
308 unset($notoptions[$key]);
309 wp_cache_set('notoptions', $notoptions, 'options');
310 }
311 }
312
313 public static function delete($session_id): bool {
314 if (empty($session_id)) {
315 return false;
316 }
317
318 $deleted = false;
319
320 // Delete individual option (+ any leftover run lock)
321 delete_option(self::get_run_lock_key($session_id));
322 $option_key = self::get_session_option_key($session_id);
323 if (get_option($option_key) !== false) {
324 $deleted = delete_option($option_key);
325 }
326
327 // Also remove from legacy option if exists
328 $legacy_data = get_option(FullSiteImport::SESSION_OPTION_KEY, []);
329 if (isset($legacy_data[$session_id])) {
330 unset($legacy_data[$session_id]);
331 update_option(FullSiteImport::SESSION_OPTION_KEY, $legacy_data);
332 $deleted = true;
333 }
334
335 return $deleted;
336 }
337
338 /**
339 * Build a scoped-storage identifier (`Class::method[::unique_id]`) for a frame
340 * in the current call stack. Migrated from Loop::CallingFunctionName().
341 *
342 * 037 US2 (FR-004): the `Loop` trait now calls this ONCE per loop()/wrapper
343 * entry with a FIXED, local `$level` measured from a DIRECT call here (e.g.
344 * `$level = 2` resolves the direct caller of the function that called this),
345 * then threads the result down. `$level` is therefore a stable, local offset —
346 * it no longer tracks how deep the import pipeline or the trait's private
347 * helper chain happens to be, so it never needs "adjusting for call depth".
348 *
349 * @param string|null $unique_id Optional unique identifier to append
350 * @param bool $function Include the ::method segment (false = class only)
351 * @param bool $line Include line number
352 * @param int $level Backtrace frame to read, counted from this method
353 * @return string The calling identifier
354 */
355 public static function get_calling_identifier($unique_id = null, $function = true, $line = false, $level = 3): string {
356 $return = 'unknown';
357 $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, ($level + 1));
358
359 // Check if the trace has at least the required elements
360 if (isset($trace[$level])) {
361 $final_call = $trace[$level];
362 $return = '';
363
364 if (isset($final_call['object'])) {
365 $return .= get_class($final_call['object']);
366 } elseif (isset($final_call['class'])) {
367 $return .= $final_call['class'];
368 }
369
370 if ($function && isset($final_call['function'])) {
371 $return .= ($return ? '::' : '') . $final_call['function'];
372 }
373
374 // Line number should be from previous level (where the function was called FROM)
375 if ($line && isset($trace[$level - 1]['line'])) {
376 $return .= ($return ? '::' : '') . $trace[$level - 1]['line'];
377 }
378
379 if (!empty($unique_id)) {
380 $return .= ($return ? '::' : '') . $unique_id;
381 }
382
383 if (!$return) {
384 $return = 'unknown';
385 }
386 }
387
388 return $return;
389 }
390
391 // ============================================================================
392 // Loop Helper Functions
393 // ============================================================================
394
395 /**
396 * Check if a key has been processed in the loop
397 *
398 * @param string $session_id The session ID
399 * @param string $calling_class The calling class identifier
400 * @param mixed $key The item key to check
401 * @return bool True if processed
402 */
403 public static function is_key_processed($session_id, $calling_class, $key) {
404 $progress = self::get($session_id, "loop.progress.{$calling_class}", []);
405 return in_array("key_{$key}", $progress, true);
406 }
407
408 /**
409 * Mark a key as processed in the loop
410 *
411 * @param string $session_id The session ID
412 * @param string $calling_class The calling class identifier
413 * @param mixed $key The item key to mark
414 * @return bool Success status
415 */
416 public static function mark_key_processed($session_id, $calling_class, $key) {
417 return self::append($session_id, "loop.progress.{$calling_class}", "key_{$key}");
418 }
419
420 /**
421 * Set loop result for a calling context
422 *
423 * @param string $session_id The session ID
424 * @param string $calling_class The calling class identifier
425 * @param array $result The result data
426 * @return bool Success status
427 */
428 public static function set_loop_result($session_id, $calling_class, $result) {
429 return self::set($session_id, "loop.result.{$calling_class}", $result);
430 }
431
432 /**
433 * Get loop result for a calling context
434 *
435 * @param string $session_id The session ID
436 * @param string $calling_class The calling class identifier
437 * @param array $default Default value if not found
438 * @return array The result data
439 */
440 public static function get_loop_result($session_id, $calling_class, $default = []) {
441 return self::get($session_id, "loop.result.{$calling_class}", $default);
442 }
443
444 // ============================================================================
445 // FullSiteImport Step Helper Functions
446 // ============================================================================
447
448 /**
449 * Check if an import step has been completed
450 *
451 * @param string $session_id The session ID
452 * @param string $step_name The step name (e.g., 'download_zip')
453 * @return bool True if completed
454 */
455 public static function is_step_complete($session_id, $step_name) {
456 return (bool) self::get($session_id, "progress.{$step_name}", false);
457 }
458
459 /**
460 * Mark an import step as complete
461 *
462 * @param string $session_id The session ID
463 * @param string $step_name The step name (e.g., 'download_zip')
464 * @return bool Success status
465 */
466 public static function mark_step_complete($session_id, $step_name) {
467 return self::set($session_id, "progress.{$step_name}", true);
468 }
469
470 // ============================================================================
471 // Skip-on-Error Tracking Functions
472 // ============================================================================
473
474 /**
475 * Increment error attempts for a specific loop item
476 *
477 * @param string $session_id The session ID
478 * @param string $calling_class The calling class identifier
479 * @param mixed $key The item key
480 * @return int The new error attempt count
481 */
482 public static function increment_error_attempts($session_id, $calling_class, $key): int {
483 $current = self::get($session_id, "loop.error_attempts.{$calling_class}.key_{$key}", 0);
484 $new_count = $current + 1;
485 self::set($session_id, "loop.error_attempts.{$calling_class}.key_{$key}", $new_count);
486 return $new_count;
487 }
488
489 /**
490 * Get error attempt count for a specific loop item
491 *
492 * @param string $session_id The session ID
493 * @param string $calling_class The calling class identifier
494 * @param mixed $key The item key
495 * @return int The error attempt count
496 */
497 public static function get_error_attempts($session_id, $calling_class, $key): int {
498 return (int) self::get($session_id, "loop.error_attempts.{$calling_class}.key_{$key}", 0);
499 }
500
501 /**
502 * Reset error attempts for a specific loop item
503 *
504 * @param string $session_id The session ID
505 * @param string $calling_class The calling class identifier
506 * @param mixed $key The item key
507 * @return bool Success status
508 */
509 public static function reset_error_attempts($session_id, $calling_class, $key): bool {
510 return self::set($session_id, "loop.error_attempts.{$calling_class}.key_{$key}", 0);
511 }
512
513 /**
514 * Mark a loop item as skipped
515 *
516 * @param string $session_id The session ID
517 * @param string $calling_class The calling class identifier
518 * @param mixed $key The item key
519 * @param string $reason The reason for skipping
520 * @return bool Success status
521 */
522 public static function mark_key_skipped($session_id, $calling_class, $key, $reason = ''): bool {
523 $skip_data = [
524 'class' => $calling_class,
525 'key' => $key,
526 'reason' => $reason,
527 'timestamp' => time(),
528 ];
529 return self::append($session_id, "loop.skipped_items", $skip_data);
530 }
531
532 /**
533 * Check if a loop item has been skipped
534 *
535 * @param string $session_id The session ID
536 * @param string $calling_class The calling class identifier
537 * @param mixed $key The item key
538 * @return bool True if skipped
539 */
540 public static function is_key_skipped($session_id, $calling_class, $key): bool {
541 $skipped_items = self::get($session_id, "loop.skipped_items", []);
542 foreach ($skipped_items as $item) {
543 if ($item['class'] === $calling_class && $item['key'] === $key) {
544 return true;
545 }
546 }
547 return false;
548 }
549
550 /**
551 * Get all skipped items for a session
552 *
553 * @param string $session_id The session ID
554 * @return array Array of skipped items
555 */
556 public static function get_skipped_items($session_id): array {
557 return self::get($session_id, "loop.skipped_items", []);
558 }
559
560 /**
561 * Increment consecutive skip counter
562 *
563 * @param string $session_id The session ID
564 * @return int The new consecutive skip count
565 */
566 public static function increment_consecutive_skips($session_id): int {
567 $current = self::get($session_id, "loop.consecutive_skips", 0);
568 $new_count = $current + 1;
569 self::set($session_id, "loop.consecutive_skips", $new_count);
570 return $new_count;
571 }
572
573 /**
574 * Reset consecutive skip counter
575 *
576 * @param string $session_id The session ID
577 * @return bool Success status
578 */
579 public static function reset_consecutive_skips($session_id): bool {
580 return self::set($session_id, "loop.consecutive_skips", 0);
581 }
582
583 /**
584 * Get consecutive skip count
585 *
586 * @param string $session_id The session ID
587 * @return int The consecutive skip count
588 */
589 public static function get_consecutive_skips($session_id): int {
590 return (int) self::get($session_id, "loop.consecutive_skips", 0);
591 }
592
593 // ============================================================================
594 // Utility Functions
595 // ============================================================================
596
597 /**
598 * Get session ID from request
599 *
600 * @return string|null The session ID or null
601 */
602 public static function get_session_id() {
603 $session_id = null;
604 if (!empty($_REQUEST['session_id'])) {
605 $session_id = sanitize_text_field($_REQUEST['session_id']);
606 }
607 return $session_id;
608 }
609
610 // ============================================================================
611 // Cleanup Functions
612 // ============================================================================
613
614 /**
615 * Get all session data - ONLY use for cleanup operations
616 * This queries both new individual options and legacy option
617 *
618 * @return array Array of session_id => session_data
619 */
620 public static function get_all_data(): array {
621 global $wpdb;
622
623 $all_sessions = [];
624
625 // Get sessions from new individual options
626 $option_prefix = 'templately_session_';
627 $sql = $wpdb->prepare(
628 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
629 $wpdb->esc_like($option_prefix) . '%'
630 );
631 $results = $wpdb->get_results($sql);
632
633 if ($results) {
634 foreach ($results as $row) {
635 $session_id = str_replace($option_prefix, '', $row->option_name);
636 $session_data = maybe_unserialize($row->option_value);
637 if (is_array($session_data)) {
638 $all_sessions[$session_id] = $session_data;
639 }
640 }
641 }
642
643 // Also get legacy data from single option for backward compatibility
644 $legacy_data = get_option(FullSiteImport::SESSION_OPTION_KEY, []);
645 if (is_array($legacy_data) && !empty($legacy_data)) {
646 foreach ($legacy_data as $session_id => $session_data) {
647 // Don't overwrite if already exists in new format
648 if (!isset($all_sessions[$session_id]) && is_array($session_data)) {
649 $all_sessions[$session_id] = $session_data;
650 }
651 }
652 }
653
654 return $all_sessions;
655 }
656
657 /**
658 * Clean up expired sessions based on time threshold
659 * Removes sessions older than the specified number of days
660 *
661 * @param int $max_age_days Maximum age in days (default 7)
662 * @return array Array with 'removed_count' and 'removed_ids'
663 */
664 public static function cleanup_expired($max_age_days = 7): array {
665 $all_session_data = self::get_all_data();
666 $removed_session_ids = [];
667 $threshold_time = time() - ($max_age_days * DAY_IN_SECONDS);
668
669 foreach ($all_session_data as $session_id => $session_data) {
670 // Active-session exemption (FR-006): never clean a session with a live
671 // import (recently touched or an in-progress, not-yet-complete step).
672 if (self::is_session_active($session_data, $threshold_time)) {
673 continue;
674 }
675 self::delete($session_id);
676 $removed_session_ids[] = $session_id;
677 }
678
679 // Sweep orphaned AI process rows (+ their page sub-keys) past retention,
680 // exempting any still linked to an active session (FR-006).
681 $removed_process_ids = AIUtils::cleanup_expired_processes($threshold_time);
682
683 return [
684 'removed_count' => count($removed_session_ids),
685 'removed_ids' => $removed_session_ids,
686 'removed_process_ids' => $removed_process_ids,
687 ];
688 }
689
690 /**
691 * Whether a session is still in use, by id.
692 *
693 * The public form of the activity predicate. The shared cleanup service
694 * (spec 052) consumes this rather than reimplementing it: only this module
695 * knows that a session carrying in-progress markers is still running, and
696 * duplicating that judgement in the engine is how the two would drift.
697 *
698 * Callers MUST re-check immediately before removing anything — an import
699 * that starts while a sweep is walking the directory has to be exempt from
700 * that moment on, not from when the candidate list was built.
701 *
702 * @param string $session_id
703 * @param int|null $threshold_time Unix time; older than this is expired.
704 * Null uses the 7-day default.
705 * @return bool
706 */
707 public static function is_active( $session_id, $threshold_time = null ): bool {
708 if ( empty( $session_id ) ) {
709 return false;
710 }
711
712 if ( null === $threshold_time ) {
713 $threshold_time = time() - ( 7 * DAY_IN_SECONDS );
714 }
715
716 $session_data = self::get_data( $session_id );
717 if ( ! is_array( $session_data ) || empty( $session_data ) ) {
718 return false;
719 }
720
721 return self::is_session_active( $session_data, (int) $threshold_time );
722 }
723
724 /**
725 * A session is "active" (cleanup-exempt) when it was touched within the
726 * retention window, or when it carries an in-progress step that has not yet
727 * completed. Any live import bumps `_updated_at` on every write, so recency
728 * covers the common case; the progress check is a defensive belt.
729 *
730 * @param array $session_data
731 * @param int $threshold_time
732 * @return bool
733 */
734 private static function is_session_active($session_data, $threshold_time): bool {
735 $updated_at = isset($session_data['_updated_at']) ? (int) $session_data['_updated_at'] : 0;
736 if ($updated_at >= $threshold_time && $updated_at > 0) {
737 return true;
738 }
739
740 if (!empty($session_data['progress']) && is_array($session_data['progress'])) {
741 $progress = $session_data['progress'];
742 $has_complete = !empty($progress['import_complete']) || !empty($progress['completed']);
743 // In-progress markers present but no completion → keep (don't kill a
744 // running import), bounded by a 2× grace so genuinely abandoned rows
745 // still age out.
746 if (!$has_complete && $updated_at >= ($threshold_time - ($threshold_time > 0 ? DAY_IN_SECONDS : 0))) {
747 return true;
748 }
749 }
750
751 return false;
752 }
753 }
754