| 1 |
<?php |
| 2 |
/** |
| 3 |
* Batched migration of the legacy variation gallery meta into WooCommerce's |
| 4 |
* native per-variation gallery. |
| 5 |
* |
| 6 |
* @package RadiusTheme\SB |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace RadiusTheme\SB\Modules\VariationGallery; |
| 10 |
|
| 11 |
use RadiusTheme\SB\Traits\SingletonTrait; |
| 12 |
|
| 13 |
defined( 'ABSPATH' ) || exit(); |
| 14 |
|
| 15 |
/** |
| 16 |
* Copies `rtsb_vg_images` into WooCommerce's `_product_image_gallery` variation meta |
| 17 |
* so the native gallery becomes the single source of truth once WooCommerce provides |
| 18 |
* it. |
| 19 |
* |
| 20 |
* The legacy meta is deliberately left in place: it keeps CSV export/import and any |
| 21 |
* third-party reader working, and lets a store roll back to an older WooCommerce |
| 22 |
* without losing data. A sentinel meta marks each processed variation instead, so the |
| 23 |
* legacy value is never used to resurrect images the merchant has since removed. |
| 24 |
* |
| 25 |
* The work runs through Action Scheduler (shipped with WooCommerce, so no new |
| 26 |
* dependency) in small batches: it survives the admin closing the page or logging out, |
| 27 |
* never blocks a request, and resumes safely after an interrupted batch. |
| 28 |
*/ |
| 29 |
class GalleryMigration { |
| 30 |
|
| 31 |
/** |
| 32 |
* Singleton Trait. |
| 33 |
*/ |
| 34 |
use SingletonTrait; |
| 35 |
|
| 36 |
/** |
| 37 |
* Option recording when the migration finished. |
| 38 |
* |
| 39 |
* @var string |
| 40 |
*/ |
| 41 |
const COMPLETED_OPTION = 'rtsb_vg_native_gallery_migration_completed_at'; |
| 42 |
|
| 43 |
/** |
| 44 |
* Action Scheduler hook running a single batch. |
| 45 |
* |
| 46 |
* @var string |
| 47 |
*/ |
| 48 |
const BATCH_HOOK = 'rtsb_vg_migrate_variation_gallery_batch'; |
| 49 |
|
| 50 |
/** |
| 51 |
* Action Scheduler group. |
| 52 |
* |
| 53 |
* @var string |
| 54 |
*/ |
| 55 |
const BATCH_GROUP = 'rtsb-variation-gallery'; |
| 56 |
|
| 57 |
/** |
| 58 |
* Number of variations processed per batch. |
| 59 |
* |
| 60 |
* @var int |
| 61 |
*/ |
| 62 |
const BATCH_SIZE = 250; |
| 63 |
|
| 64 |
/** |
| 65 |
* Transient guarding against two runners entering a batch at once. |
| 66 |
* |
| 67 |
* @var string |
| 68 |
*/ |
| 69 |
const LOCK_TRANSIENT = 'rtsb_vg_migration_running'; |
| 70 |
|
| 71 |
/** |
| 72 |
* Register hooks. |
| 73 |
*/ |
| 74 |
private function __construct() { |
| 75 |
add_action( self::BATCH_HOOK, [ __CLASS__, 'run_batch' ] ); |
| 76 |
// Action Scheduler only becomes available on `init`. |
| 77 |
add_action( 'init', [ $this, 'maybe_schedule' ], 21 ); |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Queue the next batch unless the migration is done or one is already queued. |
| 82 |
* |
| 83 |
* @return void |
| 84 |
*/ |
| 85 |
public function maybe_schedule() { |
| 86 |
if ( ! GalleryFns::use_native_gallery() || get_option( self::COMPLETED_OPTION ) ) { |
| 87 |
return; |
| 88 |
} |
| 89 |
|
| 90 |
$queue = self::get_queue(); |
| 91 |
|
| 92 |
if ( ! $queue || null !== $queue->get_next( self::BATCH_HOOK, [], self::BATCH_GROUP ) ) { |
| 93 |
return; |
| 94 |
} |
| 95 |
|
| 96 |
$queue->add( self::BATCH_HOOK, [], self::BATCH_GROUP ); |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Migrate one batch of variations and queue the next one when work remains. |
| 101 |
* |
| 102 |
* Idempotent by construction: the batch query only selects variations that still |
| 103 |
* lack the sentinel, so a batch that dies part-way simply leaves the remainder to |
| 104 |
* be picked up on the next run — nothing is processed twice and nothing is skipped. |
| 105 |
* |
| 106 |
* @return void |
| 107 |
*/ |
| 108 |
public static function run_batch() { |
| 109 |
if ( ! GalleryFns::use_native_gallery() || get_option( self::COMPLETED_OPTION ) ) { |
| 110 |
return; |
| 111 |
} |
| 112 |
|
| 113 |
// Two runners racing on the same batch would each write the same values, but |
| 114 |
// the lock keeps them from duplicating the queued follow-up batch as well. |
| 115 |
if ( get_transient( self::LOCK_TRANSIENT ) ) { |
| 116 |
return; |
| 117 |
} |
| 118 |
|
| 119 |
set_transient( self::LOCK_TRANSIENT, 'yes', 5 * MINUTE_IN_SECONDS ); |
| 120 |
|
| 121 |
try { |
| 122 |
$variation_ids = self::get_pending_variation_ids( self::BATCH_SIZE ); |
| 123 |
|
| 124 |
foreach ( $variation_ids as $variation_id ) { |
| 125 |
self::migrate_variation( $variation_id ); |
| 126 |
} |
| 127 |
|
| 128 |
$has_more = (bool) self::get_pending_variation_ids( 1 ); |
| 129 |
} finally { |
| 130 |
delete_transient( self::LOCK_TRANSIENT ); |
| 131 |
} |
| 132 |
|
| 133 |
if ( $has_more ) { |
| 134 |
$queue = self::get_queue(); |
| 135 |
|
| 136 |
if ( $queue ) { |
| 137 |
$queue->add( self::BATCH_HOOK, [], self::BATCH_GROUP ); |
| 138 |
} |
| 139 |
|
| 140 |
return; |
| 141 |
} |
| 142 |
|
| 143 |
if ( ! get_option( self::COMPLETED_OPTION ) ) { |
| 144 |
update_option( self::COMPLETED_OPTION, time(), false ); |
| 145 |
} |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Copy one variation's legacy gallery into the native gallery. |
| 150 |
* |
| 151 |
* The native value wins whenever it already holds images, so a merchant who has |
| 152 |
* authored through WooCommerce before the migration ran is never overwritten. |
| 153 |
* |
| 154 |
* @param int $variation_id Variation ID. |
| 155 |
* |
| 156 |
* @return void |
| 157 |
*/ |
| 158 |
protected static function migrate_variation( $variation_id ) { |
| 159 |
$variation_id = absint( $variation_id ); |
| 160 |
$variation = $variation_id ? wc_get_product( $variation_id ) : false; |
| 161 |
|
| 162 |
if ( ! $variation || ! $variation->is_type( 'variation' ) ) { |
| 163 |
return; |
| 164 |
} |
| 165 |
|
| 166 |
/* |
| 167 |
* Re-check ownership at write time, not just in the batch query. |
| 168 |
* |
| 169 |
* A batch selects up to BATCH_SIZE IDs in one statement and then processes them |
| 170 |
* one by one, so a merchant can save this variation in between. Were they to |
| 171 |
* empty its gallery in that window, the native store would be legitimately empty |
| 172 |
* while the legacy meta still held the old list — and the write below would put |
| 173 |
* the removed images straight back. |
| 174 |
*/ |
| 175 |
if ( GalleryFns::is_native_gallery_owned( $variation_id ) ) { |
| 176 |
return; |
| 177 |
} |
| 178 |
|
| 179 |
$legacy_ids = GalleryFns::get_legacy_variation_gallery_ids( $variation_id ); |
| 180 |
$native_ids = GalleryFns::get_native_variation_gallery_ids( $variation ); |
| 181 |
|
| 182 |
if ( empty( $native_ids ) && ! empty( $legacy_ids ) ) { |
| 183 |
$variation->set_gallery_image_ids( $legacy_ids ); |
| 184 |
$variation->save(); |
| 185 |
} |
| 186 |
|
| 187 |
GalleryFns::mark_native_gallery_owned( $variation_id ); |
| 188 |
|
| 189 |
// The cached props were built from the legacy list; drop them so the next read |
| 190 |
// rebuilds from the native gallery. |
| 191 |
GalleryFns::delete_transients( $variation_id, 'variation-images' ); |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Fetch variation IDs that still carry an unmigrated legacy gallery. |
| 196 |
* |
| 197 |
* @param int $limit Maximum number of IDs to return. |
| 198 |
* |
| 199 |
* @return array |
| 200 |
*/ |
| 201 |
protected static function get_pending_variation_ids( $limit ) { |
| 202 |
global $wpdb; |
| 203 |
|
| 204 |
$query = $wpdb->prepare( |
| 205 |
"SELECT legacy.post_id |
| 206 |
FROM {$wpdb->postmeta} AS legacy |
| 207 |
INNER JOIN {$wpdb->posts} AS posts |
| 208 |
ON posts.ID = legacy.post_id |
| 209 |
AND posts.post_type = 'product_variation' |
| 210 |
LEFT JOIN {$wpdb->postmeta} AS migrated |
| 211 |
ON migrated.post_id = legacy.post_id |
| 212 |
AND migrated.meta_key = %s |
| 213 |
WHERE legacy.meta_key = %s |
| 214 |
AND legacy.meta_value <> '' |
| 215 |
AND legacy.meta_value <> 'a:0:{}' |
| 216 |
AND migrated.post_id IS NULL |
| 217 |
GROUP BY legacy.post_id |
| 218 |
ORDER BY legacy.post_id ASC |
| 219 |
LIMIT %d", |
| 220 |
GalleryFns::NATIVE_GALLERY_SENTINEL_META_KEY, |
| 221 |
GalleryFns::LEGACY_GALLERY_META_KEY, |
| 222 |
absint( $limit ) |
| 223 |
); |
| 224 |
|
| 225 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared immediately above; a migration scan must not be cached. |
| 226 |
return array_map( 'absint', $wpdb->get_col( $query ) ); |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Resolve WooCommerce's Action Scheduler queue when it is available. |
| 231 |
* |
| 232 |
* @return \WC_Queue_Interface|null |
| 233 |
*/ |
| 234 |
protected static function get_queue() { |
| 235 |
if ( ! function_exists( 'WC' ) || ! method_exists( WC(), 'queue' ) ) { |
| 236 |
return null; |
| 237 |
} |
| 238 |
|
| 239 |
return WC()->queue(); |
| 240 |
} |
| 241 |
} |
| 242 |
|