@tallyui/connector-woocommerce 3.0.0 → 3.0.1
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 +2 -0
- package/dist/index.js +123 -108
- package/package.json +3 -3
- package/src/reconcile/catalogue.ts +3 -3
- package/src/replication/products.ts +25 -2
- package/src/traits/product.ts +2 -1
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ It also sends `X-WCPOS-Protocol: 2`, which WCPOS 2.0 requires of POS requests, a
|
|
|
13
13
|
|
|
14
14
|
## Product pull
|
|
15
15
|
|
|
16
|
+
On WCPOS 1.10.x, the product uuid comes from the `_woocommerce_pos_uuid` meta and the barcode from `global_unique_id`. Non-empty top-level `uuid` / `barcode` fields (WCPOS 2.0) win when present.
|
|
17
|
+
|
|
16
18
|
The pull works in passes:
|
|
17
19
|
- a small request finds the store's newest `date_modified_gmt`;
|
|
18
20
|
- the pass then pages every product modified since the last pass, by id with an offset;
|
package/dist/index.js
CHANGED
|
@@ -148,109 +148,6 @@ var wooProductSchema = {
|
|
|
148
148
|
|
|
149
149
|
// src/traits/product.ts
|
|
150
150
|
import { moneyFromMajor } from "@tallyui/core";
|
|
151
|
-
var WOO_STOCK_STATUS = {
|
|
152
|
-
instock: "in_stock",
|
|
153
|
-
outofstock: "out_of_stock",
|
|
154
|
-
onbackorder: "backorder"
|
|
155
|
-
};
|
|
156
|
-
var wooProductTraits = {
|
|
157
|
-
getId: (doc) => String(doc.id),
|
|
158
|
-
getName: (doc) => doc.name ?? "",
|
|
159
|
-
getSku: (doc) => doc.sku || void 0,
|
|
160
|
-
getPrices: (doc, context) => {
|
|
161
|
-
const currency = context?.currency ?? "XXX";
|
|
162
|
-
const prices = [];
|
|
163
|
-
const base = moneyFromMajor(doc.regular_price || doc.price, currency);
|
|
164
|
-
if (base) prices.push({ ...base, kind: "base" });
|
|
165
|
-
const sale = doc.on_sale === true ? moneyFromMajor(doc.sale_price, currency) : void 0;
|
|
166
|
-
if (sale) prices.push({ ...sale, kind: "sale" });
|
|
167
|
-
return prices;
|
|
168
|
-
},
|
|
169
|
-
getStock: (doc) => ({
|
|
170
|
-
status: WOO_STOCK_STATUS[doc.stock_status] ?? "unknown",
|
|
171
|
-
quantity: doc.manage_stock === false ? void 0 : doc.stock_quantity ?? void 0
|
|
172
|
-
}),
|
|
173
|
-
getPrice: (doc) => doc.price || void 0,
|
|
174
|
-
getRegularPrice: (doc) => doc.regular_price || void 0,
|
|
175
|
-
getSalePrice: (doc) => doc.sale_price || void 0,
|
|
176
|
-
isOnSale: (doc) => doc.on_sale === true,
|
|
177
|
-
getImageUrl: (doc) => doc.images?.[0]?.src || void 0,
|
|
178
|
-
getImageUrls: (doc) => (doc.images ?? []).map((img) => img.src).filter(Boolean),
|
|
179
|
-
getDescription: (doc) => doc.short_description || doc.description || void 0,
|
|
180
|
-
getStockStatus: (doc) => {
|
|
181
|
-
const status = doc.stock_status;
|
|
182
|
-
if (status === "instock" || status === "outofstock" || status === "onbackorder") {
|
|
183
|
-
return status;
|
|
184
|
-
}
|
|
185
|
-
return "unknown";
|
|
186
|
-
},
|
|
187
|
-
getStockQuantity: (doc) => doc.stock_quantity ?? null,
|
|
188
|
-
hasVariants: (doc) => doc.type === "variable",
|
|
189
|
-
isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
|
|
190
|
-
getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
|
|
191
|
-
getType: (doc) => doc.type ?? "simple",
|
|
192
|
-
getBarcode: (doc) => doc.barcode || void 0,
|
|
193
|
-
getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
// src/sync/products.ts
|
|
197
|
-
var wooProductSync = {
|
|
198
|
-
async fetchAllIds(context) {
|
|
199
|
-
const response = await fetch(`${context.baseUrl}/products`, {
|
|
200
|
-
headers: {
|
|
201
|
-
...context.headers,
|
|
202
|
-
"Content-Type": "application/json"
|
|
203
|
-
},
|
|
204
|
-
signal: context.signal
|
|
205
|
-
});
|
|
206
|
-
if (!response.ok) {
|
|
207
|
-
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
208
|
-
}
|
|
209
|
-
const products = await response.json();
|
|
210
|
-
return products.map((p) => ({
|
|
211
|
-
id: String(p.id),
|
|
212
|
-
dateModified: p.date_modified_gmt
|
|
213
|
-
}));
|
|
214
|
-
},
|
|
215
|
-
async fetchByIds(ids, context) {
|
|
216
|
-
const include = ids.join(",");
|
|
217
|
-
const response = await fetch(
|
|
218
|
-
`${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
|
|
219
|
-
{
|
|
220
|
-
headers: {
|
|
221
|
-
...context.headers,
|
|
222
|
-
"Content-Type": "application/json"
|
|
223
|
-
},
|
|
224
|
-
signal: context.signal
|
|
225
|
-
}
|
|
226
|
-
);
|
|
227
|
-
if (!response.ok) {
|
|
228
|
-
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
229
|
-
}
|
|
230
|
-
return response.json();
|
|
231
|
-
},
|
|
232
|
-
async fetchModifiedAfter(date, context) {
|
|
233
|
-
const params = new URLSearchParams({
|
|
234
|
-
modified_after: date,
|
|
235
|
-
dates_are_gmt: "true",
|
|
236
|
-
per_page: "100"
|
|
237
|
-
});
|
|
238
|
-
const response = await fetch(
|
|
239
|
-
`${context.baseUrl}/products?${params}`,
|
|
240
|
-
{
|
|
241
|
-
headers: {
|
|
242
|
-
...context.headers,
|
|
243
|
-
"Content-Type": "application/json"
|
|
244
|
-
},
|
|
245
|
-
signal: context.signal
|
|
246
|
-
}
|
|
247
|
-
);
|
|
248
|
-
if (!response.ok) {
|
|
249
|
-
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
250
|
-
}
|
|
251
|
-
return response.json();
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
151
|
|
|
255
152
|
// src/replication/products.ts
|
|
256
153
|
import { ConnectorUnauthorizedError } from "@tallyui/core";
|
|
@@ -295,9 +192,22 @@ var WooMissingUuidError = class extends Error {
|
|
|
295
192
|
this.productId = id;
|
|
296
193
|
}
|
|
297
194
|
};
|
|
195
|
+
var WCPOS_UUID_META_KEY = "_woocommerce_pos_uuid";
|
|
196
|
+
function wooProductUuid(product) {
|
|
197
|
+
if (typeof product.uuid === "string" && product.uuid.length > 0) return product.uuid;
|
|
198
|
+
return product.meta_data?.find((entry) => entry.key === WCPOS_UUID_META_KEY && typeof entry.value === "string" && entry.value.length > 0)?.value;
|
|
199
|
+
}
|
|
200
|
+
function wooProductBarcode(product) {
|
|
201
|
+
if (typeof product.barcode === "string" && product.barcode.length > 0) return product.barcode;
|
|
202
|
+
if (typeof product.global_unique_id === "string" && product.global_unique_id.length > 0) return product.global_unique_id;
|
|
203
|
+
return void 0;
|
|
204
|
+
}
|
|
298
205
|
function toProductDocument(product) {
|
|
299
|
-
|
|
300
|
-
|
|
206
|
+
const uuid = wooProductUuid(product);
|
|
207
|
+
if (uuid === void 0) throw new WooMissingUuidError(product.id);
|
|
208
|
+
const barcode = wooProductBarcode(product);
|
|
209
|
+
const projected = Object.fromEntries(Object.keys(wooProductSchema.properties).filter((key) => Object.prototype.hasOwnProperty.call(product, key)).map((key) => [key, product[key]]));
|
|
210
|
+
return { ...projected, uuid, ...barcode !== void 0 ? { barcode } : {}, _deleted: product.status !== "publish" };
|
|
301
211
|
}
|
|
302
212
|
var WooDateFilterError = class extends Error {
|
|
303
213
|
constructor(productId, bound, received) {
|
|
@@ -391,6 +301,111 @@ var wooProductReplication = {
|
|
|
391
301
|
}
|
|
392
302
|
};
|
|
393
303
|
|
|
304
|
+
// src/traits/product.ts
|
|
305
|
+
var WOO_STOCK_STATUS = {
|
|
306
|
+
instock: "in_stock",
|
|
307
|
+
outofstock: "out_of_stock",
|
|
308
|
+
onbackorder: "backorder"
|
|
309
|
+
};
|
|
310
|
+
var wooProductTraits = {
|
|
311
|
+
getId: (doc) => String(doc.id),
|
|
312
|
+
getName: (doc) => doc.name ?? "",
|
|
313
|
+
getSku: (doc) => doc.sku || void 0,
|
|
314
|
+
getPrices: (doc, context) => {
|
|
315
|
+
const currency = context?.currency ?? "XXX";
|
|
316
|
+
const prices = [];
|
|
317
|
+
const base = moneyFromMajor(doc.regular_price || doc.price, currency);
|
|
318
|
+
if (base) prices.push({ ...base, kind: "base" });
|
|
319
|
+
const sale = doc.on_sale === true ? moneyFromMajor(doc.sale_price, currency) : void 0;
|
|
320
|
+
if (sale) prices.push({ ...sale, kind: "sale" });
|
|
321
|
+
return prices;
|
|
322
|
+
},
|
|
323
|
+
getStock: (doc) => ({
|
|
324
|
+
status: WOO_STOCK_STATUS[doc.stock_status] ?? "unknown",
|
|
325
|
+
quantity: doc.manage_stock === false ? void 0 : doc.stock_quantity ?? void 0
|
|
326
|
+
}),
|
|
327
|
+
getPrice: (doc) => doc.price || void 0,
|
|
328
|
+
getRegularPrice: (doc) => doc.regular_price || void 0,
|
|
329
|
+
getSalePrice: (doc) => doc.sale_price || void 0,
|
|
330
|
+
isOnSale: (doc) => doc.on_sale === true,
|
|
331
|
+
getImageUrl: (doc) => doc.images?.[0]?.src || void 0,
|
|
332
|
+
getImageUrls: (doc) => (doc.images ?? []).map((img) => img.src).filter(Boolean),
|
|
333
|
+
getDescription: (doc) => doc.short_description || doc.description || void 0,
|
|
334
|
+
getStockStatus: (doc) => {
|
|
335
|
+
const status = doc.stock_status;
|
|
336
|
+
if (status === "instock" || status === "outofstock" || status === "onbackorder") {
|
|
337
|
+
return status;
|
|
338
|
+
}
|
|
339
|
+
return "unknown";
|
|
340
|
+
},
|
|
341
|
+
getStockQuantity: (doc) => doc.stock_quantity ?? null,
|
|
342
|
+
hasVariants: (doc) => doc.type === "variable",
|
|
343
|
+
isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
|
|
344
|
+
getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
|
|
345
|
+
getType: (doc) => doc.type ?? "simple",
|
|
346
|
+
getBarcode: (doc) => wooProductBarcode(doc),
|
|
347
|
+
getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// src/sync/products.ts
|
|
351
|
+
var wooProductSync = {
|
|
352
|
+
async fetchAllIds(context) {
|
|
353
|
+
const response = await fetch(`${context.baseUrl}/products`, {
|
|
354
|
+
headers: {
|
|
355
|
+
...context.headers,
|
|
356
|
+
"Content-Type": "application/json"
|
|
357
|
+
},
|
|
358
|
+
signal: context.signal
|
|
359
|
+
});
|
|
360
|
+
if (!response.ok) {
|
|
361
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
362
|
+
}
|
|
363
|
+
const products = await response.json();
|
|
364
|
+
return products.map((p) => ({
|
|
365
|
+
id: String(p.id),
|
|
366
|
+
dateModified: p.date_modified_gmt
|
|
367
|
+
}));
|
|
368
|
+
},
|
|
369
|
+
async fetchByIds(ids, context) {
|
|
370
|
+
const include = ids.join(",");
|
|
371
|
+
const response = await fetch(
|
|
372
|
+
`${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
|
|
373
|
+
{
|
|
374
|
+
headers: {
|
|
375
|
+
...context.headers,
|
|
376
|
+
"Content-Type": "application/json"
|
|
377
|
+
},
|
|
378
|
+
signal: context.signal
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
if (!response.ok) {
|
|
382
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
383
|
+
}
|
|
384
|
+
return response.json();
|
|
385
|
+
},
|
|
386
|
+
async fetchModifiedAfter(date, context) {
|
|
387
|
+
const params = new URLSearchParams({
|
|
388
|
+
modified_after: date,
|
|
389
|
+
dates_are_gmt: "true",
|
|
390
|
+
per_page: "100"
|
|
391
|
+
});
|
|
392
|
+
const response = await fetch(
|
|
393
|
+
`${context.baseUrl}/products?${params}`,
|
|
394
|
+
{
|
|
395
|
+
headers: {
|
|
396
|
+
...context.headers,
|
|
397
|
+
"Content-Type": "application/json"
|
|
398
|
+
},
|
|
399
|
+
signal: context.signal
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
if (!response.ok) {
|
|
403
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
404
|
+
}
|
|
405
|
+
return response.json();
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
394
409
|
// src/reconcile/catalogue.ts
|
|
395
410
|
import { errorKind } from "@tallyui/core";
|
|
396
411
|
var PAGE_SIZE = 100;
|
|
@@ -461,9 +476,9 @@ function wooCatalogueReconcile(feed) {
|
|
|
461
476
|
async confirmGone(locals, context) {
|
|
462
477
|
const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
|
|
463
478
|
if (!ids.length) return [];
|
|
464
|
-
const params = new URLSearchParams({ include: ids.join(","), per_page: String(PAGE_SIZE), status: "any", _fields: "id,uuid,status" });
|
|
479
|
+
const params = new URLSearchParams({ include: ids.join(","), per_page: String(PAGE_SIZE), status: "any", _fields: "id,uuid,status,meta_data" });
|
|
465
480
|
const rows = await get(`/products?${params}`, context);
|
|
466
|
-
const live = new Map(rows.filter((row) => row.status === "publish").map((row) => [row.id,
|
|
481
|
+
const live = new Map(rows.filter((row) => row.status === "publish").map((row) => [row.id, wooProductUuid(row)]));
|
|
467
482
|
return locals.filter((doc) => Number.isInteger(doc.id) && (!live.has(doc.id) || typeof live.get(doc.id) === "string" && live.get(doc.id) !== doc.uuid)).map((doc) => doc.uuid);
|
|
468
483
|
},
|
|
469
484
|
// An entry with no numeric id, locally or in the listing, is dropped: the store cannot be asked about it, so
|
|
@@ -495,7 +510,7 @@ function createWooReconcileFeed() {
|
|
|
495
510
|
}
|
|
496
511
|
|
|
497
512
|
// package.json
|
|
498
|
-
var version = "3.0.
|
|
513
|
+
var version = "3.0.1";
|
|
499
514
|
|
|
500
515
|
// src/index.ts
|
|
501
516
|
import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/connector-woocommerce",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "WooCommerce connector for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"directory": "connectors/woocommerce"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@tallyui/core": "^3.0.
|
|
29
|
+
"@tallyui/core": "^3.0.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.0.
|
|
32
|
+
"@tallyui/database": "3.0.1",
|
|
33
33
|
"rxdb": "17.5.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errorKind, type CatalogueReconcileAdapter, type CatalogueReconcileEntry, type ReconcileFeed, type SyncContext } from '@tallyui/core';
|
|
2
|
-
import { checkResponse } from '../replication/products';
|
|
2
|
+
import { checkResponse, wooProductUuid } from '../replication/products';
|
|
3
3
|
|
|
4
4
|
/** The listing's page size, WordPress's per_page maximum. */
|
|
5
5
|
const PAGE_SIZE = 100;
|
|
@@ -107,9 +107,9 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
|
|
|
107
107
|
const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
|
|
108
108
|
// An empty include= would list the whole store; a local without a numeric id has no proof and is kept.
|
|
109
109
|
if (!ids.length) return [];
|
|
110
|
-
const params = new URLSearchParams({ include: ids.join(','), per_page: String(PAGE_SIZE), status: 'any', _fields: 'id,uuid,status' });
|
|
110
|
+
const params = new URLSearchParams({ include: ids.join(','), per_page: String(PAGE_SIZE), status: 'any', _fields: 'id,uuid,status,meta_data' });
|
|
111
111
|
const rows: Array<{ id: number; uuid: string; status: string }> = await get(`/products?${params}`, context);
|
|
112
|
-
const live = new Map(rows.filter((row) => row.status === 'publish').map((row) => [row.id,
|
|
112
|
+
const live = new Map(rows.filter((row) => row.status === 'publish').map((row) => [row.id, wooProductUuid(row)]));
|
|
113
113
|
// A live id backs only the store's uuid; other local copies are gone (#369).
|
|
114
114
|
// A row without a uuid can't prove the local copy stale, so a live id keeps it (#375 review).
|
|
115
115
|
return locals.filter((doc) => Number.isInteger(doc.id) && (!live.has(doc.id) || (typeof live.get(doc.id) === 'string' && live.get(doc.id) !== doc.uuid))).map((doc) => doc.uuid);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConnectorUnauthorizedError, type ReplicationAdapter } from '@tallyui/core';
|
|
2
|
+
import { wooProductSchema } from '../schemas/products';
|
|
2
3
|
|
|
3
4
|
export type WooProductCheckpoint = {
|
|
4
5
|
/** Inclusive lower bound (date_modified_gmt) of the current pass; '' = the whole catalogue. */
|
|
@@ -65,13 +66,35 @@ export class WooMissingUuidError extends Error {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/** WCPOS's own uuid meta key; 1.10.x sends the product uuid only here (Uuid_Handler.php:29). */
|
|
70
|
+
export const WCPOS_UUID_META_KEY = '_woocommerce_pos_uuid';
|
|
71
|
+
|
|
72
|
+
/** The product's uuid: top-level `uuid` (the `next` shape) wins, else the `_woocommerce_pos_uuid` meta (1.10.x). */
|
|
73
|
+
export function wooProductUuid(product: any): string | undefined {
|
|
74
|
+
if (typeof product.uuid === 'string' && product.uuid.length > 0) return product.uuid;
|
|
75
|
+
return product.meta_data?.find((entry: any) =>
|
|
76
|
+
entry.key === WCPOS_UUID_META_KEY && typeof entry.value === 'string' && entry.value.length > 0)?.value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The product's barcode: top-level `barcode` wins, else `global_unique_id` (WCPOS's default barcode field), else undefined. */
|
|
80
|
+
export function wooProductBarcode(product: any): string | undefined {
|
|
81
|
+
if (typeof product.barcode === 'string' && product.barcode.length > 0) return product.barcode;
|
|
82
|
+
if (typeof product.global_unique_id === 'string' && product.global_unique_id.length > 0) return product.global_unique_id;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
/**
|
|
69
87
|
* One product row as the till stores it, the rule the pull and the reconcile feed share: a uuid is
|
|
70
88
|
* required (WooMissingUuidError), and a product that is not published arrives deleted (#229).
|
|
71
89
|
*/
|
|
72
90
|
export function toProductDocument(product: any): any {
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
const uuid = wooProductUuid(product);
|
|
92
|
+
if (uuid === undefined) throw new WooMissingUuidError(product.id);
|
|
93
|
+
const barcode = wooProductBarcode(product);
|
|
94
|
+
const projected = Object.fromEntries(Object.keys(wooProductSchema.properties)
|
|
95
|
+
.filter((key) => Object.prototype.hasOwnProperty.call(product, key))
|
|
96
|
+
.map((key) => [key, product[key]]));
|
|
97
|
+
return { ...projected, uuid, ...(barcode !== undefined ? { barcode } : {}), _deleted: product.status !== 'publish' };
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
/** The store returned a product outside a modified_after window, so it does not apply the filter. */
|
package/src/traits/product.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { moneyFromMajor } from '@tallyui/core';
|
|
2
2
|
import type { ProductPrice, ProductTraits, StockStatus } from '@tallyui/core';
|
|
3
|
+
import { wooProductBarcode } from '../replication/products';
|
|
3
4
|
|
|
4
5
|
/** WooCommerce's stock_status strings, mapped to the neutral enum. */
|
|
5
6
|
const WOO_STOCK_STATUS: Record<string, StockStatus> = {
|
|
@@ -69,7 +70,7 @@ export const wooProductTraits: ProductTraits = {
|
|
|
69
70
|
|
|
70
71
|
getType: (doc) => doc.type ?? 'simple',
|
|
71
72
|
|
|
72
|
-
getBarcode: (doc) => doc
|
|
73
|
+
getBarcode: (doc) => wooProductBarcode(doc),
|
|
73
74
|
|
|
74
75
|
getCategoryNames: (doc) =>
|
|
75
76
|
(doc.categories ?? []).map((cat: any) => cat.name).filter(Boolean),
|