@porulle/adapter-shopify 0.16.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-shopify",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@porulle/core": "0.16.0"
13
+ "@porulle/core": "0.18.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^24.5.2",
@@ -2,6 +2,8 @@ import { Err, Ok } from "@porulle/core";
2
2
  import type {
3
3
  ChannelConnectorError,
4
4
  ChannelPushCatalogField,
5
+ ChannelPushCatalogImage,
6
+ ChannelPushCatalogImageOutcome,
5
7
  ChannelPushCatalogItem,
6
8
  ChannelPushCatalogItemOutcome,
7
9
  ChannelPushCatalogPreviousField,
@@ -54,6 +56,16 @@ type ShopifyVariantSnapshot = {
54
56
  barcode?: string | null;
55
57
  };
56
58
 
59
+ type ShopifyProductImage = {
60
+ id: number | string;
61
+ src?: string | null;
62
+ alt?: string | null;
63
+ position?: number | null;
64
+ variant_ids?: Array<number | string> | null;
65
+ };
66
+
67
+ const SHOPIFY_IMAGE_ROLES = new Set<ChannelPushCatalogImage["role"]>(["primary", "gallery", "thumbnail"]);
68
+
57
69
  interface PushRequestResult<T> {
58
70
  ok: true;
59
71
  data: T;
@@ -285,10 +297,12 @@ async function loadProductSnapshot(
285
297
  token: string,
286
298
  externalId: string,
287
299
  variantIds: string[],
300
+ withImages: boolean,
288
301
  ): Promise<Result<{
289
302
  product: ShopifyProductSnapshot;
290
303
  metafields: ShopifyMetafield[];
291
304
  variantMetafields: Map<string, ShopifyMetafield[]>;
305
+ images: ShopifyProductImage[];
292
306
  }>> {
293
307
  const base = deps.apiBase(store);
294
308
  const productResult = await pushRequest<{ product: ShopifyProductSnapshot }>(
@@ -313,13 +327,128 @@ async function loadProductSnapshot(
313
327
  if (!variantResult.ok) return variantResult;
314
328
  variantMetafields.set(variantId, variantResult.data.metafields ?? []);
315
329
  }
330
+ let images: ShopifyProductImage[] = [];
331
+ if (withImages) {
332
+ const imagesResult = await pushRequest<{ images: ShopifyProductImage[] }>(
333
+ deps,
334
+ `${base}/products/${encodeURIComponent(externalId)}/images.json`,
335
+ token,
336
+ );
337
+ if (!imagesResult.ok) return imagesResult;
338
+ images = imagesResult.data.images ?? [];
339
+ }
316
340
  return Ok({
317
341
  product: productResult.data.product,
318
342
  metafields: metafieldsResult.data.metafields ?? [],
319
343
  variantMetafields,
344
+ images,
345
+ });
346
+ }
347
+
348
+ function shopifyId(externalId: string): number | string {
349
+ return /^\d+$/.test(externalId) ? Number(externalId) : externalId;
350
+ }
351
+
352
+ function imageFileName(url: string): string | undefined {
353
+ try {
354
+ const path = new URL(url).pathname;
355
+ const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
356
+ return name.length > 0 ? name : undefined;
357
+ } catch {
358
+ return undefined;
359
+ }
360
+ }
361
+
362
+ function imageFileStem(name: string): string {
363
+ const dot = name.lastIndexOf(".");
364
+ return dot > 0 ? name.slice(0, dot) : name;
365
+ }
366
+
367
+ // Shopify keeps the uploaded file name in the CDN path (suffixing `_<hash>` on a
368
+ // collision) but never echoes the original source URL, so a re-push without a
369
+ // recorded image id can only recognise its own image by that file name.
370
+ function matchExistingImage(
371
+ image: ChannelPushCatalogImage,
372
+ existing: ShopifyProductImage[],
373
+ ): ShopifyProductImage | undefined {
374
+ const externalId = image.externalId?.trim();
375
+ if (externalId) return existing.find((candidate) => String(candidate.id) === externalId);
376
+ const name = imageFileName(image.url);
377
+ if (name === undefined) return undefined;
378
+ const stem = imageFileStem(name);
379
+ return existing.find((candidate) => {
380
+ const candidateName = candidate.src ? imageFileName(candidate.src) : undefined;
381
+ if (candidateName === undefined) return false;
382
+ const candidateStem = imageFileStem(candidateName);
383
+ return candidateName === name || candidateStem === stem || candidateStem.startsWith(`${stem}_`);
320
384
  });
321
385
  }
322
386
 
387
+ function orderImages(images: ChannelPushCatalogImage[]): ChannelPushCatalogImage[] {
388
+ const rank = (image: ChannelPushCatalogImage): number => (image.role === "primary" ? 0 : 1);
389
+ return images
390
+ .map((image, index) => ({ image, index }))
391
+ .sort((left, right) =>
392
+ rank(left.image) - rank(right.image)
393
+ || (left.image.sortOrder ?? left.index) - (right.image.sortOrder ?? right.index)
394
+ || left.index - right.index)
395
+ .map((entry) => entry.image);
396
+ }
397
+
398
+ async function writeImages(
399
+ deps: PushCatalogDeps,
400
+ store: ChannelStore,
401
+ token: string,
402
+ externalId: string,
403
+ images: ChannelPushCatalogImage[],
404
+ existing: ShopifyProductImage[],
405
+ ): Promise<{ outcomes: ChannelPushCatalogImageOutcome[]; error?: ChannelConnectorError }> {
406
+ const outcomes: ChannelPushCatalogImageOutcome[] = [];
407
+ const base = `${deps.apiBase(store)}/products/${encodeURIComponent(externalId)}/images`;
408
+ const claimed = new Set<string>();
409
+ for (const [index, image] of orderImages(images).entries()) {
410
+ if (!SHOPIFY_IMAGE_ROLES.has(image.role)) {
411
+ const error: ChannelConnectorError = {
412
+ code: "SHOPIFY_IMAGE_ROLE_UNSUPPORTED",
413
+ message: `Shopify product images cannot carry the "${image.role}" role.`,
414
+ retriable: false,
415
+ };
416
+ outcomes.push({ url: image.url, role: image.role, ok: false, error });
417
+ return { outcomes, error };
418
+ }
419
+ const match = matchExistingImage(image, existing.filter((candidate) => !claimed.has(String(candidate.id))));
420
+ const payload: Record<string, unknown> = { position: index + 1 };
421
+ if (image.alt !== undefined) payload.alt = image.alt;
422
+ if (image.variantExternalIds && image.variantExternalIds.length > 0) {
423
+ payload.variant_ids = image.variantExternalIds.map(shopifyId);
424
+ }
425
+ const result = match
426
+ ? await pushRequest<{ image: ShopifyProductImage }>(deps, `${base}/${encodeURIComponent(String(match.id))}.json`, token, {
427
+ method: "PUT",
428
+ headers: { "content-type": "application/json" },
429
+ body: JSON.stringify({ image: { id: match.id, ...payload } }),
430
+ })
431
+ : await pushRequest<{ image: ShopifyProductImage }>(deps, `${base}.json`, token, {
432
+ method: "POST",
433
+ headers: { "content-type": "application/json" },
434
+ body: JSON.stringify({ image: { src: image.url, ...payload } }),
435
+ });
436
+ if (!result.ok) {
437
+ outcomes.push({ url: image.url, role: image.role, ok: false, error: result.error });
438
+ return { outcomes, error: result.error };
439
+ }
440
+ const written = result.data.image ?? match;
441
+ if (written) claimed.add(String(written.id));
442
+ outcomes.push({
443
+ url: image.url,
444
+ role: image.role,
445
+ ok: true,
446
+ ...(written ? { externalId: String(written.id) } : {}),
447
+ });
448
+ }
449
+ return { outcomes };
450
+ }
451
+
323
452
  async function writeNativeProduct(
324
453
  deps: PushCatalogDeps,
325
454
  store: ChannelStore,
@@ -442,22 +571,11 @@ async function pushCatalogItem(
442
571
  const missingField = [...item.fields, ...(item.variants ?? []).flatMap((variant) => variant.fields)]
443
572
  .find((field) => field.intent !== "tag" && remoteKey(field) === undefined);
444
573
  if (missingField) return { externalId: item.externalId, ok: false, error: missingRemoteKeyError(missingField) };
445
- if (item.images && item.images.length > 0) {
446
- return {
447
- externalId: item.externalId,
448
- ok: false,
449
- error: {
450
- code: "SHOPIFY_IMAGES_NOT_WRITTEN",
451
- message: "Shopify catalog image pushes are not written by this adapter.",
452
- retriable: false,
453
- },
454
- };
455
- }
456
-
574
+ const images = item.images ?? [];
457
575
  const variantIds = (item.variants ?? [])
458
576
  .filter((variant) => variant.fields.some((field) => isMetafieldField(field)))
459
577
  .map((variant) => variant.externalId);
460
- const snapshot = await loadProductSnapshot(deps, store, token, item.externalId, variantIds);
578
+ const snapshot = await loadProductSnapshot(deps, store, token, item.externalId, variantIds, images.length > 0);
461
579
  if (!snapshot.ok) {
462
580
  return { externalId: item.externalId, ok: false, error: snapshot.error };
463
581
  }
@@ -544,11 +662,21 @@ async function pushCatalogItem(
544
662
  }
545
663
  }
546
664
 
665
+ let imageOutcomes: ChannelPushCatalogImageOutcome[] | undefined;
666
+ if (images.length > 0) {
667
+ const imageResult = await writeImages(deps, store, token, item.externalId, images, snapshot.value.images);
668
+ imageOutcomes = imageResult.outcomes;
669
+ if (imageResult.error) {
670
+ return { externalId: item.externalId, ok: false, error: imageResult.error, images: imageOutcomes };
671
+ }
672
+ }
673
+
547
674
  return {
548
675
  externalId: item.externalId,
549
676
  ok: true,
550
677
  ...(nativeResult.data.product.updated_at ? { remoteUpdatedAt: nativeResult.data.product.updated_at } : {}),
551
678
  ...(previousFields.length > 0 ? { previousFields } : {}),
679
+ ...(imageOutcomes ? { images: imageOutcomes } : {}),
552
680
  };
553
681
  }
554
682