@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/README.md +16 -0
- package/dist/push-catalog.js +114 -13
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/push-catalog.ts +141 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-shopify",
|
|
3
|
-
"version": "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.
|
|
13
|
+
"@porulle/core": "0.18.0"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^24.5.2",
|
package/src/push-catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|