@cloudcommerce/cli 2.38.0 → 2.39.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.
@@ -2,6 +2,7 @@ import {
2
2
  argv, fs, echo, sleep,
3
3
  } from 'zx';
4
4
  import { XMLParser } from 'fast-xml-parser';
5
+ import { imageSize } from 'image-size';
5
6
  import api from '@cloudcommerce/api';
6
7
 
7
8
  const clearAccents = (str) => {
@@ -12,6 +13,65 @@ const slugify = (name) => {
12
13
  .replace(/[\s\n/]/g, '-')
13
14
  .replace(/[^a-z0-9_-]/ig, '');
14
15
  };
16
+ const uploadPicture = async (downloadUrl, authHeaders) => {
17
+ const downloadRes = await fetch(downloadUrl);
18
+ if (!downloadRes.ok) {
19
+ throw new Error(`Failed downloading ${downloadUrl} with status ${downloadRes.status}`);
20
+ }
21
+ const contentType = downloadRes.headers.get('content-type');
22
+ if (!contentType) {
23
+ throw new Error(`No mime type returned for ${downloadUrl}`);
24
+ }
25
+ const imageBuffer = await downloadRes.arrayBuffer();
26
+ const imageBlob = new Blob([imageBuffer], { type: contentType });
27
+ const formData = new FormData();
28
+ const fileName = downloadUrl.split('/').pop() || 'image.jpg';
29
+ formData.append('file', imageBlob, fileName);
30
+ const uploadRes = await fetch('https://ecomplus.app/api/storage/upload.json', {
31
+ method: 'POST',
32
+ body: formData,
33
+ headers: authHeaders,
34
+ });
35
+ if (!uploadRes.ok) {
36
+ const err = new Error(`Failed uploading ${downloadUrl} with ${uploadRes.status}`);
37
+ err.statusCode = uploadRes.status;
38
+ throw err;
39
+ }
40
+ const data = await uploadRes.json();
41
+ const { width, height } = imageSize(Buffer.from(imageBuffer));
42
+ const { picture } = data;
43
+ if (width && height) {
44
+ picture.zoom.size = `${width}x${height}`;
45
+ Object.keys(picture).forEach((thumb) => {
46
+ if (thumb === 'zoom' || !picture[thumb]) return;
47
+ const px = parseInt(picture[thumb].size, 10);
48
+ if (px) {
49
+ if (px >= Math.max(width, height)) {
50
+ picture[thumb].size = picture.zoom.size;
51
+ } else {
52
+ picture[thumb].size = width > height
53
+ ? px + 'x' + Math.round((height * px) / width)
54
+ : Math.round((width * px) / height) + 'x' + px;
55
+ }
56
+ } else {
57
+ delete picture[thumb].size;
58
+ }
59
+ });
60
+ } else {
61
+ Object.keys(picture).forEach((thumb) => {
62
+ if (!picture[thumb]) return;
63
+ delete picture[thumb].size;
64
+ });
65
+ }
66
+ if (fileName) {
67
+ const alt = fileName.replace(/\.[^.]+$/, '');
68
+ Object.keys(picture).forEach((thumb) => {
69
+ if (!picture[thumb]) return;
70
+ picture[thumb].alt = alt;
71
+ });
72
+ }
73
+ return picture;
74
+ };
15
75
  const importFeed = async () => {
16
76
  const feedFilepath = typeof argv.feed === 'string' ? argv.feed : '';
17
77
  if (!feedFilepath) {
@@ -30,6 +90,20 @@ const importFeed = async () => {
30
90
  await echo`The XML file does not appear to be a valid RSS 2.0 product feed`;
31
91
  return process.exit(1);
32
92
  }
93
+ const ecomAuthHeaders = {
94
+ 'X-Store-ID': process.env.ECOM_STORE_ID,
95
+ 'X-My-ID': process.env.ECOM_AUTHENTICATION_ID,
96
+ };
97
+ const { ECOM_ACCESS_TOKEN, ECOM_API_KEY } = process.env;
98
+ if (ECOM_ACCESS_TOKEN) {
99
+ ecomAuthHeaders['X-Access-Token'] = ECOM_ACCESS_TOKEN;
100
+ } else {
101
+ const { data } = await api.post('authenticate', {
102
+ _id: process.env.ECOM_AUTHENTICATION_ID,
103
+ api_key: ECOM_API_KEY,
104
+ });
105
+ ecomAuthHeaders['X-Access-Token'] = data.access_token;
106
+ }
33
107
  const categoryNames = [];
34
108
  const brandNames = [];
35
109
  items.forEach(async (item) => {
@@ -126,6 +200,7 @@ const importFeed = async () => {
126
200
  for (let ii = 0; ii < productItems.length; ii++) {
127
201
  if (result.find(({ sku }) => sku === productItems[ii]['g:id'])) {
128
202
  productItems.splice(ii, 1);
203
+ ii -= 1;
129
204
  }
130
205
  }
131
206
  }
@@ -172,19 +247,36 @@ const importFeed = async () => {
172
247
  });
173
248
  if (Object.keys(specifications).length) {
174
249
  productOrVatiation.specifications = specifications;
250
+ } else if (!isProductItem) {
251
+ productOrVatiation.specifications = {
252
+ size: [{ text: 'Único' }],
253
+ };
175
254
  }
176
255
  };
177
- await echo`\n`;
256
+ const listItemRemoteImages = (item) => {
257
+ const remoteImages = Array.isArray(item['g:additional_image_link'])
258
+ ? item['g:additional_image_link']
259
+ : [];
260
+ if (item['g:image_link']) {
261
+ remoteImages.unshift(item['g:image_link']);
262
+ }
263
+ if (typeof item['g:additional_image_link'] === 'string') {
264
+ remoteImages.push(item['g:additional_image_link']);
265
+ }
266
+ return remoteImages;
267
+ };
178
268
  for (let i = 0; i < productItems.length; i++) {
179
269
  const item = productItems[i];
180
270
  const { 'g:id': sku, 'g:title': name } = item;
181
271
  if (!sku || !name) continue;
272
+ await echo`\n${new Date().toISOString()}`;
273
+ await echo`${(productItems.length - i)} products to import\n`;
182
274
  await echo`SKU: ${sku}`;
183
275
  const product = {
184
276
  sku,
185
277
  name,
186
278
  slug: item['g:link']
187
- ? new URL(item['g:link']).pathname.substring(1)
279
+ ? new URL(item['g:link']).pathname.substring(1).toLowerCase()
188
280
  : slugify(name),
189
281
  condition: item['g:condition'],
190
282
  };
@@ -203,6 +295,7 @@ const importFeed = async () => {
203
295
  product.brands = [brand];
204
296
  }
205
297
  setStockAndPrices(product, item);
298
+ const remoteImages = listItemRemoteImages(item);
206
299
  const variationItems = items.filter((_item) => {
207
300
  return _item['g:item_group_id'] === sku;
208
301
  });
@@ -222,13 +315,41 @@ const importFeed = async () => {
222
315
  totalQuantity += variation.quantity || 0;
223
316
  setSpecifications(variation, variationItem);
224
317
  product.variations.push(variation);
318
+ const variationRemoteImages = listItemRemoteImages(variationItem);
319
+ variationRemoteImages.forEach((imageUrl) => {
320
+ if (!remoteImages.includes(imageUrl)) remoteImages.push(imageUrl);
321
+ });
225
322
  });
226
323
  product.quantity = totalQuantity;
227
324
  } else {
228
325
  setSpecifications(product, item, true);
229
326
  }
230
- await sleep(500);
231
- await echo`${JSON.stringify(product, null, 2)}`;
327
+ if (remoteImages.length) {
328
+ product.pictures = [];
329
+ }
330
+ for (let ii = 0; ii < remoteImages.length; ii++) {
331
+ const imageUrl = remoteImages[ii];
332
+ if (imageUrl) {
333
+ let retries = 0;
334
+ while (retries < 4) {
335
+ try {
336
+ const picture = await uploadPicture(imageUrl, ecomAuthHeaders);
337
+ product.pictures?.push(picture);
338
+ break;
339
+ } catch (err) {
340
+ // eslint-disable-next-line no-console
341
+ console.error(err);
342
+ if (err.statusCode < 500) {
343
+ throw err;
344
+ }
345
+ retries += 1;
346
+ await sleep(4000);
347
+ }
348
+ }
349
+ }
350
+ }
351
+ await echo`${JSON.stringify(product, null, 2)}\n`;
352
+ await api.post('products', product);
232
353
  }
233
354
  return echo``;
234
355
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cloudcommerce/cli",
3
3
  "type": "module",
4
- "version": "2.38.0",
4
+ "version": "2.39.0",
5
5
  "description": "e-com.plus Cloud Commerce CLI tools",
6
6
  "bin": {
7
7
  "cloudcommerce": "./bin/run.mjs"
@@ -33,11 +33,12 @@
33
33
  "@fastify/deepmerge": "^2.0.2",
34
34
  "dotenv": "^16.4.7",
35
35
  "fast-xml-parser": "^5.0.8",
36
+ "image-size": "^1.2.0",
36
37
  "libsodium-wrappers": "^0.7.15",
37
38
  "md5": "^2.3.0",
38
39
  "typescript": "~5.8.2",
39
- "zx": "^8.3.2",
40
- "@cloudcommerce/api": "2.38.0"
40
+ "zx": "^8.4.0",
41
+ "@cloudcommerce/api": "2.39.0"
41
42
  },
42
43
  "scripts": {
43
44
  "build": "bash ../../scripts/build-lib.sh"