Browse Source

feat(server): Create import-products cli command

Michael Bromley 7 years ago
parent
commit
4fb0f9c2fa

+ 26 - 8
server/cli/populate.ts

@@ -4,16 +4,28 @@ import * as path from 'path';
 import { Connection } from 'typeorm';
 
 import { logColored } from './cli-utils';
+// tslint:disable-next-line:no-var-requires
+const { Populator, Importer } = require('vendure');
 
 // tslint:disable:no-console
 export async function populate() {
     logColored('\nPopulating... (this may take a minute or two)\n');
     const app = await getApplicationRef();
     if (app) {
-        const { Populator, Importer } = require('vendure');
         const initialData = require('./assets/initial-data.json');
-        await populateInitialData(app, initialData, Populator);
-        await populateProducts(app, initialData, Importer);
+        await populateInitialData(app, initialData);
+        await populateProducts(app, initialData);
+        logColored('\nDone!');
+        await app.close();
+        process.exit(0);
+    }
+}
+
+export async function importProducts(csvPath: string, languageCode: string) {
+    logColored(`\nImporting from "${csvPath}"... (this may take a minute or two)\n`);
+    const app = await getApplicationRef();
+    if (app) {
+        await importProductsFromFile(app, csvPath, languageCode);
         logColored('\nDone!');
         await app.close();
         process.exit(0);
@@ -70,7 +82,7 @@ async function getApplicationRef(): Promise<INestApplication | undefined> {
     return app;
 }
 
-async function populateInitialData(app: INestApplication, initialData: any, Populator: any) {
+async function populateInitialData(app: INestApplication, initialData: any) {
     const populator = app.get(Populator);
     try {
         await populator.populateInitialData(initialData);
@@ -79,21 +91,27 @@ async function populateInitialData(app: INestApplication, initialData: any, Popu
     }
 }
 
-async function populateProducts(app: INestApplication, initialData: any, Importer: any) {
+async function populateProducts(app: INestApplication, initialData: any) {
     // copy the images to the import folder
     const images = path.join(__dirname, 'assets', 'images');
     const destination = path.join(process.cwd(), 'vendure', 'import-assets');
     await fs.copy(images, destination);
 
+    // import the csv of same product data
+    const sampleProductsFile = path.join(__dirname, 'assets', 'sample-products.csv');
+    await importProductsFromFile(app, sampleProductsFile, initialData.defaultLanguage);
+    await fs.emptyDir(destination);
+}
+
+async function importProductsFromFile(app: INestApplication, csvPath: string, languageCode: string) {
     // import the csv of same product data
     const importer = app.get(Importer);
-    const productData = await fs.readFile(path.join(__dirname, 'assets', 'sample-products.csv'), 'utf-8');
-    const importResult = await importer.parseAndImport(productData, initialData.defaultLanguage);
+    const productData = await fs.readFile(csvPath, 'utf-8');
+    const importResult = await importer.parseAndImport(productData, languageCode);
     if (importResult.errors.length) {
         console.error(`Error encountered when importing product data:`);
         console.error(importResult.errors.join('\n'));
     } else {
         console.log(`Imported ${importResult.importedCount} products`);
-        await fs.emptyDir(destination);
     }
 }

+ 10 - 1
server/cli/vendure-cli.ts

@@ -1,10 +1,11 @@
 #!/usr/bin/env node
 import * as program from 'commander';
+import * as path from 'path';
 import * as prompts from 'prompts';
 
 import { logColored } from './cli-utils';
 import { init } from './init';
-import { populate } from './populate';
+import { importProducts, populate } from './populate';
 // tslint:disable-next-line:no-var-requires
 const version = require('../../package.json').version;
 
@@ -43,6 +44,14 @@ program
     .action(async () => {
         await populate();
     });
+program
+    .command('import-products <csvFile>')
+    .option('-l, --language', 'Specify ISO 639-1 language code, e.g. "de", "es". Defaults to "en"')
+    .description('Import product data from the specified csv file')
+    .action(async (csvPath, command) => {
+        const filePath = path.join(process.cwd(), csvPath);
+        await importProducts(filePath, command.language);
+    });
 program.parse(process.argv);
 if (!process.argv.slice(2).length) {
     program.help();

+ 49 - 23
server/src/data-import/providers/importer/importer.ts

@@ -38,25 +38,13 @@ export class Importer {
         input: string | Stream,
         ctxOrLanguageCode: RequestContext | LanguageCode,
     ): Promise<ImportInfo> {
-        let ctx: RequestContext;
-        if (ctxOrLanguageCode instanceof RequestContext) {
-            ctx = ctxOrLanguageCode;
-        } else {
-            const channel = await this.channelService.getDefaultChannel();
-            ctx = new RequestContext({
-                isAuthorized: true,
-                authorizedAsOwnerOnly: false,
-                channel,
-                languageCode: ctxOrLanguageCode,
-            });
-        }
-
+        const ctx = await this.getRequestContext(ctxOrLanguageCode);
         const parsed = await this.importParser.parseProducts(input);
         if (parsed && parsed.results.length) {
             try {
-                const result = await this.importProducts(ctx, parsed.results);
+                const importErrors = await this.importProducts(ctx, parsed.results);
                 return {
-                    errors: parsed.errors,
+                    errors: parsed.errors.concat(importErrors),
                     importedCount: parsed.results.length,
                 };
             } catch (err) {
@@ -73,11 +61,35 @@ export class Importer {
         }
     }
 
-    private async importProducts(ctx: RequestContext, rows: ParsedProductWithVariants[]) {
+    private async getRequestContext(
+        ctxOrLanguageCode: RequestContext | LanguageCode,
+    ): Promise<RequestContext> {
+        if (ctxOrLanguageCode instanceof RequestContext) {
+            return ctxOrLanguageCode;
+        } else {
+            const channel = await this.channelService.getDefaultChannel();
+            return new RequestContext({
+                isAuthorized: true,
+                authorizedAsOwnerOnly: false,
+                channel,
+                languageCode: ctxOrLanguageCode,
+            });
+        }
+    }
+
+    /**
+     * Imports the products specified in the rows object. Return an array of error messages.
+     */
+    private async importProducts(ctx: RequestContext, rows: ParsedProductWithVariants[]): Promise<string[]> {
+        let errors: string[] = [];
         const languageCode = ctx.languageCode;
         const taxCategories = await this.taxCategoryService.findAll();
         for (const { product, variants } of rows) {
-            const productAssets = await this.createAssets(product.assetPaths);
+            const createProductAssets = await this.createAssets(product.assetPaths);
+            const productAssets = createProductAssets.assets;
+            if (createProductAssets.errors.length) {
+                errors = errors.concat(createProductAssets.errors);
+            }
             const createdProduct = await this.productService.create(ctx, {
                 featuredAssetId: productAssets.length ? (productAssets[0].id as string) : undefined,
                 assetIds: productAssets.map(a => a.id) as string[],
@@ -118,7 +130,11 @@ export class Importer {
             }
 
             for (const variant of variants) {
-                const variantAssets = await this.createAssets(variant.assetPaths);
+                const createVariantAssets = await this.createAssets(variant.assetPaths);
+                const variantAssets = createVariantAssets.assets;
+                if (createVariantAssets.errors.length) {
+                    errors = errors.concat(createVariantAssets.errors);
+                }
                 await this.productVariantService.create(ctx, createdProduct, {
                     featuredAssetId: variantAssets.length ? (variantAssets[0].id as string) : undefined,
                     assetIds: variantAssets.map(a => a.id) as string[],
@@ -135,18 +151,28 @@ export class Importer {
                 });
             }
         }
+        return errors;
     }
 
-    private async createAssets(assetPaths: string[]): Promise<Asset[]> {
+    private async createAssets(assetPaths: string[]): Promise<{ assets: Asset[]; errors: string[] }> {
         const assets: Asset[] = [];
+        const errors: string[] = [];
         const { importAssetsDir } = this.configService.importExportOptions;
         for (const assetPath of assetPaths) {
             const filename = path.join(importAssetsDir, assetPath);
-            const stream = fs.createReadStream(filename);
-            const asset = await this.assetService.createFromFileStream(stream);
-            assets.push(asset);
+            if (fs.existsSync(filename)) {
+                try {
+                    const stream = fs.createReadStream(filename);
+                    const asset = await this.assetService.createFromFileStream(stream);
+                    assets.push(asset);
+                } catch (err) {
+                    errors.push(err.toString());
+                }
+            } else {
+                errors.push(`File "${filename}" does not exist`);
+            }
         }
-        return assets;
+        return { assets, errors };
     }
 
     /**

+ 1 - 1
server/src/entity/product-option-group/product-option-group.entity.ts

@@ -17,7 +17,7 @@ export class ProductOptionGroup extends VendureEntity implements Translatable, H
 
     name: LocaleString;
 
-    @Column({ unique: true })
+    @Column()
     code: string;
 
     @OneToMany(type => ProductOptionGroupTranslation, translation => translation.base, { eager: true })

+ 1 - 1
shared/normalize-string.ts

@@ -4,7 +4,7 @@
  * Based on https://stackoverflow.com/a/37511463/772859
  */
 export function normalizeString(input: string, spaceReplacer = ' '): string {
-    return input
+    return (input || '')
         .normalize('NFD')
         .replace(/[\u0300-\u036f]/g, '')
         .toLowerCase()