@porulle/adapter-shopify 0.15.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 +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/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.
|
package/dist/push-catalog.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|