import-parser.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import { Injectable } from '@nestjs/common';
  2. import * as parse from 'csv-parse';
  3. import { Stream } from 'stream';
  4. import { normalizeString } from '../../../../../shared/normalize-string';
  5. import { unique } from '../../../../../shared/unique';
  6. export type BaseProductRecord = {
  7. name?: string;
  8. slug?: string;
  9. description?: string;
  10. assets?: string;
  11. optionGroups?: string;
  12. optionValues?: string;
  13. sku?: string;
  14. price?: string;
  15. taxCategory?: string;
  16. variantAssets?: string;
  17. facets?: string;
  18. };
  19. export type RawProductRecord = BaseProductRecord & { [customFieldName: string]: string };
  20. export interface ParsedProductVariant {
  21. optionValues: string[];
  22. sku: string;
  23. price: number;
  24. taxCategory: string;
  25. assetPaths: string[];
  26. facets: Array<{
  27. facet: string;
  28. value: string;
  29. }>;
  30. customFields: {
  31. [name: string]: string;
  32. };
  33. }
  34. export interface ParsedProduct {
  35. name: string;
  36. slug: string;
  37. description: string;
  38. assetPaths: string[];
  39. optionGroups: Array<{
  40. name: string;
  41. values: string[];
  42. }>;
  43. customFields: {
  44. [name: string]: string;
  45. };
  46. }
  47. export interface ParsedProductWithVariants {
  48. product: ParsedProduct;
  49. variants: ParsedProductVariant[];
  50. }
  51. export interface ParseResult<T> {
  52. results: T[];
  53. errors: string[];
  54. processed: number;
  55. }
  56. const requiredColumns: Array<keyof BaseProductRecord> = [
  57. 'name',
  58. 'slug',
  59. 'description',
  60. 'assets',
  61. 'optionGroups',
  62. 'optionValues',
  63. 'sku',
  64. 'price',
  65. 'taxCategory',
  66. 'variantAssets',
  67. 'facets',
  68. ];
  69. /**
  70. * Validates and parses CSV files into a data structure which can then be used to created new entities.
  71. */
  72. @Injectable()
  73. export class ImportParser {
  74. async parseProducts(input: string | Stream): Promise<ParseResult<ParsedProductWithVariants>> {
  75. const options: parse.Options = {
  76. trim: true,
  77. relax_column_count: true,
  78. };
  79. return new Promise<ParseResult<ParsedProductWithVariants>>((resolve, reject) => {
  80. let errors: string[] = [];
  81. if (typeof input === 'string') {
  82. parse(input, options, (err: any, records: string[][]) => {
  83. if (err) {
  84. errors = errors.concat(err);
  85. }
  86. if (records) {
  87. const parseResult = this.processRawRecords(records);
  88. errors = errors.concat(parseResult.errors);
  89. resolve({ results: parseResult.results, errors, processed: parseResult.processed });
  90. } else {
  91. resolve({ results: [], errors, processed: 0 });
  92. }
  93. });
  94. } else {
  95. const parser = parse(options);
  96. const records: string[][] = [];
  97. // input.on('open', () => input.pipe(parser));
  98. input.pipe(parser);
  99. parser.on('readable', () => {
  100. let record;
  101. // tslint:disable-next-line:no-conditional-assignment
  102. while ((record = parser.read())) {
  103. records.push(record);
  104. }
  105. });
  106. parser.on('error', reject);
  107. parser.on('end', () => {
  108. const parseResult = this.processRawRecords(records);
  109. errors = errors.concat(parseResult.errors);
  110. resolve({ results: parseResult.results, errors, processed: parseResult.processed });
  111. });
  112. }
  113. });
  114. }
  115. private processRawRecords(records: string[][]): ParseResult<ParsedProductWithVariants> {
  116. const results: ParsedProductWithVariants[] = [];
  117. const errors: string[] = [];
  118. let currentRow: ParsedProductWithVariants | undefined;
  119. const headerRow = records[0];
  120. const rest = records.slice(1);
  121. const totalProducts = rest.map(row => row[0]).filter(name => name.trim() !== '').length;
  122. const columnError = validateRequiredColumns(headerRow);
  123. if (columnError) {
  124. return { results: [], errors: [columnError], processed: 0 };
  125. }
  126. let line = 1;
  127. for (const record of rest) {
  128. line++;
  129. const columnCountError = validateColumnCount(headerRow, record);
  130. if (columnCountError) {
  131. errors.push(columnCountError + ` on line ${line}`);
  132. continue;
  133. }
  134. const r = mapRowToObject(headerRow, record);
  135. if (r.name) {
  136. if (currentRow) {
  137. populateOptionGroupValues(currentRow);
  138. results.push(currentRow);
  139. }
  140. currentRow = {
  141. product: parseProductFromRecord(r),
  142. variants: [parseVariantFromRecord(r)],
  143. };
  144. } else {
  145. if (currentRow) {
  146. currentRow.variants.push(parseVariantFromRecord(r));
  147. }
  148. }
  149. const optionError = validateOptionValueCount(r, currentRow);
  150. if (optionError) {
  151. errors.push(optionError + ` on line ${line}`);
  152. }
  153. }
  154. if (currentRow) {
  155. populateOptionGroupValues(currentRow);
  156. results.push(currentRow);
  157. }
  158. return { results, errors, processed: totalProducts };
  159. }
  160. }
  161. function populateOptionGroupValues(currentRow: ParsedProductWithVariants) {
  162. const values = currentRow.variants.map(v => v.optionValues);
  163. currentRow.product.optionGroups.forEach((og, i) => {
  164. og.values = unique(values.map(v => v[i]));
  165. });
  166. }
  167. function validateRequiredColumns(r: string[]): string | undefined {
  168. const rowKeys = r;
  169. const missing: string[] = [];
  170. for (const col of requiredColumns) {
  171. if (!rowKeys.includes(col)) {
  172. missing.push(col);
  173. }
  174. }
  175. if (missing.length) {
  176. return `The import file is missing the following columns: ${missing.map(m => `"${m}"`).join(', ')}`;
  177. }
  178. }
  179. function validateColumnCount(columns: string[], row: string[]): string | undefined {
  180. if (columns.length !== row.length) {
  181. return `Invalid Record Length: header length is ${columns.length}, got ${row.length}`;
  182. }
  183. }
  184. function mapRowToObject(columns: string[], row: string[]): { [key: string]: string } {
  185. return row.reduce((obj, val, i) => {
  186. return { ...obj, [columns[i]]: val };
  187. }, {});
  188. }
  189. function validateOptionValueCount(
  190. r: BaseProductRecord,
  191. currentRow?: ParsedProductWithVariants,
  192. ): string | undefined {
  193. if (!currentRow) {
  194. return;
  195. }
  196. const optionValues = parseStringArray(r.optionValues);
  197. if (currentRow.product.optionGroups.length !== optionValues.length) {
  198. return `The number of optionValues must match the number of optionGroups`;
  199. }
  200. }
  201. function parseProductFromRecord(r: RawProductRecord): ParsedProduct {
  202. const name = parseString(r.name);
  203. const slug = parseString(r.slug) || normalizeString(name, '-');
  204. return {
  205. name,
  206. slug,
  207. description: parseString(r.description),
  208. assetPaths: parseStringArray(r.assets),
  209. optionGroups: parseStringArray(r.optionGroups).map(ogName => ({
  210. name: ogName,
  211. values: [],
  212. })),
  213. customFields: parseCustomFields('product', r),
  214. };
  215. }
  216. function parseVariantFromRecord(r: RawProductRecord): ParsedProductVariant {
  217. return {
  218. optionValues: parseStringArray(r.optionValues),
  219. sku: parseString(r.sku),
  220. price: parseNumber(r.price),
  221. taxCategory: parseString(r.taxCategory),
  222. assetPaths: parseStringArray(r.variantAssets),
  223. facets: parseStringArray(r.facets).map(pair => {
  224. const [facet, value] = pair.split(':');
  225. return { facet, value };
  226. }),
  227. customFields: parseCustomFields('variant', r),
  228. };
  229. }
  230. function parseCustomFields(prefix: 'product' | 'variant', r: RawProductRecord): { [name: string]: string } {
  231. return Object.entries(r)
  232. .filter(([key, value]) => {
  233. return key.indexOf(`${prefix}:`) === 0;
  234. })
  235. .reduce((output, [key, value]) => {
  236. const fieldName = key.replace(`${prefix}:`, '');
  237. return {
  238. ...output,
  239. [fieldName]: value,
  240. };
  241. }, {});
  242. }
  243. function parseString(input?: string): string {
  244. return (input || '').trim();
  245. }
  246. function parseNumber(input?: string): number {
  247. return +(input || '').trim();
  248. }
  249. function parseStringArray(input?: string, separator = '|'): string[] {
  250. return (input || '')
  251. .trim()
  252. .split(separator)
  253. .map(s => s.trim())
  254. .filter(s => s !== '');
  255. }