@porulle/adapter-shopify 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,3 +22,19 @@ Operators can recover without disconnecting the store:
22
22
  Use `shopifyReauthorizeUrl(options, params)` to build the authorize URL outside
23
23
  the push error path, or `shopifyPushCatalogEnabled(store)` to check scope
24
24
  coverage before enqueueing work.
25
+
26
+ ## Product images
27
+
28
+ `pushCatalog` writes `item.images` to the product's images through the Admin
29
+ REST API (`/products/{id}/images.json`). Roles `primary`, `gallery` and
30
+ `thumbnail` are written as images; `video` and `document` fail the item with
31
+ `SHOPIFY_IMAGE_ROLE_UNSUPPORTED`. The `primary` image is written first and
32
+ takes position 1, the rest follow `sortOrder`; `alt` and `variantExternalIds`
33
+ map to the image's `alt` and `variant_ids`.
34
+
35
+ Each item outcome carries `images[]` with the Shopify image id as
36
+ `externalId`. Persist it and send it back as `image.externalId` on the next
37
+ push so the adapter updates that image in place. Without an id the adapter
38
+ falls back to matching the uploaded file name against the CDN path Shopify
39
+ keeps (`boot.jpg` matches `boot.jpg` and `boot_a1b2c3.jpg`); a file name that
40
+ matches nothing creates a new image. Dry runs do not touch images.
@@ -13,6 +13,7 @@ export const SHOPIFY_NATIVE_VARIANT_FIELDS = new Set([
13
13
  "sku",
14
14
  "barcode",
15
15
  ]);
16
+ const SHOPIFY_IMAGE_ROLES = new Set(["primary", "gallery", "thumbnail"]);
16
17
  export function shopifyGrantedScopes(store) {
17
18
  const raw = store.credentials.grantedScopes;
18
19
  if (Array.isArray(raw)) {
@@ -204,7 +205,7 @@ async function pushRequest(deps, url, token, init) {
204
205
  };
205
206
  }
206
207
  }
207
- async function loadProductSnapshot(deps, store, token, externalId, variantIds) {
208
+ async function loadProductSnapshot(deps, store, token, externalId, variantIds, withImages) {
208
209
  const base = deps.apiBase(store);
209
210
  const productResult = await pushRequest(deps, `${base}/products/${encodeURIComponent(externalId)}.json`, token);
210
211
  if (!productResult.ok)
@@ -219,12 +220,113 @@ async function loadProductSnapshot(deps, store, token, externalId, variantIds) {
219
220
  return variantResult;
220
221
  variantMetafields.set(variantId, variantResult.data.metafields ?? []);
221
222
  }
223
+ let images = [];
224
+ if (withImages) {
225
+ const imagesResult = await pushRequest(deps, `${base}/products/${encodeURIComponent(externalId)}/images.json`, token);
226
+ if (!imagesResult.ok)
227
+ return imagesResult;
228
+ images = imagesResult.data.images ?? [];
229
+ }
222
230
  return Ok({
223
231
  product: productResult.data.product,
224
232
  metafields: metafieldsResult.data.metafields ?? [],
225
233
  variantMetafields,
234
+ images,
226
235
  });
227
236
  }
237
+ function shopifyId(externalId) {
238
+ return /^\d+$/.test(externalId) ? Number(externalId) : externalId;
239
+ }
240
+ function imageFileName(url) {
241
+ try {
242
+ const path = new URL(url).pathname;
243
+ const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
244
+ return name.length > 0 ? name : undefined;
245
+ }
246
+ catch {
247
+ return undefined;
248
+ }
249
+ }
250
+ function imageFileStem(name) {
251
+ const dot = name.lastIndexOf(".");
252
+ return dot > 0 ? name.slice(0, dot) : name;
253
+ }
254
+ // Shopify keeps the uploaded file name in the CDN path (suffixing `_<hash>` on a
255
+ // collision) but never echoes the original source URL, so a re-push without a
256
+ // recorded image id can only recognise its own image by that file name.
257
+ function matchExistingImage(image, existing) {
258
+ const externalId = image.externalId?.trim();
259
+ if (externalId)
260
+ return existing.find((candidate) => String(candidate.id) === externalId);
261
+ const name = imageFileName(image.url);
262
+ if (name === undefined)
263
+ return undefined;
264
+ const stem = imageFileStem(name);
265
+ return existing.find((candidate) => {
266
+ const candidateName = candidate.src ? imageFileName(candidate.src) : undefined;
267
+ if (candidateName === undefined)
268
+ return false;
269
+ const candidateStem = imageFileStem(candidateName);
270
+ return candidateName === name || candidateStem === stem || candidateStem.startsWith(`${stem}_`);
271
+ });
272
+ }
273
+ function orderImages(images) {
274
+ const rank = (image) => (image.role === "primary" ? 0 : 1);
275
+ return images
276
+ .map((image, index) => ({ image, index }))
277
+ .sort((left, right) => rank(left.image) - rank(right.image)
278
+ || (left.image.sortOrder ?? left.index) - (right.image.sortOrder ?? right.index)
279
+ || left.index - right.index)
280
+ .map((entry) => entry.image);
281
+ }
282
+ async function writeImages(deps, store, token, externalId, images, existing) {
283
+ const outcomes = [];
284
+ const base = `${deps.apiBase(store)}/products/${encodeURIComponent(externalId)}/images`;
285
+ const claimed = new Set();
286
+ for (const [index, image] of orderImages(images).entries()) {
287
+ if (!SHOPIFY_IMAGE_ROLES.has(image.role)) {
288
+ const error = {
289
+ code: "SHOPIFY_IMAGE_ROLE_UNSUPPORTED",
290
+ message: `Shopify product images cannot carry the "${image.role}" role.`,
291
+ retriable: false,
292
+ };
293
+ outcomes.push({ url: image.url, role: image.role, ok: false, error });
294
+ return { outcomes, error };
295
+ }
296
+ const match = matchExistingImage(image, existing.filter((candidate) => !claimed.has(String(candidate.id))));
297
+ const payload = { position: index + 1 };
298
+ if (image.alt !== undefined)
299
+ payload.alt = image.alt;
300
+ if (image.variantExternalIds && image.variantExternalIds.length > 0) {
301
+ payload.variant_ids = image.variantExternalIds.map(shopifyId);
302
+ }
303
+ const result = match
304
+ ? await pushRequest(deps, `${base}/${encodeURIComponent(String(match.id))}.json`, token, {
305
+ method: "PUT",
306
+ headers: { "content-type": "application/json" },
307
+ body: JSON.stringify({ image: { id: match.id, ...payload } }),
308
+ })
309
+ : await pushRequest(deps, `${base}.json`, token, {
310
+ method: "POST",
311
+ headers: { "content-type": "application/json" },
312
+ body: JSON.stringify({ image: { src: image.url, ...payload } }),
313
+ });
314
+ if (!result.ok) {
315
+ outcomes.push({ url: image.url, role: image.role, ok: false, error: result.error });
316
+ return { outcomes, error: result.error };
317
+ }
318
+ const written = result.data.image ?? match;
319
+ if (written)
320
+ claimed.add(String(written.id));
321
+ outcomes.push({
322
+ url: image.url,
323
+ role: image.role,
324
+ ok: true,
325
+ ...(written ? { externalId: String(written.id) } : {}),
326
+ });
327
+ }
328
+ return { outcomes };
329
+ }
228
330
  async function writeNativeProduct(deps, store, token, externalId, fields, tagValues, existingTags) {
229
331
  const product = { id: externalId };
230
332
  for (const field of fields) {
@@ -302,21 +404,11 @@ async function pushCatalogItem(deps, store, token, item, dryRun) {
302
404
  .find((field) => field.intent !== "tag" && remoteKey(field) === undefined);
303
405
  if (missingField)
304
406
  return { externalId: item.externalId, ok: false, error: missingRemoteKeyError(missingField) };
305
- if (item.images && item.images.length > 0) {
306
- return {
307
- externalId: item.externalId,
308
- ok: false,
309
- error: {
310
- code: "SHOPIFY_IMAGES_NOT_WRITTEN",
311
- message: "Shopify catalog image pushes are not written by this adapter.",
312
- retriable: false,
313
- },
314
- };
315
- }
407
+ const images = item.images ?? [];
316
408
  const variantIds = (item.variants ?? [])
317
409
  .filter((variant) => variant.fields.some((field) => isMetafieldField(field)))
318
410
  .map((variant) => variant.externalId);
319
- const snapshot = await loadProductSnapshot(deps, store, token, item.externalId, variantIds);
411
+ const snapshot = await loadProductSnapshot(deps, store, token, item.externalId, variantIds, images.length > 0);
320
412
  if (!snapshot.ok) {
321
413
  return { externalId: item.externalId, ok: false, error: snapshot.error };
322
414
  }
@@ -378,11 +470,20 @@ async function pushCatalogItem(deps, store, token, item, dryRun) {
378
470
  return { externalId: item.externalId, ok: false, error: variantResult.error };
379
471
  }
380
472
  }
473
+ let imageOutcomes;
474
+ if (images.length > 0) {
475
+ const imageResult = await writeImages(deps, store, token, item.externalId, images, snapshot.value.images);
476
+ imageOutcomes = imageResult.outcomes;
477
+ if (imageResult.error) {
478
+ return { externalId: item.externalId, ok: false, error: imageResult.error, images: imageOutcomes };
479
+ }
480
+ }
381
481
  return {
382
482
  externalId: item.externalId,
383
483
  ok: true,
384
484
  ...(nativeResult.data.product.updated_at ? { remoteUpdatedAt: nativeResult.data.product.updated_at } : {}),
385
485
  ...(previousFields.length > 0 ? { previousFields } : {}),
486
+ ...(imageOutcomes ? { images: imageOutcomes } : {}),
386
487
  };
387
488
  }
388
489
  export async function pushCatalog(deps, store, items, opts) {