PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / trunk
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). vtrunk
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / includes / classes / zoho-inventory / class-cmbird-image-zi.php
commercebird / includes / classes / zoho-inventory Last commit date
class-cmbird-categories-zi.php 2 months ago class-cmbird-image-zi.php 2 weeks ago class-import-items.php 2 weeks ago class-import-price-list.php 3 months ago class-multi-currency.php 8 months ago class-order-sync.php 2 weeks ago class-product.php 2 months ago class-users-contact.php 3 weeks ago index.php 1 year ago
class-cmbird-image-zi.php
496 lines
1 <?php
2 /**
3 * CommerceBird
4 *
5 * @package CommerceBird
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Imports Zoho Inventory item images into the WordPress media library.
14 *
15 * Duplicate detection is content based, following the approach used by the
16 * Media Deduper plugin: the md5 of the file's bytes is the identity of an
17 * image. Filenames and attachment titles are treated as hints only - they can
18 * disagree with the bytes in both directions, so they never decide a match on
19 * their own.
20 *
21 * @package WooZo Inventory
22 */
23 class CMBIRD_Image_ZI {
24
25 /**
26 * Post meta holding the md5 of an attachment's file.
27 *
28 * Same value Media Deduper stores in `mdd_hash`, so the two indexes agree.
29 */
30 const HASH_META = '_cmbird_image_hash';
31
32 /**
33 * Post meta holding the byte size of an attachment's file.
34 */
35 const SIZE_META = '_cmbird_image_size';
36
37 /**
38 * Post meta linking an attachment back to the Zoho image it came from.
39 */
40 const SOURCE_META = '_cmbird_zi_image_key';
41
42 /**
43 * Media Deduper's hash meta key. Read as well as our own so an existing
44 * Media Deduper index is reused instead of recomputed.
45 */
46 const MDD_HASH_META = 'mdd_hash';
47
48 /**
49 * Media Deduper's sentinel for an attachment whose file is missing.
50 */
51 const MDD_NOT_FOUND_HASH = 'not-found';
52
53 /**
54 * How many not-yet-indexed attachments to hash-verify per lookup.
55 */
56 const MAX_LEGACY_CANDIDATES = 20;
57
58 /**
59 * Zoho API configuration.
60 *
61 * @var array
62 */
63 private $config;
64
65 /**
66 * Constructor.
67 */
68 public function __construct() {
69 $this->config = array(
70 'ProductZI' => array(
71 'OID' => get_option( 'cmbird_zoho_inventory_oid' ),
72 'APIURL' => get_option( 'cmbird_zoho_inventory_url' ),
73 ),
74 );
75 }
76
77 /**
78 * Attach an item image from Zoho, reusing an existing attachment when the
79 * same image is already in the media library.
80 *
81 * @param string $item_id - Item id for image details.
82 * @param string $item_name - Item name.
83 * @param string $image_name - Image name.
84 * @param string $post_id - Post id of product.
85 * @param string $image_document_id - Zoho image document id, when known. Changes
86 * whenever the image is replaced in Zoho, so
87 * passing it makes replacements sync through.
88 * @return integer|void The attachment id, or nothing when the image could not be imported.
89 */
90 public function cmbird_zi_get_image( $item_id, $item_name, $image_name, $post_id = null, $image_document_id = '' ) {
91 $post_id = empty( $post_id ) ? 0 : (int) $post_id;
92 $item_name = (string) $item_name;
93 $image_name = (string) $image_name;
94
95 if ( '' === $image_name ) {
96 return;
97 }
98
99 // Fast path: this exact Zoho image has been imported before. Skips the
100 // download entirely, which is what keeps a full resync cheap.
101 $source_key = $this->build_source_key( $item_id, $image_name, $image_document_id );
102 $attachment_id = $this->find_attachment_by_source_key( $source_key );
103 if ( $attachment_id ) {
104 $this->link_attachment( $attachment_id, $post_id, $item_name );
105 return $attachment_id;
106 }
107
108 $local_path = $this->download_image( $item_id, $image_name, $image_document_id );
109 if ( '' === $local_path ) {
110 return;
111 }
112
113 // Reject anything that is not a real image - an expired token or a
114 // deleted document makes Zoho answer with an error body, and sideloading
115 // that would create a junk attachment.
116 if ( ! $this->is_image_file( $local_path ) ) {
117 wp_delete_file( $local_path );
118 return;
119 }
120
121 $hash = md5_file( $local_path );
122 if ( false === $hash ) {
123 wp_delete_file( $local_path );
124 return;
125 }
126 $size = (int) filesize( $local_path );
127
128 // Content match against the indexed library, then against attachments
129 // that have not been indexed yet.
130 $attachment_id = $this->find_attachment_by_hash( $hash );
131 if ( ! $attachment_id ) {
132 $attachment_id = $this->find_unindexed_attachment_by_hash( $image_name, $hash );
133 }
134
135 if ( $attachment_id ) {
136 $this->remember_attachment( $attachment_id, $hash, $size, $source_key );
137 wp_delete_file( $local_path );
138 $this->link_attachment( $attachment_id, $post_id, $item_name );
139 return $attachment_id;
140 }
141
142 $attachment_id = $this->sideload_image( $local_path, $image_name, $item_name, $post_id );
143 if ( ! $attachment_id ) {
144 return;
145 }
146
147 $this->remember_attachment( $attachment_id, $hash, $size, $source_key );
148
149 // Two workers importing items that share an image can both reach this
150 // point. Whoever finishes last adopts the earlier attachment and drops
151 // its own copy, so a race cannot leave a duplicate behind.
152 $existing = $this->find_attachment_by_hash( $hash, $attachment_id );
153 if ( $existing && $existing < $attachment_id ) {
154 wp_delete_attachment( $attachment_id, true );
155 $attachment_id = $existing;
156 $this->remember_attachment( $attachment_id, $hash, $size, $source_key );
157 }
158
159 $this->link_attachment( $attachment_id, $post_id, $item_name );
160
161 return $attachment_id;
162 }
163
164 /**
165 * Compute and store the md5 of an attachment's file.
166 *
167 * Mirrors Media Deduper's `calc_media_meta()`. Call this after replacing an
168 * attachment's file so the stored hash keeps matching the bytes on disk.
169 *
170 * @param int $attachment_id The attachment to index.
171 * @return string|false The stored hash, or false when the file is missing.
172 */
173 public function index_attachment( $attachment_id ) {
174 $attachment_id = (int) $attachment_id;
175 $file = get_attached_file( $attachment_id );
176
177 if ( empty( $file ) || ! file_exists( $file ) ) {
178 delete_post_meta( $attachment_id, self::HASH_META );
179 delete_post_meta( $attachment_id, self::SIZE_META );
180 return false;
181 }
182
183 $hash = md5_file( $file );
184 if ( false === $hash ) {
185 return false;
186 }
187
188 update_post_meta( $attachment_id, self::HASH_META, $hash );
189 update_post_meta( $attachment_id, self::SIZE_META, (int) filesize( $file ) );
190
191 return $hash;
192 }
193
194 /**
195 * Build the key that identifies a Zoho image.
196 *
197 * The document id is preferred because Zoho mints a new one when the image
198 * on an item is replaced. Without it the key falls back to the item plus the
199 * filename, which is stable but cannot see a replacement that reuses the
200 * same filename.
201 *
202 * @param string $item_id Zoho item id.
203 * @param string $image_name Image filename as reported by Zoho.
204 * @param string $image_document_id Zoho image document id, when known.
205 * @return string
206 */
207 private function build_source_key( $item_id, $image_name, $image_document_id ) {
208 if ( ! empty( $image_document_id ) ) {
209 return 'doc:' . $image_document_id;
210 }
211
212 return 'item:' . $item_id . '|' . strtolower( wp_basename( $image_name ) );
213 }
214
215 /**
216 * Find the attachment previously imported for a Zoho image key.
217 *
218 * @param string $source_key The key produced by build_source_key().
219 * @return int|false The attachment id, or false when there is no usable match.
220 */
221 private function find_attachment_by_source_key( $source_key ) {
222 global $wpdb;
223
224 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
225 $attachment_ids = $wpdb->get_col(
226 $wpdb->prepare(
227 "SELECT pm.post_id FROM {$wpdb->postmeta} pm
228 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
229 WHERE pm.meta_key = %s AND pm.meta_value = %s
230 AND p.post_type = 'attachment'
231 ORDER BY pm.post_id ASC
232 LIMIT 5",
233 self::SOURCE_META,
234 $source_key
235 )
236 );
237 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
238
239 foreach ( $attachment_ids as $attachment_id ) {
240 $attachment_id = (int) $attachment_id;
241 if ( $this->attachment_file_exists( $attachment_id ) ) {
242 return $attachment_id;
243 }
244 // The file is gone, so the mapping is worthless - drop it and let the
245 // image be downloaded again.
246 delete_post_meta( $attachment_id, self::SOURCE_META );
247 }
248
249 return false;
250 }
251
252 /**
253 * Find an indexed attachment whose file has the given md5.
254 *
255 * @param string $hash The md5 of the file to look for.
256 * @param int $exclude Optional attachment id to exclude from the search.
257 * @return int|false The attachment id, or false when there is no match.
258 */
259 private function find_attachment_by_hash( $hash, $exclude = 0 ) {
260 global $wpdb;
261
262 $exclude = (int) $exclude;
263
264 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
265 $attachment_ids = $wpdb->get_col(
266 $wpdb->prepare(
267 "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm
268 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
269 WHERE pm.meta_key IN ( %s, %s )
270 AND pm.meta_value = %s
271 AND p.post_type = 'attachment'
272 AND p.ID <> %d
273 ORDER BY pm.post_id ASC
274 LIMIT 10",
275 self::HASH_META,
276 self::MDD_HASH_META,
277 $hash,
278 $exclude
279 )
280 );
281 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
282
283 foreach ( $attachment_ids as $attachment_id ) {
284 $attachment_id = (int) $attachment_id;
285 if ( $this->attachment_file_exists( $attachment_id ) ) {
286 return $attachment_id;
287 }
288 // Stale index entry for a file that no longer exists.
289 delete_post_meta( $attachment_id, self::HASH_META );
290 delete_post_meta( $attachment_id, self::SIZE_META );
291 }
292
293 return false;
294 }
295
296 /**
297 * Look for a match among attachments that have never been hashed.
298 *
299 * The filename is used only to narrow the candidates down to a handful; the
300 * md5 of each candidate then decides. Because the bytes have the final say,
301 * the filename match can afford to be loose - a candidate that turns out to
302 * be a different image simply costs one md5 and is discarded, where the old
303 * name-only logic would have linked it to the product.
304 *
305 * Every candidate examined gets its hash stored, so the library indexes
306 * itself as syncs run and this path gets cheaper over time.
307 *
308 * @param string $image_name The Zoho filename to build candidates from.
309 * @param string $hash The md5 the candidate has to match.
310 * @return int|false The attachment id, or false when nothing matches.
311 */
312 private function find_unindexed_attachment_by_hash( $image_name, $hash ) {
313 global $wpdb;
314
315 $base_name = pathinfo( wp_basename( $image_name ), PATHINFO_FILENAME );
316 if ( '' === $base_name ) {
317 return false;
318 }
319
320 // A single LIKE covers every rewrite WordPress may have applied to the
321 // name: the "-1" collision suffix, the "-scaled" suffix on large images,
322 // a corrected extension, and sanitize_file_name() substitutions.
323 $like = '%' . $wpdb->esc_like( $base_name ) . '%';
324
325 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
326 $attachment_ids = $wpdb->get_col(
327 $wpdb->prepare(
328 "SELECT p.ID FROM {$wpdb->posts} p
329 INNER JOIN {$wpdb->postmeta} pm_file
330 ON pm_file.post_id = p.ID AND pm_file.meta_key = '_wp_attached_file'
331 LEFT JOIN {$wpdb->postmeta} pm_hash
332 ON pm_hash.post_id = p.ID AND pm_hash.meta_key = %s
333 WHERE p.post_type = 'attachment'
334 AND p.post_mime_type LIKE %s
335 AND pm_file.meta_value LIKE %s
336 AND pm_hash.meta_id IS NULL
337 ORDER BY p.ID ASC
338 LIMIT %d",
339 self::HASH_META,
340 'image/%',
341 $like,
342 self::MAX_LEGACY_CANDIDATES
343 )
344 );
345 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
346
347 foreach ( $attachment_ids as $attachment_id ) {
348 $attachment_id = (int) $attachment_id;
349 $candidate_hash = $this->index_attachment( $attachment_id );
350 if ( false !== $candidate_hash && hash_equals( $hash, $candidate_hash ) ) {
351 return $attachment_id;
352 }
353 }
354
355 return false;
356 }
357
358 /**
359 * Store the dedupe index entries for an attachment.
360 *
361 * @param int $attachment_id The attachment to record.
362 * @param string $hash The md5 of its file.
363 * @param int $size The byte size of its file.
364 * @param string $source_key The Zoho image key it was imported for.
365 * @return void
366 */
367 private function remember_attachment( $attachment_id, $hash, $size, $source_key ) {
368 update_post_meta( $attachment_id, self::HASH_META, $hash );
369 update_post_meta( $attachment_id, self::SIZE_META, $size );
370 update_post_meta( $attachment_id, self::SOURCE_META, $source_key );
371 }
372
373 /**
374 * Attach an image to a product and describe it.
375 *
376 * @param int $attachment_id The attachment to link.
377 * @param int $post_id The product or variation to link it to, 0 to skip.
378 * @param string $item_name The Zoho item name, used as the alt text.
379 * @return void
380 */
381 private function link_attachment( $attachment_id, $post_id, $item_name ) {
382 if ( $post_id > 0 ) {
383 set_post_thumbnail( $post_id, $attachment_id );
384 }
385 if ( '' !== $item_name ) {
386 update_post_meta( $attachment_id, '_wp_attachment_image_alt', $item_name );
387 }
388 }
389
390 /**
391 * Download an item image from Zoho into the temporary uploads directory.
392 *
393 * Every image attached to an item -- the featured one and any gallery
394 * image -- is itself a Zoho document, so when its document id is known
395 * this fetches it directly from the generic documents endpoint, which is
396 * the only reliable way to select a specific one; the item's own /image
397 * endpoint always returns its current default image regardless of which
398 * document id is requested. That endpoint is kept as a fallback for
399 * callers that only know the item id (e.g. webhook payloads that don't
400 * carry an image document id).
401 *
402 * @param string $item_id Zoho item id.
403 * @param string $image_name Image filename as reported by Zoho.
404 * @param string $image_document_id Zoho document id, when known.
405 * @return string The local path of the downloaded file, or an empty string on failure.
406 */
407 private function download_image( $item_id, $image_name, $image_document_id = '' ) {
408 $zoho_inventory_oid = $this->config['ProductZI']['OID'];
409 $zoho_inventory_url = $this->config['ProductZI']['APIURL'];
410
411 if ( '' !== $image_document_id ) {
412 $url = $zoho_inventory_url . 'inventory/v1/documents/' . rawurlencode( $image_document_id ) . '?organization_id=' . rawurlencode( $zoho_inventory_oid );
413 } else {
414 $url = "{$zoho_inventory_url}inventory/v1/items/$item_id/image";
415 $url .= "?organization_id=$zoho_inventory_oid";
416 }
417
418 $execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
419 $image_url = $execute_curl_call_handle->execute_curl_call_image_get( $url, $image_name );
420
421 if ( is_wp_error( $image_url ) || empty( $image_url ) ) {
422 return '';
423 }
424
425 $upload_dir = wp_upload_dir();
426 $local_path = $upload_dir['basedir'] . '/zoho_image/' . wp_basename( $image_url );
427
428 return file_exists( $local_path ) ? $local_path : '';
429 }
430
431 /**
432 * Add a downloaded file to the media library.
433 *
434 * @param string $local_path The file to sideload. Consumed by this call.
435 * @param string $image_name The filename to store it under.
436 * @param string $item_name The Zoho item name, used as the attachment title.
437 * @param int $post_id The product to attach it to, 0 for none.
438 * @return int|false The new attachment id, or false on failure.
439 */
440 private function sideload_image( $local_path, $image_name, $item_name, $post_id ) {
441 require_once ABSPATH . 'wp-admin/includes/media.php';
442 require_once ABSPATH . 'wp-admin/includes/file.php';
443 require_once ABSPATH . 'wp-admin/includes/image.php';
444
445 $file_array = array(
446 'name' => sanitize_file_name( wp_basename( $image_name ) ),
447 'tmp_name' => $local_path,
448 );
449
450 $attachment_id = media_handle_sideload( $file_array, $post_id, '' === $item_name ? null : $item_name );
451
452 if ( is_wp_error( $attachment_id ) ) {
453 // media_handle_sideload() removes the temp file itself on success; on
454 // failure it is left behind.
455 if ( file_exists( $local_path ) ) {
456 wp_delete_file( $local_path );
457 }
458 return false;
459 }
460
461 return (int) $attachment_id;
462 }
463
464 /**
465 * Check whether an attachment still has a file on disk.
466 *
467 * @param int $attachment_id The attachment to check.
468 * @return bool
469 */
470 private function attachment_file_exists( $attachment_id ) {
471 if ( 'attachment' !== get_post_type( $attachment_id ) ) {
472 return false;
473 }
474
475 $file = get_attached_file( $attachment_id );
476
477 return ! empty( $file ) && file_exists( $file );
478 }
479
480 /**
481 * Check whether a downloaded file really is an image.
482 *
483 * @param string $local_path The file to inspect.
484 * @return bool
485 */
486 private function is_image_file( $local_path ) {
487 if ( ! function_exists( 'wp_getimagesize' ) ) {
488 require_once ABSPATH . 'wp-admin/includes/image.php';
489 }
490
491 $size = wp_getimagesize( $local_path );
492
493 return ! empty( $size ) && ! empty( $size[0] ) && ! empty( $size[1] );
494 }
495 }
496