PluginProbe
Extendify / 3.2.0
Extendify v3.2.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / Agent / WooProductImages.php

WooProductImages.php in Extendify 3.2.0, at app/Agent/WooProductImages.php

225 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\Agent;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 use Extendify\Shared\Services\Import\ImageUploader;
8
9 /**
10 * Adds an `images` field to WooCommerce's product abilities, which ship with no
11 * way to set a product image at all.
12 */
13 class WooProductImages
14 {
15 public static $abilities = [
16 'woocommerce/product-create',
17 'woocommerce/product-update',
18 ];
19
20 public static function init()
21 {
22 \add_filter('wp_register_ability_args', [self::class, 'patchArgs'], 10, 2);
23 }
24
25 /**
26 * Extend the schemas and wrap the callback of a product ability.
27 *
28 * Runs before the Abilities API validates the args or builds the
29 * WP_Ability, so every reader downstream — REST, MCP — sees only the
30 * patched version.
31 *
32 * @param array $args Ability registration args.
33 * @param string $name Ability name, with namespace.
34 * @return array
35 */
36 public static function patchArgs($args, $name)
37 {
38 if (!in_array($name, self::$abilities, true)) {
39 return $args;
40 }
41
42 // Woo reworking these abilities could make a blind patch reject every call.
43 if (empty($args['input_schema']['oneOf']) || !is_array($args['input_schema']['oneOf'])) {
44 return $args;
45 }
46
47 foreach ($args['input_schema']['oneOf'] as $branch) {
48 // Woo shipped its own image support; patching again would duplicate it.
49 if (isset($branch['properties']['images'])) {
50 return $args;
51 }
52 }
53
54 foreach (array_keys($args['input_schema']['oneOf']) as $index) {
55 // Every branch sets additionalProperties:false, so a root-level field fails.
56 $args['input_schema']['oneOf'][$index]['properties']['images'] = self::inputSchema();
57 }
58
59 $product = &$args['output_schema']['properties']['product']['properties'];
60 $product['images'] = [
61 'type' => 'array',
62 'items' => [
63 'type' => 'object',
64 'properties' => [
65 'id' => ['type' => 'integer'],
66 'url' => ['type' => 'string'],
67 ],
68 ],
69 ];
70 $product['failed_images'] = [
71 'type' => 'array',
72 'items' => ['type' => 'string'],
73 ];
74
75 $original = $args['execute_callback'];
76 $args['execute_callback'] = function ($input) use ($original) {
77 return self::execute($original, $input);
78 };
79
80 return $args;
81 }
82
83 /**
84 * Run Woo's own callback, then attach the images it ignored.
85 *
86 * @param callable $original Woo's execute_callback.
87 * @param array $input Ability input, including our `images`.
88 * @return array|\WP_Error
89 */
90 public static function execute($original, $input)
91 {
92 $images = isset($input['images']) ? $input['images'] : [];
93 unset($input['images']);
94
95 // Woo's callback rejects an id-only update, so an image-only change attaches directly.
96 $imagesOnly = $images
97 && !empty($input['id'])
98 && empty(array_diff(array_keys($input), ['id']))
99 && \get_post((int) $input['id']);
100 if ($imagesOnly) {
101 $productId = (int) $input['id'];
102 return ['product' => array_merge(['id' => $productId], self::attach($productId, $images))];
103 }
104
105 $result = call_user_func($original, $input);
106
107 if (\is_wp_error($result) || !$images || empty($result['product']['id'])) {
108 return $result;
109 }
110
111 return array_replace_recursive($result, [
112 'product' => self::attach($result['product']['id'], $images),
113 ]);
114 }
115
116 /**
117 * Import each image, then set the first as featured and the rest as the
118 * gallery — the ordering WooCommerce's own REST API applies.
119 *
120 * @param int $productId Product post id.
121 * @param array $images Image descriptors from the ability input.
122 * @return array
123 */
124 private static function attach($productId, $images)
125 {
126 $ids = [];
127 $failed = [];
128
129 foreach ($images as $image) {
130 $src = isset($image['src']) ? $image['src'] : '';
131 $given = isset($image['id']) ? $image['id'] : $src;
132
133 // anyOf would enforce this in schema, but the model's converter breaks on it.
134 if (!$given) {
135 continue;
136 }
137
138 $id = isset($image['id']) ? self::existingAttachment($image['id']) : self::sideload($src);
139
140 if (!$id) {
141 $failed[] = (string) $given;
142 continue;
143 }
144
145 if (!empty($image['alt'])) {
146 \update_post_meta($id, '_wp_attachment_image_alt', \sanitize_text_field($image['alt']));
147 }
148
149 $ids[] = $id;
150 }
151
152 if ($ids) {
153 \set_post_thumbnail($productId, $ids[0]);
154 \update_post_meta($productId, '_product_image_gallery', implode(',', array_slice($ids, 1)));
155 }
156
157 // Carrying the url spares every later render of this result a lookup.
158 $images = array_map(function ($id) {
159 return ['id' => $id, 'url' => (string) \wp_get_attachment_url($id)];
160 }, $ids);
161
162 return [
163 'images' => $images,
164 'failed_images' => $failed,
165 ];
166 }
167
168 private static function existingAttachment($id)
169 {
170 $id = (int) $id;
171
172 return \wp_attachment_is_image($id) ? $id : false;
173 }
174
175 private static function sideload($url)
176 {
177 // Woo's permission callback clears product caps, not upload_files.
178 if (!\current_user_can('upload_files')) {
179 return false;
180 }
181
182 $uploaded = (new ImageUploader())->uploadImage($url);
183
184 return \is_wp_error($uploaded) ? false : $uploaded['attachment_id'];
185 }
186
187 private static function inputSchema()
188 {
189 return [
190 'type' => 'array',
191 // The picker returns one, so extra entries could only be invented.
192 'maxItems' => 1,
193 'description' => __(
194 "The product's image, replacing any it already has.",
195 'extendify-local'
196 ),
197 'items' => [
198 'type' => 'object',
199 'properties' => [
200 'id' => [
201 'type' => 'integer',
202 'minimum' => 1,
203 'description' => __(
204 'Media library attachment id. Preferred over src whenever one is available.',
205 'extendify-local'
206 ),
207 ],
208 'src' => [
209 'type' => 'string',
210 'format' => 'uri',
211 'description' => __(
212 'Publicly reachable image url, imported into the media library. Ignored when id is set.',
213 'extendify-local'
214 ),
215 ],
216 'alt' => [
217 'type' => 'string',
218 'description' => __('Alt text for the image.', 'extendify-local'),
219 ],
220 ],
221 ],
222 ];
223 }
224 }
225