@vendure/core
Version:
A modern, headless ecommerce framework
418 lines • 21.2 kB
JavaScript
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Importer = void 0;
const common_1 = require("@nestjs/common");
const normalize_string_1 = require("@vendure/common/lib/normalize-string");
const rxjs_1 = require("rxjs");
const request_context_1 = require("../../../api/common/request-context");
const errors_1 = require("../../../common/error/errors");
const config_service_1 = require("../../../config/config.service");
const index_1 = require("../../../config/index");
const channel_service_1 = require("../../../service/services/channel.service");
const facet_value_service_1 = require("../../../service/services/facet-value.service");
const facet_service_1 = require("../../../service/services/facet.service");
const tax_category_service_1 = require("../../../service/services/tax-category.service");
const asset_importer_1 = require("../asset-importer/asset-importer");
const import_parser_1 = require("../import-parser/import-parser");
const fast_importer_service_1 = require("./fast-importer.service");
const progress_bar_1 = require("./progress-bar");
/**
* @description
* Parses and imports Products using the CSV import format.
*
* Internally it is using the {@link ImportParser} to parse the CSV file, and then the
* {@link FastImporterService} and the {@link AssetImporter} to actually create the resulting
* entities in the Vendure database.
*
* @docsCategory import-export
*/
let Importer = class Importer {
/** @internal */
constructor(configService, importParser, channelService, facetService, facetValueService, taxCategoryService, assetImporter, fastImporter) {
this.configService = configService;
this.importParser = importParser;
this.channelService = channelService;
this.facetService = facetService;
this.facetValueService = facetValueService;
this.taxCategoryService = taxCategoryService;
this.assetImporter = assetImporter;
this.fastImporter = fastImporter;
this.taxCategoryMatches = {};
// These Maps are used to cache newly-created entities and prevent duplicates
// from being created.
this.facetMap = new Map();
this.facetValueMap = new Map();
}
/**
* @description
* Parses the contents of the [product import CSV file](/developer-guide/importing-data/#product-import-format) and imports
* the resulting Product & ProductVariants, as well as any associated Assets, Facets & FacetValues.
*
* The `ctxOrLanguageCode` argument is used to specify the languageCode to be used when creating the Products.
*/
parseAndImport(input, ctxOrLanguageCode, reportProgress = false) {
let bar;
return new rxjs_1.Observable(subscriber => {
const p = this.doParseAndImport(input, ctxOrLanguageCode, progress => {
if (reportProgress) {
if (!bar) {
bar = new progress_bar_1.ProgressBar(' importing [:bar] :percent :etas Importing: :prodName', {
complete: '=',
incomplete: ' ',
total: progress.processed,
width: 40,
});
}
bar.tick({ prodName: progress.currentProduct });
}
subscriber.next(progress);
}).then(value => {
subscriber.next(Object.assign(Object.assign({}, value), { currentProduct: 'Complete' }));
subscriber.complete();
});
});
}
async doParseAndImport(input, ctxOrLanguageCode, onProgress) {
const ctx = await this.getRequestContext(ctxOrLanguageCode);
const parsed = await this.importParser.parseProducts(input, ctx.languageCode);
if (parsed && parsed.results.length) {
try {
const importErrors = await this.importProducts(ctx, parsed.results, progess => {
onProgress(Object.assign(Object.assign({}, progess), { processed: parsed.processed }));
});
return {
errors: parsed.errors.concat(importErrors),
imported: parsed.results.length,
processed: parsed.processed,
};
}
catch (err) {
return {
errors: [err.message],
imported: 0,
processed: parsed.processed,
};
}
}
else {
return {
errors: [],
imported: 0,
processed: parsed.processed,
};
}
}
async getRequestContext(ctxOrLanguageCode) {
if (ctxOrLanguageCode instanceof request_context_1.RequestContext) {
return ctxOrLanguageCode;
}
else {
const channel = await this.channelService.getDefaultChannel();
return new request_context_1.RequestContext({
apiType: 'admin',
isAuthorized: true,
authorizedAsOwnerOnly: false,
channel,
languageCode: ctxOrLanguageCode,
});
}
}
/**
* @description
* Imports the products specified in the rows object. Return an array of error messages.
*/
async importProducts(ctx, rows, onProgress) {
var _a;
let errors = [];
let imported = 0;
const languageCode = ctx.languageCode;
const taxCategories = await this.taxCategoryService.findAll(ctx);
if (taxCategories.totalItems === 0) {
index_1.Logger.error([
`No TaxCategories found in the database. Ensure that at least one TaxCategory exists.`,
`If you are populating from an InitialData object, ensure the 'taxRates' array is not empty.`,
].join('\n'));
throw new Error(`No TaxCategories found in the database. Ensure the IntialData.taxRates array is not empty.`);
}
await this.fastImporter.initialize(ctx.channel);
// Build a map of shared option groups (only groups with an explicit code are shared)
const sharedGroupMap = await this.buildSharedOptionGroupMap(ctx, rows);
for (const { product, variants } of rows) {
const productMainTranslation = this.getTranslationByCodeOrFirst(product.translations, ctx.languageCode);
const createProductAssets = await this.assetImporter.getAssets(product.assetPaths, ctx);
const productAssets = createProductAssets.assets;
if (createProductAssets.errors.length) {
errors = errors.concat(createProductAssets.errors);
}
const customFields = this.processCustomFieldValues(product.translations[0].customFields, this.configService.customFields.Product);
const createdProductId = await this.fastImporter.createProduct({
featuredAssetId: productAssets.length ? productAssets[0].id : undefined,
assetIds: productAssets.map(a => a.id),
facetValueIds: await this.getFacetValueIds(ctx, product.facets, ctx.languageCode),
translations: product.translations.map(translation => {
return {
languageCode: translation.languageCode,
name: translation.name,
description: translation.description,
slug: translation.slug,
customFields: this.processCustomFieldValues(translation.customFields, this.configService.customFields.Product),
};
}),
customFields,
});
const optionsMap = {};
for (const [optionGroup, optionGroupIndex] of product.optionGroups.map((group, i) => [group, i])) {
const optionGroupMainTranslation = this.getTranslationByCodeOrFirst(optionGroup.translations, ctx.languageCode);
const shared = optionGroup.code ? sharedGroupMap.get(optionGroup.code) : undefined;
if (shared) {
// Use the pre-created shared option group
await this.fastImporter.addOptionGroupToProduct(createdProductId, shared.groupId);
for (const value of optionGroupMainTranslation.values) {
const optionId = shared.optionIds.get(value);
if (!optionId) {
throw new Error(`Option value "${value}" not found in shared option group "${(_a = optionGroup.code) !== null && _a !== void 0 ? _a : ''}"`);
}
optionsMap[`${optionGroupIndex}_${value}`] = optionId;
}
}
else {
// Create a product-specific option group (original behaviour)
const code = (0, normalize_string_1.normalizeString)(`${productMainTranslation.name}-${optionGroupMainTranslation.name}`, '-');
const groupId = await this.fastImporter.createProductOptionGroup({
code,
options: optionGroupMainTranslation.values.map(name => ({})),
translations: optionGroup.translations.map(translation => {
return {
languageCode: translation.languageCode,
name: translation.name,
};
}),
});
for (const [optionIndex, value] of optionGroupMainTranslation.values.map((val, index) => [index, val])) {
const createdOptionId = await this.fastImporter.createProductOption({
productOptionGroupId: groupId,
code: (0, normalize_string_1.normalizeString)(value, '-'),
translations: optionGroup.translations.map(translation => {
return {
languageCode: translation.languageCode,
name: translation.values[optionIndex],
};
}),
});
optionsMap[`${optionGroupIndex}_${value}`] = createdOptionId;
}
await this.fastImporter.addOptionGroupToProduct(createdProductId, groupId);
}
}
for (const variant of variants) {
const variantMainTranslation = this.getTranslationByCodeOrFirst(variant.translations, ctx.languageCode);
const createVariantAssets = await this.assetImporter.getAssets(variant.assetPaths, ctx);
const variantAssets = createVariantAssets.assets;
if (createVariantAssets.errors.length) {
errors = errors.concat(createVariantAssets.errors);
}
let facetValueIds = [];
if (0 < variant.facets.length) {
facetValueIds = await this.getFacetValueIds(ctx, variant.facets, languageCode);
}
const variantCustomFields = this.processCustomFieldValues(variantMainTranslation.customFields, this.configService.customFields.ProductVariant);
const optionIds = variantMainTranslation.optionValues.map((v, index) => optionsMap[`${index}_${v}`]);
const createdVariant = await this.fastImporter.createProductVariant({
productId: createdProductId,
facetValueIds,
featuredAssetId: variantAssets.length ? variantAssets[0].id : undefined,
assetIds: variantAssets.map(a => a.id),
sku: variant.sku,
taxCategoryId: this.getMatchingTaxCategoryId(variant.taxCategory, taxCategories.items),
stockOnHand: variant.stockOnHand,
trackInventory: variant.trackInventory,
optionIds,
translations: variant.translations.map(translation => {
const productTranslation = product.translations.find(t => t.languageCode === translation.languageCode);
if (!productTranslation) {
throw new errors_1.InternalServerError(`No translation '${translation.languageCode}' for product with slug '${productMainTranslation.slug}'`);
}
return {
languageCode: translation.languageCode,
name: [productTranslation.name, ...translation.optionValues].join(' '),
customFields: this.processCustomFieldValues(translation.customFields, this.configService.customFields.ProductVariant),
};
}),
price: Math.round(variant.price * 100),
customFields: variantCustomFields,
});
}
imported++;
onProgress({
processed: 0,
imported,
errors,
currentProduct: productMainTranslation.name,
});
}
return errors;
}
async getFacetValueIds(ctx, facets, languageCode) {
const facetValueIds = [];
for (const item of facets) {
const itemMainTranslation = this.getTranslationByCodeOrFirst(item.translations, languageCode);
const facetName = itemMainTranslation.facet;
const valueName = itemMainTranslation.value;
let facetEntity;
const cachedFacet = this.facetMap.get(facetName);
if (cachedFacet) {
facetEntity = cachedFacet;
}
else {
const existing = await this.facetService.findByCode(ctx, (0, normalize_string_1.normalizeString)(facetName, '-'), languageCode);
if (existing) {
facetEntity = existing;
}
else {
facetEntity = await this.facetService.create(ctx, {
isPrivate: false,
code: (0, normalize_string_1.normalizeString)(facetName, '-'),
translations: item.translations.map(translation => {
return {
languageCode: translation.languageCode,
name: translation.facet,
};
}),
});
}
this.facetMap.set(facetName, facetEntity);
}
let facetValueEntity;
const facetValueMapKey = `${facetName}:${valueName}`;
const cachedFacetValue = this.facetValueMap.get(facetValueMapKey);
if (cachedFacetValue) {
facetValueEntity = cachedFacetValue;
}
else {
const existing = facetEntity.values.find(v => v.name === valueName);
if (existing) {
facetValueEntity = existing;
}
else {
facetValueEntity = await this.facetValueService.create(ctx, facetEntity, {
code: (0, normalize_string_1.normalizeString)(valueName, '-'),
translations: item.translations.map(translation => {
return {
languageCode: translation.languageCode,
name: translation.value,
};
}),
});
}
this.facetValueMap.set(facetValueMapKey, facetValueEntity);
}
facetValueIds.push(facetValueEntity.id);
}
return facetValueIds;
}
processCustomFieldValues(customFields, config) {
const processed = {};
for (const fieldDef of config) {
const value = customFields[fieldDef.name];
if (fieldDef.list === true) {
processed[fieldDef.name] = value === null || value === void 0 ? void 0 : value.split('|').filter(val => val.trim() !== '');
}
else if (fieldDef.type === 'boolean') {
processed[fieldDef.name] = value ? value.toLowerCase() === 'true' : undefined;
}
else {
processed[fieldDef.name] = value ? value : undefined;
}
}
return processed;
}
/**
* Attempts to match a TaxCategory entity against the name supplied in the import table. If no matches
* are found, the first TaxCategory id is returned.
*/
getMatchingTaxCategoryId(name, taxCategories) {
if (this.taxCategoryMatches[name]) {
return this.taxCategoryMatches[name];
}
const regex = new RegExp(name, 'i');
const found = taxCategories.find(tc => !!tc.name.match(regex));
const match = found ? found : taxCategories[0];
this.taxCategoryMatches[name] = match.id;
return match.id;
}
getTranslationByCodeOrFirst(translations, languageCode) {
let translation = translations.find(t => t.languageCode === languageCode);
if (!translation) {
translation = translations[0];
}
return translation;
}
/**
* Performs a first-pass analysis of all parsed products to identify option groups
* that should be shared. Only groups with an explicit code (from the `name:code` CSV syntax)
* are shared. Groups without a code are always product-scoped.
*
* Returns a map of code -> { groupId, optionIds } for shared groups that have
* been pre-created.
*/
async buildSharedOptionGroupMap(ctx, rows) {
const sharedGroupMap = new Map();
// Collect the first occurrence of each explicit-code option group
const codeEntries = new Map();
for (const { product } of rows) {
for (const optionGroup of product.optionGroups) {
if (optionGroup.code && !codeEntries.has(optionGroup.code)) {
codeEntries.set(optionGroup.code, optionGroup);
}
}
}
// Pre-create each shared option group
for (const [code, optionGroup] of codeEntries) {
const ogMainTranslation = this.getTranslationByCodeOrFirst(optionGroup.translations, ctx.languageCode);
const groupId = await this.fastImporter.createProductOptionGroup({
code,
options: ogMainTranslation.values.map(() => ({})),
translations: optionGroup.translations.map(translation => ({
languageCode: translation.languageCode,
name: translation.name,
})),
});
const optionIds = new Map();
for (const [optionIndex, value] of ogMainTranslation.values.map((val, index) => [index, val])) {
const createdOptionId = await this.fastImporter.createProductOption({
productOptionGroupId: groupId,
code: (0, normalize_string_1.normalizeString)(value, '-'),
translations: optionGroup.translations.map(translation => ({
languageCode: translation.languageCode,
name: translation.values[optionIndex],
})),
});
optionIds.set(value, createdOptionId);
}
sharedGroupMap.set(code, { groupId, optionIds });
}
return sharedGroupMap;
}
};
exports.Importer = Importer;
exports.Importer = Importer = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [config_service_1.ConfigService,
import_parser_1.ImportParser,
channel_service_1.ChannelService,
facet_service_1.FacetService,
facet_value_service_1.FacetValueService,
tax_category_service_1.TaxCategoryService,
asset_importer_1.AssetImporter,
fast_importer_service_1.FastImporterService])
], Importer);
//# sourceMappingURL=importer.js.map