PluginProbe
Extendify / 3.1.4
Extendify v3.1.4
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.1.4, at app/Agent/WooProductImages.php

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