├── src ├── importers │ ├── templates │ │ ├── attributes.json │ │ ├── taxrules.json │ │ ├── category.json │ │ ├── attribute_code_gender.json │ │ ├── attribute_code_size.json │ │ ├── attribute_type_select.json │ │ ├── attribute_type_multiselect.json │ │ ├── attribute_code_color.json │ │ ├── attribute_type_numeric.json │ │ ├── attribute_type_input.json │ │ ├── attribute_type_wysiwyg.json │ │ ├── localizedfields.json │ │ └── product.json │ ├── category.js │ ├── basic.js │ └── product.js ├── lib │ ├── promise.js │ ├── pimcore-api │ │ └── index.js │ ├── message.js │ └── attribute.js ├── assetsrv.js └── index.js ├── doc ├── setup.png ├── vs-video.png ├── vs-pimcore-1.png ├── vs-pimcore-2.png ├── setup-success.png ├── pimcore2vuestorefront-architecture.png └── Pimcore2vuestorefront-architecture-draw-io.xml ├── README.md ├── .vscode └── launch.json ├── .gitignore ├── config.example.json ├── LICENSE ├── package.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── setup.js /src/importers/templates/attributes.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DivanteLtd/pimcore2vuestorefront/HEAD/doc/setup.png -------------------------------------------------------------------------------- /doc/vs-video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DivanteLtd/pimcore2vuestorefront/HEAD/doc/vs-video.png -------------------------------------------------------------------------------- /doc/vs-pimcore-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DivanteLtd/pimcore2vuestorefront/HEAD/doc/vs-pimcore-1.png -------------------------------------------------------------------------------- /doc/vs-pimcore-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DivanteLtd/pimcore2vuestorefront/HEAD/doc/vs-pimcore-2.png -------------------------------------------------------------------------------- /doc/setup-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DivanteLtd/pimcore2vuestorefront/HEAD/doc/setup-success.png -------------------------------------------------------------------------------- /doc/pimcore2vuestorefront-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DivanteLtd/pimcore2vuestorefront/HEAD/doc/pimcore2vuestorefront-architecture.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project has been moved 2 | 3 | This repository is currently **not being actively developed**. Please check our [**Core Shop Vue Storefront Bridge**](https://github.com/DivanteLtd/coreshop-vsbridge) which is our official Vue Storefront support for Pimcore + Coreshop module. 4 | 5 | -------------------------------------------------------------------------------- /src/lib/promise.js: -------------------------------------------------------------------------------- 1 | /* 2 | * serial executes Promises sequentially. 3 | * @param {funcs} An array of funcs that return promises. 4 | * @example 5 | * const urls = ['/url1', '/url2', '/url3'] 6 | * serial(urls.map(url => () => $.ajax(url))) 7 | * .then(console.log.bind(console)) 8 | */ 9 | const serial = funcs => 10 | funcs.reduce((promise, func) => 11 | promise.then(result => func().then(Array.prototype.concat.bind(result))), Promise.resolve([])) 12 | 13 | exports.serial = serial -------------------------------------------------------------------------------- /src/assetsrv.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const express = require('express'); 3 | const path = require('path') 4 | 5 | let app = express(); 6 | app.server = http.createServer(app); 7 | 8 | let rootDir = path.join(__dirname, '../var/assets/') 9 | 10 | console.log('Root dir', rootDir) 11 | 12 | app.use('/assets', express.static(rootDir)) 13 | 14 | app.server.listen(process.env.PORT || 8081, () => { 15 | console.log(`Started on port ${app.server.address().port}`); 16 | }); 17 | 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "type": "node", 10 | "request": "launch", 11 | "name": "Launch Program", 12 | "program": "${workspaceFolder}/src/index.js", 13 | "args": [ 14 | "products" 15 | ] 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /src/importers/templates/taxrules.json: -------------------------------------------------------------------------------- 1 | [{"id":1,"code":"Rule1","priority":0,"position":0,"customer_tax_class_ids":[3],"product_tax_class_ids":[2],"tax_rate_ids":[3],"calculate_subtotal":false,"rates":[{"id":3,"tax_country_id":"US","tax_region_id":33,"region_name":"MI","tax_postcode":"*","rate":8.00,"code":"US-MI-*-Rate1","titles":[]}]}, 2 | {"id":2,"code":"Poland","priority":0,"position":0,"customer_tax_class_ids":[3],"product_tax_class_ids":[2],"tax_rate_ids":[4],"calculate_subtotal":false,"rates":[{"id":4,"tax_country_id":"PL","tax_region_id":0,"tax_postcode":"*","rate":23.0,"code":"VAT23%","titles":[]}]} 3 | ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | script-compiled.js 2 | var/log/general.log 3 | var/attributes.json 4 | var/log/products_0.txt 5 | var/log/products_1.txt 6 | var/log/products_10.txt 7 | var/log/products_11.txt 8 | var/log/products_12.txt 9 | var/log/products_13.txt 10 | var/log/products_14.txt 11 | var/log/products_15.txt 12 | var/log/products_16.txt 13 | var/log/products_17.txt 14 | var/log/products_18.txt 15 | var/log/products_19.txt 16 | var/log/products_2.txt 17 | var/log/products_3.txt 18 | var/log/products_4.txt 19 | var/log/products_5.txt 20 | var/log/products_6.txt 21 | var/log/products_7.txt 22 | var/log/products_8.txt 23 | var/assets/* 24 | var/log/assets/* 25 | var/indexMetadata.json -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "elasticsearch": { 3 | "host": "", 4 | "indexName": "vue_storefront_catalog" 5 | }, 6 | "pimcore": { 7 | "url": "http://localhost/", 8 | "locale": "en_GB", 9 | "assetsPath": "../var/assets", 10 | "apiKey": "", 11 | "downloadImages": true, 12 | "rootCategoryId": 11148, 13 | "productClass": { 14 | "id": 12, 15 | "name": "Product", 16 | "map": { 17 | "price": "price", 18 | "name": "seoname", 19 | "description": "description" 20 | } 21 | }, 22 | "categoryClass": { 23 | "id": 14, 24 | "name": "ProductCategory", 25 | "map": { 26 | "name": "name", 27 | "description": "description" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/importers/templates/category.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 29, 3 | "parent_id": 2, 4 | "name": "Promotions", 5 | "is_active": true, 6 | "children_data": [ 7 | { 8 | "is_active": true, 9 | "parent_id": 29, 10 | "name": "Women Sale", 11 | "id": 30, 12 | "children_data": [] 13 | }, 14 | { 15 | "is_active": true, 16 | "parent_id": 29, 17 | "name": "Men Sale", 18 | "id": 31, 19 | "children_data": [] 20 | }, 21 | { 22 | "is_active": true, 23 | "parent_id": 29, 24 | "name": "Pants", 25 | "id": 32, 26 | "children_data": [] 27 | }, 28 | { 29 | "is_active": true, 30 | "parent_id": 29, 31 | "product_count": 192, 32 | "name": "Tees", 33 | "id": 33, 34 | "children_data": [] 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /src/importers/templates/attribute_code_gender.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": true, 4 | "used_for_sort_by": false, 5 | "is_filterable": true, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": false, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": false, 10 | "position": 0, 11 | "apply_to": [], 12 | "is_searchable": "0", 13 | "is_visible_in_advanced_search": "0", 14 | "is_comparable": "0", 15 | "is_used_for_promo_rules": "1", 16 | "is_visible_on_front": "0", 17 | "used_in_product_listing": "1", 18 | "is_visible": true, 19 | "scope": "global", 20 | "attribute_id": 142, 21 | "attribute_code": "size", 22 | "frontend_input": "select", 23 | "entity_type_id": "4", 24 | "is_required": false, 25 | "options": [ 26 | ], 27 | "is_user_defined": true, 28 | "default_frontend_label": "Size", 29 | "frontend_labels": null, 30 | "backend_type": "int", 31 | "is_unique": "0", 32 | "validation_rules": [], 33 | "id": 142, 34 | "default_value": "91" 35 | } -------------------------------------------------------------------------------- /src/importers/templates/attribute_code_size.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": true, 4 | "used_for_sort_by": false, 5 | "is_filterable": true, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": false, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": false, 10 | "position": 0, 11 | "apply_to": [], 12 | "is_searchable": "0", 13 | "is_visible_in_advanced_search": "0", 14 | "is_comparable": "0", 15 | "is_used_for_promo_rules": "1", 16 | "is_visible_on_front": "0", 17 | "used_in_product_listing": "1", 18 | "is_visible": true, 19 | "scope": "global", 20 | "attribute_id": 142, 21 | "attribute_code": "size", 22 | "frontend_input": "select", 23 | "entity_type_id": "4", 24 | "is_required": false, 25 | "options": [ 26 | ], 27 | "is_user_defined": true, 28 | "default_frontend_label": "Size", 29 | "frontend_labels": null, 30 | "backend_type": "int", 31 | "is_unique": "0", 32 | "validation_rules": [], 33 | "id": 142, 34 | "default_value": "91" 35 | } -------------------------------------------------------------------------------- /src/importers/templates/attribute_type_select.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": true, 4 | "used_for_sort_by": false, 5 | "is_filterable": true, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": false, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": false, 10 | "position": 0, 11 | "apply_to": [], 12 | "is_searchable": "0", 13 | "is_visible_in_advanced_search": "0", 14 | "is_comparable": "0", 15 | "is_used_for_promo_rules": "1", 16 | "is_visible_on_front": "0", 17 | "used_in_product_listing": "1", 18 | "is_visible": true, 19 | "scope": "global", 20 | "attribute_id": 142, 21 | "attribute_code": "size", 22 | "frontend_input": "select", 23 | "entity_type_id": "4", 24 | "is_required": false, 25 | "options": [ 26 | ], 27 | "is_user_defined": true, 28 | "default_frontend_label": "Size", 29 | "frontend_labels": null, 30 | "backend_type": "int", 31 | "is_unique": "0", 32 | "validation_rules": [], 33 | "id": 142, 34 | "default_value": "91" 35 | } -------------------------------------------------------------------------------- /src/importers/templates/attribute_type_multiselect.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": true, 4 | "used_for_sort_by": false, 5 | "is_filterable": true, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": false, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": false, 10 | "position": 0, 11 | "apply_to": [], 12 | "is_searchable": "0", 13 | "is_visible_in_advanced_search": "0", 14 | "is_comparable": "0", 15 | "is_used_for_promo_rules": "1", 16 | "is_visible_on_front": "0", 17 | "used_in_product_listing": "1", 18 | "is_visible": true, 19 | "scope": "global", 20 | "attribute_id": 142, 21 | "attribute_code": "size", 22 | "frontend_input": "select", 23 | "entity_type_id": "4", 24 | "is_required": false, 25 | "options": [ 26 | ], 27 | "is_user_defined": true, 28 | "default_frontend_label": "Size", 29 | "frontend_labels": null, 30 | "backend_type": "int", 31 | "is_unique": "0", 32 | "validation_rules": [], 33 | "id": 142, 34 | "default_value": "91" 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Divante Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/importers/templates/attribute_code_color.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": true, 4 | "used_for_sort_by": false, 5 | "is_filterable": true, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": true, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": true, 10 | "position": 0, 11 | "apply_to": [ 12 | "simple", 13 | "virtual", 14 | "configurable" 15 | ], 16 | "is_searchable": "0", 17 | "is_visible_in_advanced_search": "0", 18 | "is_comparable": "0", 19 | "is_used_for_promo_rules": "1", 20 | "is_visible_on_front": "0", 21 | "used_in_product_listing": "1", 22 | "is_visible": true, 23 | "scope": "global", 24 | "attribute_id": 93, 25 | "attribute_code": "color", 26 | "frontend_input": "select", 27 | "entity_type_id": "4", 28 | "is_required": false, 29 | "options": [ 30 | ], 31 | "is_user_defined": true, 32 | "default_frontend_label": "Color", 33 | "frontend_labels": null, 34 | "backend_type": "int", 35 | "default_value": "49", 36 | "is_unique": "0", 37 | "validation_rules": [], 38 | "id": 93 39 | } -------------------------------------------------------------------------------- /src/importers/templates/attribute_type_numeric.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": false, 4 | "used_for_sort_by": false, 5 | "is_filterable": false, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": true, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": true, 10 | "position": 0, 11 | "apply_to": [ 12 | "simple", 13 | "virtual", 14 | "bundle", 15 | "downloadable", 16 | "configurable" 17 | ], 18 | "is_searchable": "0", 19 | "is_visible_in_advanced_search": "0", 20 | "is_comparable": "0", 21 | "is_used_for_promo_rules": "0", 22 | "is_visible_on_front": "0", 23 | "used_in_product_listing": "0", 24 | "is_visible": true, 25 | "scope": "global", 26 | "attribute_id": 82, 27 | "attribute_code": "weight", 28 | "frontend_input": "weight", 29 | "entity_type_id": "4", 30 | "is_required": false, 31 | "options": [], 32 | "is_user_defined": false, 33 | "default_frontend_label": "Weight", 34 | "frontend_labels": null, 35 | "backend_type": "decimal", 36 | "is_unique": "0", 37 | "validation_rules": [], 38 | "id": 82 39 | } -------------------------------------------------------------------------------- /src/importers/templates/attribute_type_input.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": false, 4 | "used_for_sort_by": false, 5 | "is_filterable": false, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": true, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": true, 10 | "position": 0, 11 | "apply_to": [ 12 | "simple", 13 | "virtual", 14 | "bundle", 15 | "downloadable", 16 | "configurable" 17 | ], 18 | "is_searchable": "0", 19 | "is_visible_in_advanced_search": "0", 20 | "is_comparable": "0", 21 | "is_used_for_promo_rules": "0", 22 | "is_visible_on_front": "0", 23 | "used_in_product_listing": "0", 24 | "is_visible": true, 25 | "scope": "store", 26 | "attribute_id": 85, 27 | "attribute_code": "meta_keyword", 28 | "frontend_input": "textarea", 29 | "entity_type_id": "4", 30 | "is_required": false, 31 | "options": [], 32 | "is_user_defined": false, 33 | "default_frontend_label": "Meta Keywords", 34 | "frontend_labels": null, 35 | "backend_type": "text", 36 | "is_unique": "0", 37 | "validation_rules": [], 38 | "id": 85 39 | } -------------------------------------------------------------------------------- /src/importers/templates/attribute_type_wysiwyg.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_wysiwyg_enabled": false, 3 | "is_html_allowed_on_front": false, 4 | "used_for_sort_by": false, 5 | "is_filterable": false, 6 | "is_filterable_in_search": false, 7 | "is_used_in_grid": true, 8 | "is_visible_in_grid": false, 9 | "is_filterable_in_grid": true, 10 | "position": 0, 11 | "apply_to": [ 12 | "simple", 13 | "virtual", 14 | "bundle", 15 | "downloadable", 16 | "configurable" 17 | ], 18 | "is_searchable": "0", 19 | "is_visible_in_advanced_search": "0", 20 | "is_comparable": "0", 21 | "is_used_for_promo_rules": "0", 22 | "is_visible_on_front": "0", 23 | "used_in_product_listing": "0", 24 | "is_visible": true, 25 | "scope": "store", 26 | "attribute_id": 85, 27 | "attribute_code": "meta_keyword", 28 | "frontend_input": "textarea", 29 | "entity_type_id": "4", 30 | "is_required": false, 31 | "options": [], 32 | "is_user_defined": false, 33 | "default_frontend_label": "Meta Keywords", 34 | "frontend_labels": null, 35 | "backend_type": "text", 36 | "is_unique": "0", 37 | "validation_rules": [], 38 | "id": 85 39 | } -------------------------------------------------------------------------------- /src/lib/pimcore-api/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const unirest = require('unirest') 3 | 4 | class PimcoreApiClient { 5 | 6 | /** 7 | * Setup Pimcore Api Client 8 | * @param {object} config configuration with "apiKey" and "url" keys for Pimcore API endpoint 9 | */ 10 | constructor(config) { 11 | this.config = config 12 | 13 | if (!config.apiKey || !config.url) 14 | throw Error('apiKey and url are required config keys for Pimcore Api Client') 15 | 16 | this.baseUrl = `${config.url}webservice/rest/` 17 | this.apiKey = config.apiKey 18 | this.client = unirest 19 | } 20 | 21 | _setupRequest(unirest) { 22 | return unirest.headers({'Accept': 'application/json', 'Content-Type': 'application/json'}) 23 | } 24 | _setupUrl(endpointName) { 25 | return this.baseUrl + endpointName + '?apikey=' + this.apiKey 26 | } 27 | post(endpointName) { 28 | return this._setupRequest(this.client.post(this._setupUrl(endpointName))) 29 | } 30 | 31 | get(endpointName) { 32 | return this._setupRequest(this.client.get(this._setupUrl(endpointName))) 33 | } 34 | 35 | put(endpointName) { 36 | return this._setupRequest(client.put(this._setupUrl(endpointName))) 37 | } 38 | 39 | delete(endpointName) { 40 | return this._setupRequest(client.delete(this._setupUrl(endpointName))) 41 | } 42 | 43 | } 44 | module.exports = PimcoreApiClient -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pimcore2vuestorefront", 3 | "version": "1.0.0", 4 | "description": "Pimcore To vue-storefront bridge", 5 | "main": "babel-node setup.js", 6 | "dependencies": { 7 | "babel-cli": "^6.26.0", 8 | "command-exists": "^1.2.2", 9 | "command-router": "^1.0.1", 10 | "elasticdump": "^3.3.1", 11 | "elasticsearch": "^14.0.0", 12 | "empty-dir": "^0.2.1", 13 | "express": "^4.16.2", 14 | "fs-exists-sync": "^0.1.0", 15 | "inquirer": "^4.0.1", 16 | "is-windows": "^1.0.1", 17 | "jsonfile": "^4.0.0", 18 | "lockfile": "^1.0.3", 19 | "lodash": "^4.17.4", 20 | "memored": "^1.1.1", 21 | "mkdirp": "^0.5.1", 22 | "node-mutex": "^0.2.2", 23 | "print-message": "^2.1.0", 24 | "promise-limit": "^2.5.0", 25 | "shelljs": "^0.7.8", 26 | "unirest": "^0.5.1", 27 | "url-parse": "^1.2.0", 28 | "valid-url": "^1.0.9" 29 | }, 30 | "devDependencies": {}, 31 | "scripts": { 32 | "start": "babel-node setup.js", 33 | "test": "echo \"Error: no test specified\" && exit 1", 34 | "restore": "node node_modules/elasticdump/bin/elasticdump --input=var/catalog.json --output=http://localhost:9200/vue_storefront_pimcore", 35 | "dump": "node node_modules/elasticdump/bin/elasticdump --output=var/catalog.json --input=http://localhost:9200/vue_storefront_pimcore" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/pkarw/pimcore2vuestorefront.git" 40 | }, 41 | "author": "Piotr Karwatka", 42 | "license": "ISC", 43 | "bugs": { 44 | "url": "https://github.com/pkarw/pimcore2vuestorefront/issues" 45 | }, 46 | "homepage": "https://github.com/pkarw/pimcore2vuestorefront#readme" 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/message.js: -------------------------------------------------------------------------------- 1 | const message = require('print-message') 2 | 3 | /** 4 | * Message management 5 | */ 6 | class Message { 7 | /** 8 | * Renders informative message 9 | * 10 | * @param text 11 | */ 12 | static info (text) { 13 | text = Array.isArray(text) ? text : [text] 14 | 15 | message([ 16 | ...text 17 | ], {color: 'blue', border: false, marginTop: 1}) 18 | } 19 | 20 | /** 21 | * Renders error message 22 | * 23 | * @param text 24 | * @param logFile 25 | */ 26 | static error (text, logFile = process.stderr) { 27 | text = Array.isArray(text) ? text : [text] 28 | 29 | // show trace if exception occurred 30 | if (text[0] instanceof Error) { 31 | text = text[0].stack.split('\n') 32 | } 33 | 34 | let logDetailsInfo = `Please check log file for details: ${logFile}` 35 | 36 | message([ 37 | 'ERROR', 38 | '', 39 | ...text, 40 | '', 41 | logDetailsInfo 42 | ], {borderColor: 'red', marginBottom: 1}) 43 | 44 | process.exit(1) 45 | } 46 | 47 | /** 48 | * Render warning message 49 | * 50 | * @param text 51 | */ 52 | static warning (text) { 53 | text = Array.isArray(text) ? text : [text] 54 | 55 | message([ 56 | 'WARNING:', 57 | ...text 58 | ], {color: 'yellow', border: false, marginTop: 1}) 59 | } 60 | 61 | /** 62 | * Render block info message 63 | * 64 | * @param text 65 | * @param isLastMessage 66 | */ 67 | static greeting (text, isLastMessage = false) { 68 | text = Array.isArray(text) ? text : [text] 69 | 70 | message([ 71 | ...text 72 | ], Object.assign(isLastMessage ? {marginTop: 1} : {}, {borderColor: 'green', marginBottom: 1})) 73 | } 74 | } 75 | 76 | module.exports = Message -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | Already a JS/Vue.js developer? Pick an issue, push a PR and instantly become a member of the vue-storefront contributors community. 4 | We've marked some issues as "Easy first pick" to make it easier for newcomers to begin! 5 | 6 | Thank you for your interest in, and engagement! 7 | 8 | Before you type an issue please check: 9 | 10 | Main readme - https://github.com/DivanteLtd/vue-storefront/blob/master/README.md 11 | 12 | ## Issue reporting guidelines: 13 | 14 | Always define type of issue: 15 | * Bug report 16 | * Feature request 17 | 18 | While writing issues, be as specific as possible 19 | All requests regarding support with implementation or application setup should be sent to contributors@vuestorefront.io 20 | 21 | ## Pull requests 22 | 23 | Here’s how to submit a pull request: 24 | 25 | 1. Fork the repository and clone it locally. Connect your local repository to the original “upstream” repository by adding it as a remote repository. Pull in changes from “upstream” often in order to stay up to date so that when you submit your pull request, merge conflicts will be less likely. 26 | 2. Create a branch for your edits. Use the following branch naming conventions: 27 | * bugfix/task-title 28 | * feature/task-name 29 | 3. Reference any relevant issues or supporting documentation in your PR (ex. “OLOY: 39. Issue title.”). 30 | 4. Include screenshots of the before and after if your changes include differences in HTML/CSS. Drag and drop the images into the body of your pull request. 31 | 5.Test your changes! Run your changes against any existing tests and create new ones when needed. Make sure your changes don’t break the existing project. 32 | 33 | If you have found a potential security vulnerability, please DO NOT report it on the public issue tracker. Instead, send it to us at contributors@vuestorefront.io. We will work with you to verify and fix it as soon as possible. 34 | -------------------------------------------------------------------------------- /src/importers/category.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const fs = require('fs') 3 | const path = require('path') 4 | const shell = require('shelljs') 5 | const attribute = require('../lib/attribute') 6 | 7 | module.exports = class { 8 | constructor(config, api, db) { 9 | this.config = config 10 | this.db = db 11 | this.api = api 12 | this.single = this.single.bind(this) 13 | } 14 | 15 | /** 16 | * This is an EXAMPLE of custom Product / entity mapper; you can write your own to map the Pimcore entities to vue-storefront data format (see: templates/product.json for reference) 17 | * @returns Promise 18 | */ 19 | single(pimcoreObjectData, convertedObject, childObjects, level = 1, parent_id = null) { 20 | return new Promise((resolve, reject) => { 21 | console.log('Helo from custom category converter for', convertedObject.id) 22 | convertedObject.url_key = pimcoreObjectData.key // pimcoreObjectData.path? 23 | convertedObject.level = level 24 | if (parent_id != null) 25 | convertedObject.parent_id = parent_id 26 | else 27 | convertedObject.parent_id = pimcoreObjectData.parentId 28 | 29 | let subPromises = [] 30 | 31 | convertedObject.children_data = [] // clear the options 32 | if (childObjects && childObjects.length){ 33 | // here we're flattening the child array out, because it's specific to pimcore that children can have children here :) 34 | 35 | let childObjectsFlattened = _.flattenDeep(childObjects) 36 | 37 | for(let childObject of childObjectsFlattened) { 38 | if(childObject.src.parentId === convertedObject.id) { 39 | console.log('Adding category child for ', convertedObject.name, convertedObject.id, childObject.dst.name) 40 | let confChild = { 41 | name: childObject.dst.name, 42 | id: childObject.dst.id, 43 | parent_id: convertedObject.id, 44 | is_active: true, 45 | level: level + 1, 46 | children_data: childObject.dst.children_data 47 | } 48 | 49 | convertedObject.children_data.push(confChild) 50 | } 51 | } 52 | console.debug(' - Category children for: ', convertedObject.id, convertedObject.children_data.length, convertedObject.children_data) 53 | } 54 | 55 | Promise.all(subPromises).then(results => { 56 | resolve({ src: pimcoreObjectData, dst: convertedObject }) 57 | }).catch((reason) => { console.error(reason) }) 58 | }) 59 | } 60 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hi@divante.co. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /src/importers/templates/localizedfields.json: -------------------------------------------------------------------------------- 1 | { type: 'localizedfields', 2 | value: [ 3 | { type: 'input', 4 | value: 'Classic FG Jr', 5 | name: 'name', 6 | language: 'en_GB' 7 | }, 8 | { type: 'input', 9 | value: 'Football Shoes Classic FG Jr', 10 | name: 'seoname', 11 | language: 'en_GB' 12 | }, 13 | { type: 'input', 14 | value: 'Black / White', 15 | name: 'colorName', 16 | language: 'en_GB' 17 | }, 18 | { type: 'numeric', 19 | value: '33.7000', 20 | name: 'price', 21 | language: 'en_GB' 22 | }, 23 | { type: 'numeric', 24 | value: null, 25 | name: 'priceOld', 26 | language: 'en_GB' 27 | }, 28 | { type: 'checkbox', 29 | value: false, 30 | name: 'fromPrice', 31 | language: 'en_GB' 32 | }, 33 | { type: 'wysiwyg', 34 | value: '\n', 35 | name: 'description', 36 | language: 'en_GB' 37 | }, 38 | { type: 'wysiwyg', 39 | value: null, 40 | name: 'material', 41 | language: 'en_GB' 42 | }, 43 | { type: 'multihref', 44 | value: [], 45 | name: 'downloads', 46 | language: 'en_GB' 47 | }, 48 | { type: 'multihref', 49 | value: [], 50 | name: 'videos', 51 | language: 'en_GB' 52 | }, 53 | { type: 'href', value: null, name: 'rotation', language: 'en_GB' 54 | }, 55 | { type: 'input', 56 | value: null, 57 | name: 'youtubeVideo', 58 | language: 'en_GB' 59 | }, 60 | { type: 'calculatedValue', 61 | value: null, 62 | name: 'textsAvailable', 63 | language: 'en_GB' 64 | }, 65 | { type: 'input', 66 | value: 'Classic FG Jr', 67 | name: 'name', 68 | language: 'de_AT' 69 | }, 70 | { type: 'input', 71 | value: 'Football Shoes Classic FG Jr', 72 | name: 'seoname', 73 | language: 'de_AT' 74 | }, 75 | { type: 'input', 76 | value: 'Black / White', 77 | name: 'colorName', 78 | language: 'de_AT' 79 | }, 80 | { type: 'numeric', value: null, name: 'price', language: 'de_AT' 81 | }, 82 | { type: 'numeric', 83 | value: null, 84 | name: 'priceOld', 85 | language: 'de_AT' 86 | }, 87 | { type: 'checkbox', 88 | value: false, 89 | name: 'fromPrice', 90 | language: 'de_AT' 91 | }, 92 | { type: 'wysiwyg', 93 | value: '\n', 94 | name: 'description', 95 | language: 'de_AT' 96 | }, 97 | { type: 'wysiwyg', 98 | value: null, 99 | name: 'material', 100 | language: 'de_AT' 101 | }, 102 | { type: 'multihref', 103 | value: [], 104 | name: 'downloads', 105 | language: 'de_AT' 106 | }, 107 | { type: 'multihref', 108 | value: [], 109 | name: 'videos', 110 | language: 'de_AT' 111 | }, 112 | { type: 'href', value: null, name: 'rotation', language: 'de_AT' 113 | }, 114 | { type: 'input', 115 | value: null, 116 | name: 'youtubeVideo', 117 | language: 'de_AT' 118 | }, 119 | { type: 'calculatedValue', 120 | value: null, 121 | name: 'textsAvailable', 122 | language: 'de_AT' 123 | }, 124 | { type: 'input', value: null, name: 'name', language: 'fr_FR' 125 | }, 126 | { type: 'input', value: null, name: 'seoname', language: 'fr_FR' 127 | }, 128 | { type: 'input', 129 | value: null, 130 | name: 'colorName', 131 | language: 'fr_FR' 132 | }, 133 | { type: 'numeric', value: null, name: 'price', language: 'fr_FR' 134 | }, 135 | { type: 'numeric', 136 | value: null, 137 | name: 'priceOld', 138 | language: 'fr_FR' 139 | }, 140 | { type: 'checkbox', 141 | value: false, 142 | name: 'fromPrice', 143 | language: 'fr_FR' 144 | }, 145 | { type: 'wysiwyg', 146 | value: null, 147 | name: 'description', 148 | language: 'fr_FR' 149 | }, 150 | { type: 'wysiwyg', 151 | value: null, 152 | name: 'material', 153 | language: 'fr_FR' 154 | }, 155 | { type: 'multihref', 156 | value: [], 157 | name: 'downloads', 158 | language: 'fr_FR' 159 | }, 160 | { type: 'multihref', 161 | value: [], 162 | name: 'videos', 163 | language: 'fr_FR' 164 | }, 165 | { type: 'href', value: null, name: 'rotation', language: 'fr_FR' 166 | }, 167 | { type: 'input', 168 | value: null, 169 | name: 'youtubeVideo', 170 | language: 'fr_FR' 171 | }, 172 | { type: 'calculatedValue', 173 | value: null, 174 | name: 'textsAvailable', 175 | language: 'fr_FR' 176 | } 177 | ], 178 | name: 'localizedfields', 179 | language: null 180 | } -------------------------------------------------------------------------------- /src/lib/attribute.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const memored = require('memored'); // TODO: interprocess cache - can be used for synchronizing the attributes between processes 4 | const lockFile = require('lockfile') 5 | const attrFile = path.join(__dirname, '../../var/attributes.json') 6 | const attrLockFile = path.join(__dirname, '../../var/attributes.json.lock') 7 | const jsonFile = require('jsonfile') 8 | 9 | let attrHash = {} 10 | 11 | exports.getMap = () => { 12 | return attrHash 13 | } 14 | 15 | 16 | function mapToVS (attributeCode, attributeType, attributeValue) { 17 | return new Promise((resolve,reject) => { 18 | 19 | console.debug('Locking attributes file mutex') 20 | lockFile.lockSync(attrLockFile) // TODO: lock attributes per attribute not global lock - BUT then we need to ensure the attribute_id to be incremental 21 | try { 22 | attrHash = jsonFile.readFileSync(attrFile) 23 | console.debug('Attributes hash loaded', attrHash) 24 | } catch (err) { 25 | attrHash = {} 26 | console.log('Wrong attributes file format ', err.message) 27 | } 28 | 29 | 30 | let attr = attrHash[attributeCode] 31 | if (! attr) { 32 | let maxAttrId = Object.keys(attrHash).length + 1 33 | attr = attributeTemplate(attributeCode, attributeType) 34 | attr.id = maxAttrId 35 | attr.attribute_id = maxAttrId 36 | 37 | attrHash[attributeCode] = attr 38 | maxAttrId++ 39 | } 40 | if (attr.frontend_input == 'select') { 41 | let existingOption = attr.options.find((option) => { return option.label == attributeValue}) 42 | if(!existingOption) { 43 | let lastOption = attr.options.length > 0 ? attr.options[attr.options.length-1] : null // we can use memored or elastic search to store each option per each attribute separately - to keep the same indexes between processes for example key would be: $attribute_code$$attribute_value = 14 44 | // OR SEND MODIFIED attributes to the workers each time attrHash changes: https://nodejs.org/api/cluster.html#cluster_cluster_workers 45 | // OR WORK ON MUTEXES https://github.com/ttiny/mutex-node 46 | // OR WORK ON FILE LOCKS https://www.npmjs.com/package/lockfile 47 | let optIndex = 1 48 | if (lastOption) { 49 | optIndex = lastOption.value + 1 50 | } 51 | attr.options.push({ 52 | label: attributeValue, 53 | value: optIndex 54 | }) 55 | jsonFile.writeFileSync(attrFile, attrHash, {spaces: 2}) 56 | lockFile.unlockSync(attrLockFile) 57 | console.debug('Attributes file has been written and unlocked') 58 | resolve(optIndex) 59 | } else { 60 | jsonFile.writeFileSync(attrFile, attrHash, {spaces: 2}) 61 | lockFile.unlockSync(attrLockFile) 62 | console.debug('Attributes file has been written and unlocked') 63 | resolve(existingOption.value) // non select attrs 64 | } 65 | 66 | 67 | } else { 68 | jsonFile.writeFileSync(attrFile, attrHash, {spaces: 2}) 69 | lockFile.unlockSync(attrLockFile) 70 | console.debug('Attributes file has been written and unlocked') 71 | resolve(attributeValue) 72 | // we're fine here for decimal and varchar attributes 73 | } 74 | }) 75 | } 76 | 77 | 78 | function mapElements(result, elements, locale = null) { 79 | let subpromises = [] 80 | for(let attr of elements) { 81 | if(['multiselect', 'input', 'wysiwyg', 'numeric'].indexOf(attr.type) >= 0 && attr.value && (locale === null || attr.language == locale)) { 82 | subpromises.push(mapToVS(attr.name, attr.type, Array.isArray(attr.value) ? attr.value.join(', ') : attr.value).then((mappedValue) => { 83 | console.debug(` - attr ${attr.name} values: ${result.id} to ${attr.value}`) 84 | result[attr.name] = mappedValue 85 | console.debug(` - vs attr ${attr.name} values: ${result.id} to ${result[attr.name]}`) 86 | })) 87 | } 88 | } 89 | return Promise.all(subpromises) 90 | } 91 | 92 | function attributeTemplate(attributeCode, attributeType = null) { // TODO: if we plan to support not full reindexes then we shall load the attribute templates from ES (previously filled up attrbutes) 93 | if(!fs.existsSync(path.join(__dirname, `../importers/templates/attribute_code_${attributeCode}.json`))) { 94 | console.debug(`Loading attribute by type ${attributeType}`) 95 | let attr = Object.assign({}, require(`../importers/templates/attribute_type_${attributeType}`)) 96 | attr.attribute_code = attributeCode 97 | attr.default_frontend_label = attributeCode 98 | 99 | return attr 100 | } 101 | else { 102 | console.debug(`Loading attribute by code ${attributeCode}`) // in this case we have pretty damn good attribute meta in template, like name etc 103 | return require(`../importers/templates/attribute_code_${attributeCode}`) 104 | } 105 | } 106 | 107 | exports.attributeTemplate = attributeTemplate 108 | /** 109 | * Vue storefront needs - like Magento - attribute dictionary to be populated by attr specifics; and here we are! 110 | * @param {String} attributeCode 111 | * @param {String} attributeType 112 | * @param {mixed} attributeValue 113 | */ 114 | exports.mapToVS = mapToVS 115 | 116 | exports.mapElements = mapElements -------------------------------------------------------------------------------- /src/importers/basic.js: -------------------------------------------------------------------------------- 1 | const attribute = require('../lib/attribute') 2 | const promiseLimit = require('../lib/promise') 3 | 4 | module.exports = class { 5 | constructor(entityType, customImporter, config, api, db) { 6 | this.config = config 7 | this.db = db 8 | this.api = api 9 | this.entityType = entityType 10 | this.customImporter = customImporter 11 | this.single = this.single.bind(this) 12 | } 13 | 14 | 15 | /** 16 | * @returns Promise 17 | */ 18 | single(descriptor, level = 1, parent_id = null) { 19 | return new Promise(((resolve, reject) => { 20 | console.debug('** REC. LEVEL = ', level) 21 | this.api.get(`object/id/${descriptor.id}`).end((resp) => { 22 | console.log('Processing object: ', descriptor.id) 23 | 24 | if(resp.body && resp.body.data) { 25 | const objectData = resp.body.data 26 | const subpromises = [] 27 | 28 | if (objectData.childs) { 29 | for (let chdDescriptor of objectData.childs) { 30 | console.log('- child objects found: ', chdDescriptor.id, descriptor.id) 31 | subpromises.push(() => this.single(chdDescriptor, level + 1, descriptor.id)) 32 | } 33 | } 34 | new Promise(((subresolve, subreject) => { // TODO: we should extrapolate the code snippet below and make it more general; In other words: to add the same behaviour like we do have here for ALL "objects" related - to download all the connected technologies etc 35 | this.api.get('object-list').query({ 36 | condition: 'o_parentId=\'' + descriptor.id + '\' AND o_type=\'variant\'', // get variants 37 | }).end(((resp) => { 38 | if(resp.body && resp.body.data) 39 | for (let chdDescriptor of resp.body.data) { 40 | console.log('- variant object found: ', chdDescriptor.id, descriptor.id) 41 | subpromises.push(() => this.single(chdDescriptor, level + 1, descriptor.id)) 42 | } 43 | subresolve(subpromises) 44 | }).bind(this)) 45 | }).bind(this)).then((variants) => { 46 | console.log('Variants retrieved for ', descriptor.id) 47 | 48 | let result = this.resultTemplate(this.entityType) // TOOD: objectData.childs should be also taken into consideration 49 | const locale = this.config.pimcore.locale 50 | const entityConfig = this.config.pimcore[`${this.entityType}Class`] 51 | let localizedFields = objectData.elements.find((itm)=> { return itm.name === 'localizedfields'}).value 52 | let elements = objectData.elements 53 | 54 | result.created_at = new Date(objectData.creationDate*1000) 55 | result.updated_at = new Date(objectData.modificationDate*1000) 56 | result.id = descriptor.id 57 | result.sku = descriptor.id 58 | 59 | Promise.all([ attribute.mapElements(result, elements), attribute.mapElements(result, localizedFields, this.config.pimcore.locale) ]).then((elementResults) => { 60 | Object.keys(entityConfig.map).map((srcField) => { 61 | const dstField = entityConfig.map[srcField] 62 | const dstValue = localizedFields.find((lf) => { return lf.name === dstField && lf.language === locale}) 63 | 64 | if(!dstValue) { 65 | console.error('Cannot find the value for ', dstField, locale) 66 | } else { 67 | result[srcField] = dstValue.type === 'numeric' ? parseFloat(dstValue.value) : dstValue.value 68 | } 69 | }) 70 | 71 | promiseLimit.serial(subpromises).then((childrenResults) => { 72 | if(this.customImporter) 73 | { 74 | this.customImporter.single(objectData, result, childrenResults, level, parent_id).then((resp) => { 75 | if (childrenResults.length > 0) 76 | { 77 | childrenResults.push(resp) 78 | resolve(childrenResults) 79 | } else resolve(resp) 80 | }).catch((reason) => { console.error(reason) }) 81 | } else { 82 | if (childrenResults.length > 0) 83 | { 84 | childrenResults.push({ dst: result, src: objectData }) 85 | resolve(childrenResults) 86 | } else { 87 | resolve({ dst: result, src: objectData }) 88 | } 89 | 90 | } 91 | }).catch((reason) => { console.error(reason) }) 92 | }).catch((reason) => { console.error(reason)}) 93 | }).catch((reason) => { console.error(reason) }) 94 | } else { 95 | console.error('No Data for ', descriptor.id) 96 | } 97 | }) 98 | })) 99 | } 100 | 101 | 102 | resultTemplate (entityType) { // TODO: add /templates/general.json for all the other entities - like featured product links etc to map all the linked objects and so on 103 | return Object.assign({}, require(`./templates/${entityType}.json`)) 104 | } 105 | } -------------------------------------------------------------------------------- /src/importers/product.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const fs = require('fs') 3 | const path = require('path') 4 | const shell = require('shelljs') 5 | const attribute = require('../lib/attribute') 6 | 7 | module.exports = class { 8 | constructor(config, api, db) { 9 | this.config = config 10 | this.db = db 11 | this.api = api 12 | this.single = this.single.bind(this) 13 | } 14 | 15 | /** 16 | * This is an EXAMPLE of custom Product / entity mapper; you can write your own to map the Pimcore entities to vue-storefront data format (see: templates/product.json for reference) 17 | * @returns Promise 18 | */ 19 | single(pimcoreObjectData, convertedObject, childObjects, level = 1, parent_id = null) { 20 | return new Promise((resolve, reject) => { 21 | console.debug('Helo from custom product converter for', convertedObject.id) 22 | convertedObject.url_key = pimcoreObjectData.key // pimcoreObjectData.path? 23 | convertedObject.type_id = (childObjects.length > 0) ? 'configurable' : 'simple' 24 | 25 | let elements = pimcoreObjectData.elements 26 | let features = elements.find((elem) => elem.name === 'features') 27 | let categories = elements.find((elem) => elem.name === 'categories') 28 | let images = elements.find((elem) => elem.name === 'images') 29 | let materialComposition = elements.find((elem) => elem.name === 'materialComposition') 30 | let color = elements.find((elem) => elem.name === 'color') 31 | let gender = elements.find((elem) => elem.name === 'gender') 32 | let size = elements.find((elem) => elem.name === 'size') 33 | 34 | let localizedFields = elements.find((itm)=> { return itm.name === 'localizedfields'}) 35 | 36 | let subPromises = [] 37 | 38 | if(size && size.value) 39 | subPromises.push(attribute.mapToVS('size', 'select', size.value).then((mappedValue) => { 40 | convertedObject.size = mappedValue 41 | })) 42 | 43 | if(color && color.value) 44 | subPromises.push(attribute.mapToVS('color', 'select', Array.isArray(color.value) ? color.value.join(', ') : color.value).then((mappedValue) => { 45 | convertedObject.color = mappedValue 46 | })) 47 | 48 | let imagePromises = [] 49 | if(images && this.config.pimcore.downloadImages) { 50 | 51 | images.value.map((imgDescr) => { 52 | let imgId = imgDescr.value[0].value 53 | imagePromises.push(new Promise((imgResolve, imgReject) => { 54 | shell.cd(path.join(__dirname, '../')) 55 | let downloaderResult = shell.exec(`node index.js asset --id=${imgId}`, { silent: true }) 56 | if (downloaderResult) { 57 | try { 58 | let jsonResult = JSON.parse(_.trim(downloaderResult.stdout)) 59 | if(jsonResult && jsonResult.relativePath) { 60 | convertedObject.image = jsonResult.relativePath 61 | console.debug('Image set to ', convertedObject.image) 62 | } 63 | } catch (err) { 64 | console.log('ASSET OUTPUT', downloaderResult.stdout) 65 | console.error(err) 66 | } 67 | } 68 | imgResolve() 69 | })) 70 | }) 71 | console.log('Downloading binary assets for ', convertedObject.id) 72 | } 73 | 74 | Promise.all(imagePromises).then((result) => { 75 | 76 | if(features && features.value) { 77 | features.value.map((featDescr) => { 78 | subPromises.push((this.api.get(`object/id/${featDescr.id}`).end((resp) => { 79 | // console.log('Feature', resp.body.data.elements.find((el) => { return el.name === 'localizedfields'})) 80 | }))) 81 | }) 82 | } 83 | convertedObject.category = [] 84 | convertedObject.category_ids = [] 85 | if(categories && categories.value) { 86 | categories.value.map((catDescr) => { 87 | subPromises.push(new Promise((catResolve, catReject) => { 88 | this.api.get(`object/id/${catDescr.id}`).end((resp) => { 89 | if(resp.body && resp.body.data) { 90 | let catLocalizedFields = resp.body.data.elements.find((el) => { return el.name === 'localizedfields'}) 91 | let catObject = { category_id: catDescr.id } 92 | if(catLocalizedFields) { 93 | attribute.mapElements(catObject, catLocalizedFields.value, this.config.pimcore.locale) 94 | console.debug(' - mapped product category ', catObject) 95 | } 96 | catResolve(catObject) 97 | convertedObject.category.push(catObject) 98 | convertedObject.category_ids.push(catObject.category_id) 99 | } 100 | }) 101 | })) 102 | }) 103 | } 104 | 105 | convertedObject.configurable_children = [] // clear the options 106 | if (convertedObject.type_id === 'configurable'){ 107 | // here we're flattening the child array out, because it's specific to pimcore that children can have children here :) 108 | 109 | let childObjectsFlattened = _.flatten(childObjects) 110 | 111 | let color_options = new Set() 112 | let size_options = new Set() 113 | for(let childObject of childObjectsFlattened) { 114 | let confChild = { 115 | name: childObject.dst.name, 116 | sku: childObject.dst.sku, 117 | price: childObject.dst.price 118 | } 119 | 120 | childObject.dst.visibility = 1 // Magento's constant which means: skip in search and category results - we're hiding the variants from being visible within the categories 121 | if(_.trim(childObject.dst.color) != '') 122 | color_options.add(childObject.dst.color) 123 | 124 | if(_.trim(childObject.dst.size) != '') 125 | size_options.add(childObject.dst.size) 126 | 127 | confChild.custom_attributes = [ // other custom attributes can be stored here as well 128 | { 129 | "value": childObject.dst.url_key, 130 | "attribute_code": "url_key" 131 | }, 132 | /* { 133 | "value": childObject.dst.small_image, 134 | "attribute_code": "small_image" 135 | },*/ 136 | { 137 | "value": childObject.dst.image, 138 | "attribute_code": "image" 139 | }, 140 | { 141 | "value": `${childObject.dst.size}`, // these attributes are used for configuration, todo: map to attribute dictionary to be used in Magento 142 | "attribute_code": "size" 143 | }, 144 | { 145 | "value": `${childObject.dst.color}`, // TODO: map to enumerable attribute to be used in Magento - because are dictionary attrs in Magento 146 | "attribute_code": "color" 147 | }, 148 | 149 | /* { 150 | "value": childObject.dst.thumbnail, 151 | "attribute_code": "thumbnail" 152 | }*/ 153 | ] 154 | 155 | 156 | convertedObject.configurable_children.push(confChild) 157 | } 158 | console.debug(' - Configurable children for: ', convertedObject.id, convertedObject.configurable_children.length, convertedObject.configurable_children) 159 | convertedObject.color_options = Array.from(color_options) // this is vue storefront feature of setting up the combined options altogether in the parent for allowing filters to work on configurable products 160 | convertedObject.size_options = Array.from(size_options) 161 | 162 | 163 | const attrs = attribute.getMap() 164 | const configurableAttrs = ['size', 'color'] 165 | convertedObject.configurable_options = [] 166 | 167 | configurableAttrs.map((attrCode) => { 168 | let attr = attrs[attrCode] 169 | let confOptions = { 170 | "attribute_id": attr.id, 171 | "values": [ 172 | ], 173 | "product_id": convertedObject.id, 174 | "label": attr.default_frontend_label 175 | } 176 | convertedObject[`${attrCode}_options`].map((op) => { 177 | confOptions.values.push({ 178 | value_index: op 179 | }) 180 | }) 181 | convertedObject.configurable_options.push(confOptions) 182 | }) 183 | } else { 184 | convertedObject.configurable_options = [] 185 | convertedObject.configurable_children = [] 186 | } 187 | 188 | Promise.all(subPromises).then(results => { 189 | resolve({ src: pimcoreObjectData, dst: convertedObject }) 190 | }).catch((reason) => { console.error(reason) }) 191 | }).catch((reason) => { console.error(reason) }) 192 | }) 193 | } 194 | } -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const shell = require('shelljs') 4 | const mkdirp = require('mkdirp') 5 | const exists = require('fs-exists-sync') 6 | const inquirer = require('inquirer') 7 | const jsonFile = require('jsonfile') 8 | const urlParser = require('url-parse') 9 | const isWindows = require('is-windows') 10 | const isEmptyDir = require('empty-dir') 11 | const commandExists = require('command-exists') 12 | const validUrl = require('valid-url'); 13 | const path = require('path') 14 | 15 | const PimcoreApiClient = require('./src/lib/pimcore-api') 16 | let api 17 | 18 | const TARGET_CONFIG_FILE = 'config.json' 19 | const SOURCE_CONFIG_FILE = 'config.example.json' 20 | 21 | const SELF_DIRECTORY = shell.pwd() 22 | 23 | const LOG_DIR = `${SELF_DIRECTORY}/var/log` 24 | const INSTALL_LOG_FILE = `${SELF_DIRECTORY}/var/log/install.log` 25 | const GENERAL_LOG_FILE = `${SELF_DIRECTORY}/var/log/general.log` 26 | 27 | const Message = require('./src/lib/message.js') 28 | 29 | /** 30 | * Abstract class for field initialization 31 | */ 32 | class Abstract { 33 | /** 34 | * Constructor 35 | * 36 | * Initialize fields 37 | */ 38 | constructor (answers) { 39 | this.answers = answers 40 | } 41 | } 42 | 43 | 44 | /** 45 | * Scripts for initialization of Pimcore instance 46 | */ 47 | class Pimcore extends Abstract { 48 | /** 49 | * Creating storefront config.json 50 | * 51 | * @returns {Promise} 52 | */ 53 | createConfig () { 54 | return new Promise((resolve, reject) => { 55 | let config 56 | 57 | Message.info(`Creating pimcore config '${TARGET_CONFIG_FILE}'...`) 58 | 59 | try { 60 | config = jsonFile.readFileSync(SOURCE_CONFIG_FILE) 61 | 62 | let backendPath 63 | 64 | const pimcoreClassFinder = function (className) { 65 | return availablePimcoreClassess.find((itm) => { return itm.name === className }) 66 | } 67 | 68 | config.elasticsearch.host = this.answers.elasticsearchUrl 69 | config.elasticsearch.indexName = this.answers.elasticsearchIndexName 70 | config.pimcore.url = this.answers.pimcoreUrl 71 | config.pimcore.assetsPath = this.answers.assetsPath 72 | config.pimcore.apiKey = this.answers.apiKey 73 | config.pimcore.rootCategoryId = parseInt(this.answers.rootCategoryId) 74 | config.pimcore.locale = this.answers.locale 75 | config.pimcore.productClass = Object.assign(config.pimcore.productClass, pimcoreClassFinder(this.answers.productClass)) 76 | config.pimcore.categoryClass = Object.assign(config.pimcore.categoryClass, pimcoreClassFinder(this.answers.categoryClass)) 77 | 78 | jsonFile.writeFileSync(TARGET_CONFIG_FILE, config, {spaces: 2}) 79 | } catch (e) { 80 | reject('Can\'t create storefront config.') 81 | } 82 | 83 | resolve() 84 | }) 85 | } 86 | 87 | 88 | /** 89 | * Start 'npm run import' in background 90 | * 91 | * @returns {Promise} 92 | */ 93 | runImporter (answers) { 94 | return new Promise((resolve, reject) => { 95 | Message.info('Starting Pimcore inporter ...') 96 | 97 | let lastExecResult = null 98 | shell.cd('src') 99 | if (shell.exec(`node index.js new`).code !== 0) { 100 | reject('Can\'t create elasticsearch index.') 101 | resolve(answers) 102 | } 103 | if ((lastExecResult = shell.exec(`node index.js taxrules`)) && lastExecResult.code !== 0) { 104 | reject('Can\'t import the taxrules') 105 | resolve(answers) 106 | } 107 | if ((lastExecResult = shell.exec(`node index.js categories`)) && lastExecResult.code !== 0) { 108 | reject('Can\'t import the categories') 109 | resolve(answers) 110 | } 111 | if ((lastExecResult = shell.exec(`node index.js products`)) && lastExecResult.code !== 0) { 112 | reject('Can\'t import the products') 113 | resolve(answers) 114 | } 115 | 116 | if ((lastExecResult = shell.exec(`node index.js publish`)) && lastExecResult.code !== 0) { 117 | reject('Can\'t publish the index') 118 | resolve(answers) 119 | } 120 | 121 | resolve(answers) 122 | }) 123 | } 124 | } 125 | 126 | class Manager extends Abstract { 127 | /** 128 | * {@inheritDoc} 129 | * 130 | * Assign backend and storefront entities 131 | */ 132 | constructor (answers) { 133 | super(answers) 134 | 135 | this.pimcore = new Pimcore(answers) 136 | } 137 | 138 | /** 139 | * Trying to create log files 140 | * If is impossible - warning shows 141 | * 142 | * @returns {Promise} 143 | */ 144 | tryToCreateLogFiles () { 145 | return new Promise((resolve, reject) => { 146 | Message.info('Trying to create log files...') 147 | 148 | try { 149 | mkdirp.sync(LOG_DIR, {mode: parseInt('0755', 8)}) 150 | 151 | let logFiles = [ 152 | INSTALL_LOG_FILE, 153 | GENERAL_LOG_FILE 154 | ] 155 | 156 | for (let logFile of logFiles) { 157 | if (shell.touch(logFile).code !== 0 || !exists(logFile)) { 158 | throw new Error() 159 | } 160 | } 161 | 162 | Abstract.logsWereCreated = true 163 | Abstract.infoLogStream = INSTALL_LOG_FILE 164 | Abstract.logStream = GENERAL_LOG_FILE 165 | } catch (e) { 166 | Message.warning('Can\'t create log files.') 167 | } 168 | 169 | resolve() 170 | }) 171 | } 172 | 173 | 174 | /** 175 | * Initialize all processes for storefront 176 | * 177 | * @returns {Promise} 178 | */ 179 | initPimcore () { 180 | return this.pimcore.createConfig.bind(this.pimcore)() 181 | .then(this.pimcore.runImporter.bind(this.pimcore)) 182 | } 183 | 184 | /** 185 | * Check user OS and shows error if not supported 186 | */ 187 | static checkUserOS () { 188 | if (isWindows()) { 189 | Message.error([ 190 | 'Unfortunately currently only Linux and OSX are supported.', 191 | 'To install vue-storefront on your mac please go threw manual installation process provided in documentation:', 192 | `${STOREFRONT_GIT_URL}/blob/master/doc/Installing%20on%20Windows.md` 193 | ]) 194 | } 195 | } 196 | 197 | /** 198 | * Shows message rendered on the very beginning 199 | */ 200 | static showWelcomeMessage () { 201 | Message.greeting([ 202 | 'Hi, welcome to the pimcore2vuestorefront setup.', 203 | 'Let\'s configure it together :)' 204 | ]) 205 | } 206 | 207 | /** 208 | * Shows details about successful installation finish 209 | * 210 | * @returns {Promise} 211 | */ 212 | showGoodbyeMessage () { 213 | return new Promise((resolve, reject) => { 214 | Message.greeting([ 215 | 'Congratulations!', 216 | '', 217 | 'You\'ve just configured Pimcore -> VueStorefront integrator.', 218 | '', 219 | 'Good Luck!' 220 | ], true) 221 | 222 | resolve() 223 | }) 224 | } 225 | } 226 | 227 | const urlFilter = function (url) { 228 | let prefix = 'http://' 229 | let prefixSsl = 'https://' 230 | 231 | url = url.trim() 232 | 233 | // add http:// if no protocol set 234 | if (url.substr(0, prefix.length) !== prefix && url.substr(0, prefixSsl.length) !== prefixSsl) { 235 | url = prefix + url 236 | } 237 | 238 | // add extra slash as suffix if was not set 239 | return url.slice(-1) === '/' ? url : `${url}/` 240 | } 241 | 242 | let pimcoreUrl 243 | let availablePimcoreClassess 244 | 245 | /** 246 | * Here we configure questions 247 | * 248 | * @type {[Object,Object,Object,Object]} 249 | */ 250 | let questions = [ 251 | { 252 | type: 'input', 253 | name: 'pimcoreUrl', 254 | message: 'Please provide Pimcore URL', 255 | filter: urlFilter, 256 | default: 'http://vue-catalog-pimcore.test.divante.pl/', 257 | when: function (answers) { 258 | return true 259 | }, 260 | validate: function (value) { 261 | pimcoreUrl = value 262 | 263 | if (validUrl.isUri(value)){ 264 | return true 265 | } 266 | else { 267 | return 'Provide a valid Pimcore URI' 268 | } 269 | 270 | return true 271 | } 272 | }, 273 | { 274 | type: 'input', 275 | name: 'apiKey', 276 | message: 'Please provide valid Pimcore API Key', 277 | default: 'da6cb4a55ead8faffebcf5ed96ba2796536044247f08f37c49dd2dac84b67974', 278 | when: function (answers) { 279 | return true 280 | }, 281 | validate: function (value) { 282 | var done = this.async(); 283 | api = new PimcoreApiClient({ 284 | url: pimcoreUrl, 285 | apiKey: value 286 | }) 287 | try { 288 | api.get('classes').end((resp) => { 289 | if (resp.body.success == false) { 290 | done (resp.body.msg) 291 | } else { 292 | availablePimcoreClassess = resp.body.data 293 | done(null, true) 294 | } 295 | }) 296 | } catch (err) { 297 | console.error(err) 298 | done('Please provide valid URL and API Key for Pimcore') 299 | } 300 | } 301 | }, 302 | { 303 | type: 'input', 304 | name: 'elasticsearchUrl', 305 | message: 'Please provide Elastic Search URL', 306 | default: 'http://localhost:9200', 307 | filter: urlFilter, 308 | when: function (answers) { 309 | return true 310 | }, 311 | validate: function (value) { 312 | return true 313 | } 314 | }, 315 | { 316 | type: 'input', 317 | name: 'elasticsearchIndexName', 318 | message: 'Please provide the Elastic Search index name for vue-storefront', 319 | default: 'vue_storefront_pimcore', 320 | when: function (answers) { 321 | return true 322 | }, 323 | validate: function (value) { 324 | return true 325 | } 326 | }, 327 | { 328 | type: 'input', 329 | name: 'rootCategoryId', 330 | message: 'Please the root product category ID of Pimcore', 331 | default: '11148', 332 | when: function (answers) { 333 | return true 334 | }, 335 | validate: function (value) { 336 | return (Number.isInteger(parseInt(value))) 337 | } 338 | }, 339 | { 340 | type: 'input', 341 | name: 'assetsPath', 342 | message: 'Enter the assets path. Pimcore images will be downloaded in here:', 343 | default: path.normalize(__dirname + '/var/assets'), 344 | when: function (answers) { 345 | return true 346 | }, 347 | validate: function (value) { 348 | return true 349 | } 350 | }, 351 | { 352 | type: 'choice', 353 | name: 'locale', 354 | message: 'Which language version should be synchronized', 355 | default: 'en_GB', 356 | choices: ['en_GB', 'de_AT', 'de_DE', 'pl_PL', 'fr_FR', 'en_US'], 357 | when: function (answers) { 358 | return true 359 | }, 360 | validate: function (value) { 361 | return true 362 | } 363 | }, 364 | { 365 | type: 'list', 366 | choices: function(answers) { return availablePimcoreClassess.map((itm) => { return itm.name }) }, 367 | 368 | name: 'productClass', 369 | message: 'Please select valid Pimcore class for Product entities', 370 | default: 'Product', 371 | when: function (answers) { 372 | return true 373 | }, 374 | validate: function (value) { 375 | return true 376 | } 377 | }, 378 | { 379 | type: 'list', 380 | choices: function(answers) { return availablePimcoreClassess.map((itm) => { return itm.name }) }, 381 | 382 | name: 'categoryClass', 383 | message: 'Please select valid Pimcore class for Category entities', 384 | default: 'ProductCategory', 385 | when: function (answers) { 386 | return true 387 | }, 388 | validate: function (value) { 389 | return true 390 | } 391 | }, 392 | ] 393 | 394 | process.on('unhandledRejection', (reason, p) => { 395 | console.error('Unhandled Rejection at: Promise', p, 'reason:', reason); 396 | // application specific logging, throwing an error, or other logic here 397 | }); 398 | 399 | /** 400 | * Predefine class static variables 401 | */ 402 | Abstract.logsWereCreated = false 403 | Abstract.infoLogStream = '/dev/null' 404 | Abstract.logStream = '/dev/null' 405 | 406 | if (require.main.filename === __filename) { 407 | /** 408 | * Pre-loading staff 409 | */ 410 | Manager.checkUserOS() 411 | Manager.showWelcomeMessage() 412 | 413 | /** 414 | * This is where all the magic happens 415 | */ 416 | inquirer.prompt(questions).then(async function (answers) { 417 | let manager = new Manager(answers) 418 | 419 | await manager.tryToCreateLogFiles() 420 | .then(manager.initPimcore.bind(manager)) 421 | .then(manager.showGoodbyeMessage.bind(manager)) 422 | .catch(Message.error) 423 | 424 | shell.exit(0) 425 | }) 426 | } else { 427 | module.exports.Message = Message 428 | module.exports.Manager = Manager 429 | module.exports.Abstract = Abstract 430 | module.exports.TARGET_CONFIG_FILE = TARGET_CONFIG_FILE 431 | } 432 | -------------------------------------------------------------------------------- /doc/Pimcore2vuestorefront-architecture-draw-io.xml: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json') 2 | const PimcoreApiClient = require('./lib/pimcore-api') 3 | const api = new PimcoreApiClient(config.pimcore) 4 | 5 | const ProductImpoter = require('./importers/product') 6 | const BasicImporter = require('./importers/basic') 7 | const CategoryImpoter = require('./importers/category') 8 | const _ = require('lodash') 9 | const attribute = require('./lib/attribute') 10 | 11 | const promiseLimit = require('promise-limit') 12 | const limit = promiseLimit(3) // limit N promises to be executed at time 13 | const promise = require('./lib/promise') // right now we're using serial execution because of recursion stack issues 14 | const path = require('path') 15 | const shell = require('shelljs') 16 | const fs = require('fs') 17 | const jsonFile = require('jsonfile') 18 | 19 | 20 | let INDEX_VERSION = 1 21 | let INDEX_META_DATA 22 | const INDEX_META_PATH = path.join(__dirname, '../var/indexMetadata.json') 23 | 24 | const { spawn } = require('child_process'); 25 | 26 | const es = require('elasticsearch') 27 | let client = new es.Client({ // as we're runing tax calculation and other data, we need a ES indexer 28 | host: config.elasticsearch.host, 29 | log: 'error', 30 | apiVersion: '5.5', 31 | requestTimeout: 10000 32 | }) 33 | 34 | const CommandRouter = require('command-router') 35 | const cli = CommandRouter() 36 | 37 | cli.option({ name: 'offset' 38 | , alias: 'p' 39 | , default: 0 40 | , type: Number 41 | }) 42 | cli.option({ name: 'limit' 43 | , alias: 'l' 44 | , default: 25 45 | , type: Number 46 | }) 47 | 48 | cli.option({ name: 'switchPage' 49 | , alias: 's' 50 | , default: true 51 | , type: Boolean 52 | }) 53 | 54 | cli.option({ name: 'partitions' 55 | , alias: 't' 56 | , default: 20 57 | , type: Boolean 58 | }) 59 | 60 | cli.option({ name: 'runSerial' 61 | , alias: 'o' 62 | , default: false 63 | , type: Boolean 64 | }) 65 | 66 | function showWelcomeMsg() { 67 | console.log('** CURRENT INDEX VERSION', INDEX_VERSION, INDEX_META_DATA.created) 68 | } 69 | 70 | 71 | function readIndexMeta() { 72 | let indexMeta = { version: 0, created: new Date(), updated: new Date() } 73 | 74 | try { 75 | indexMeta = jsonFile.readFileSync(INDEX_META_PATH) 76 | } catch (err){ 77 | console.log('Seems like first time run!', err.message) 78 | } 79 | return indexMeta 80 | } 81 | 82 | function recreateTempIndex() { 83 | 84 | let indexMeta = readIndexMeta() 85 | 86 | try { 87 | indexMeta.version ++ 88 | INDEX_VERSION = indexMeta.version 89 | indexMeta.updated = new Date() 90 | jsonFile.writeFileSync(INDEX_META_PATH, indexMeta) 91 | } catch (err) { 92 | console.error(err) 93 | } 94 | 95 | let step2 = () => { 96 | client.indices.create({ index: `${config.elasticsearch.indexName}_${INDEX_VERSION}` }).then(result=>{ 97 | console.log('Index Created', result) 98 | console.log('** NEW INDEX VERSION', INDEX_VERSION, INDEX_META_DATA.created) 99 | }) 100 | } 101 | 102 | 103 | return client.indices.delete({ 104 | index: `${config.elasticsearch.indexName}_${INDEX_VERSION}` 105 | }).then((result) => { 106 | console.log('Index deleted', result) 107 | step2() 108 | }).catch((err) => { 109 | console.log('Index does not exst') 110 | step2() 111 | }) 112 | } 113 | 114 | function publishTempIndex() { 115 | let step2 = () => { 116 | client.indices.putAlias({ index: `${config.elasticsearch.indexName}_${INDEX_VERSION}`, name: config.elasticsearch.indexName }).then(result=>{ 117 | console.log('Index alias created', result) 118 | }) 119 | } 120 | 121 | 122 | return client.indices.deleteAlias({ 123 | index: `${config.elasticsearch.indexName}_${INDEX_VERSION-1}`, 124 | name: config.elasticsearch.indexName 125 | }).then((result) => { 126 | console.log('Public index alias deleted', result) 127 | step2() 128 | }).catch((err) => { 129 | console.log('Public index alias does not exists', err.message) 130 | step2() 131 | }) 132 | } 133 | 134 | function storeResults(singleResults, entityType) { 135 | let fltResults = _.flattenDeep(singleResults) 136 | let attributes = attribute.getMap() 137 | 138 | fltResults.map((ent) => { 139 | client.index({ 140 | index: `${config.elasticsearch.indexName}_${INDEX_VERSION}`, 141 | type: entityType, 142 | id: ent.dst.id, 143 | body: ent.dst 144 | }) 145 | }) 146 | Object.values(attributes).map((attr) => { 147 | client.index({ 148 | index: `${config.elasticsearch.indexName}_${INDEX_VERSION}`, 149 | type: 'attribute', 150 | id: attr.id, 151 | body: attr 152 | }) 153 | }) 154 | } 155 | 156 | 157 | /** 158 | * Import full list of specific entites 159 | * @param {String} entityType 160 | * @param {Object} importer 161 | */ 162 | function importListOf(entityType, importer, config, api, offset = 0, count = 100, recursive = true) { 163 | 164 | return new Promise((resolve, reject) => { 165 | let entityConfig = config.pimcore[`${entityType}Class`] 166 | if (!entityConfig) { 167 | throw new Error(`No Pimcore class configuration for ${entityType}`) 168 | } 169 | 170 | const query = { // TODO: add support for `limit` and `offset` paramters 171 | objectClass: entityConfig.name, 172 | offset: offset, 173 | limit: count 174 | } 175 | 176 | let generalQueue = [] 177 | console.log('*** Getting objects list for', query) 178 | api.get('object-list').query(query).end((resp) => { 179 | 180 | let queue = [] 181 | let index = 0 182 | for(let objDescriptor of resp.body.data) { 183 | let promise = importer.single(objDescriptor).then((singleResults) => { 184 | storeResults(singleResults, entityType) 185 | console.log('* Record done for ', objDescriptor.id, index, count) 186 | index++ 187 | }) 188 | if(cli.params.runSerial) 189 | queue.push(() => promise) 190 | else 191 | queue.push(promise) 192 | } 193 | let resultParser = (results) => { 194 | console.log('** Page done ', offset, resp.body.total) 195 | 196 | if(resp.body.total === count) 197 | { 198 | try { 199 | global.gc(); 200 | } catch (e) { 201 | console.log("WARNING: You can run program with 'node --expose-gc index.js' or 'npm start'"); 202 | } 203 | 204 | if(recursive) { 205 | console.log('*** Switching page!') 206 | return importListOf(entityType, importer, config, api, offset += count, count) 207 | } else { 208 | return { 209 | total: resp.body.total, 210 | count: count, 211 | offset: offset 212 | } 213 | } 214 | } 215 | } 216 | if(cli.params.runSerial) 217 | promise.serial(queue).then(resultParser).then((res) => resolve(res)).catch((reason) => { console.error(reason); reject() }) 218 | else 219 | Promise.all(queue).then(resultParser).then((res) => resolve(res)).catch((reason) => { console.error(reason); reject() }) 220 | }) 221 | }) 222 | } 223 | // TODO: 224 | // 2. Images server 225 | // 5. Add styles for color attributes like "white, black" etc 226 | 227 | cli.command('products', () => { 228 | showWelcomeMsg() 229 | 230 | importListOf('product', new BasicImporter('product', new ProductImpoter(config, api, client), config, api, client), config, api, offset = cli.options.offset, count = cli.options.limit, recursive = false).then((result) => 231 | { 232 | if(cli.options.switchPage) { 233 | if(result && result.count === result.total) // run the next instance 234 | { 235 | let cmd = `node index.js products --switchPage=true --offset=${result.offset+result.count} --limit=${cli.options.limit} --runSerial=${cli.options.runSerial}` 236 | console.log('Starting cubprocess for the next page', cmd) 237 | shell.exec(cmd) 238 | } 239 | } 240 | }).catch(err => { 241 | console.error(err) 242 | }) 243 | }) 244 | 245 | cli.command('taxrules', () => { 246 | showWelcomeMsg() 247 | let taxRules = jsonFile.readFileSync('./importers/templates/taxrules.json') 248 | for(let taxRule of taxRules) { 249 | client.index({ 250 | index: `${config.elasticsearch.indexName}_${INDEX_VERSION}`, 251 | type: 'taxrule', 252 | id: taxRule.id, 253 | body: taxRule 254 | }) 255 | } 256 | }); 257 | 258 | cli.command('productsMultiProcess', () => { 259 | showWelcomeMsg() 260 | for(let i = 0; i < cli.options.partitions; i++) { // TODO: support for dynamic count of products etc 261 | shell.exec(`node index.js products --offset=${i*cli.options.limit} --limit=${cli.options.limit} --switchPage=false > ../var/log/products_${i}.txt`, (code, stdout, stderr) => { 262 | console.log('Exit code:', code); 263 | console.log('Program stderr:', stderr); 264 | }) 265 | } 266 | }); 267 | 268 | 269 | cli.command('new', () => { 270 | showWelcomeMsg() 271 | recreateTempIndex() 272 | }); 273 | 274 | 275 | cli.command('publish', () => { 276 | showWelcomeMsg() 277 | publishTempIndex() 278 | }); 279 | 280 | 281 | cli.command('categories', () => { 282 | showWelcomeMsg() 283 | let importer = new BasicImporter('category', new CategoryImpoter(config, api, client), config, api, client) // ProductImporter can be switched to your custom data mapper of choice 284 | importer.single({ id: config.pimcore.rootCategoryId }, level = 1, parent_id = 1).then((results) => { 285 | let fltResults = _.flattenDeep(results) 286 | storeResults(fltResults, 'category') 287 | })}); 288 | 289 | /** 290 | * Download asset and return the meta data as a JSON 291 | */ 292 | cli.command('asset', () => { 293 | if(!cli.options.id) { 294 | console.log(JSON.stringify({ status: -1, message: 'Please provide asset Id' })) 295 | process.exit(-1) 296 | } 297 | api.get(`asset/id/${cli.options.id}`).end((resp) => { 298 | if(resp.body && resp.body.data) { 299 | const imageName = resp.body.data.filename 300 | const imageRelativePath = resp.body.data.path 301 | const imageAbsolutePath = path.join(config.pimcore.assetsPath, imageRelativePath, imageName) 302 | 303 | shell.mkdir('-p', path.join(config.pimcore.assetsPath, imageRelativePath)) 304 | fs.writeFileSync(imageAbsolutePath, Buffer.from(resp.body.data.data, 'base64')) 305 | console.log(JSON.stringify({ status: 0, message: 'Image downloaded!', absolutePath: imageAbsolutePath, relativePath: path.join(imageRelativePath, imageName) })) 306 | } 307 | }) 308 | }) 309 | 310 | cli.on('notfound', (action) => { 311 | console.error('I don\'t know how to: ' + action) 312 | process.exit(1) 313 | }) 314 | 315 | 316 | process.on('unhandledRejection', (reason, p) => { 317 | console.error('Unhandled Rejection at: Promise', p, 'reason:', reason); 318 | // application specific logging, throwing an error, or other logic here 319 | }); 320 | 321 | process.on('uncaughtException', function (exception) { 322 | console.error(exception); // to see your exception details in the console 323 | // if you are on production, maybe you can send the exception details to your 324 | // email as well ? 325 | }); 326 | 327 | 328 | INDEX_META_DATA = readIndexMeta() 329 | INDEX_VERSION = INDEX_META_DATA.version 330 | 331 | // RUN 332 | cli.parse(process.argv); 333 | 334 | 335 | // FOR DEV/DEBUG PURPOSES 336 | 337 | cli.command('testcategory', () => { 338 | let importer = new BasicImporter('category', new CategoryImpoter(config, api, client), config, api, client) // ProductImporter can be switched to your custom data mapper of choice 339 | importer.single({ id: 11148 }).then((results) => { 340 | let fltResults = _.flattenDeep(results) 341 | let obj = fltResults.find((it) => it.dst.id === 11148) 342 | console.log('CATEGORIES', fltResults.length, obj, obj.dst.children_data) 343 | console.log('ATTRIBUTES', attribute.getMap()) 344 | console.log('CO', obj.dst.configurable_options) 345 | }).catch((reason) => { console.error(reason) }) 346 | }); 347 | 348 | 349 | cli.command('testproduct', () => { 350 | let importer = new BasicImporter('product', new ProductImpoter(config, api, client), config, api, client) // ProductImporter can be switched to your custom data mapper of choice 351 | importer.single({ id: 1237 }).then((results) => { 352 | let fltResults = _.flatten(results) 353 | let obj = fltResults.find((it) => it.dst.id === 1237) 354 | console.log('PRODUCTS', fltResults.length, obj, obj.dst.configurable_children) 355 | console.log('ATTRIBUTES', attribute.getMap()) 356 | console.log('CO', obj.dst.configurable_options) 357 | }).catch((reason) => { console.error(reason) }) 358 | }); 359 | 360 | // Using a single function to handle multiple signals 361 | function handle(signal) { 362 | console.log('Received exit signal. Bye!'); 363 | process.exit(-1) 364 | } 365 | process.on('SIGINT', handle); 366 | process.on('SIGTERM', handle); 367 | -------------------------------------------------------------------------------- /src/importers/templates/product.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2029, 3 | "sku": "WSH11", 4 | "name": "Ina Compression Short", 5 | "price": 0, 6 | "status": 1, 7 | "visibility": 4, 8 | "type_id": "configurable", 9 | "created_at": "2017-11-06 12:17:29", 10 | "updated_at": "2017-11-06 12:17:29", 11 | "product_links": [], 12 | "tier_prices": [], 13 | "custom_attributes": null, 14 | "description": "

One of Luma's most popular items, the Ina Compression Short has you covered with exceptional support and comfort, whether you're running the trail, riding a bike or ripping out reps. The ventilating fabric offers cool relief and prevents irritating chafing.

\n

• Royal blue bike shorts.
• Compression fit.
• Moisture-wicking.
• Anti-microbial.
• Machine wash/dry.

", 15 | "image": "/w/s/wsh11-blue_main.jpg", 16 | "category_ids": [ 17 | "28", 18 | "34", 19 | "2" 20 | ], 21 | "url_key": "ina-compression-short", 22 | "tax_class_id": 2, 23 | "configurable_options": [ 24 | { 25 | "attribute_id": "93", 26 | "values": [ 27 | { 28 | "value_index": 50 29 | }, 30 | { 31 | "value_index": 56 32 | }, 33 | { 34 | "value_index": 58 35 | } 36 | ], 37 | "product_id": 2029, 38 | "id": 293, 39 | "label": "Color", 40 | "position": 1 41 | }, 42 | { 43 | "attribute_id": "142", 44 | "values": [ 45 | { 46 | "value_index": 172 47 | }, 48 | { 49 | "value_index": 173 50 | } 51 | ], 52 | "product_id": 2029, 53 | "id": 292, 54 | "label": "Size", 55 | "position": 0 56 | } 57 | ], 58 | "stock": { 59 | "min_sale_qty": 1, 60 | "is_in_stock": true, 61 | "product_id": 2029, 62 | "item_id": 2029, 63 | "qty": 1 64 | }, 65 | "configurable_children": [ 66 | { 67 | "price": 49, 68 | "name": "Ina Compression Short-28-Blue", 69 | "sku": "WSH11-28-Blue", 70 | "custom_attributes": [ 71 | { 72 | "value": "0", 73 | "attribute_code": "required_options" 74 | }, 75 | { 76 | "value": "0", 77 | "attribute_code": "has_options" 78 | }, 79 | { 80 | "value": "2", 81 | "attribute_code": "tax_class_id" 82 | }, 83 | { 84 | "value": [ 85 | "28", 86 | "34", 87 | "2" 88 | ], 89 | "attribute_code": "category_ids" 90 | }, 91 | { 92 | "value": "172", 93 | "attribute_code": "size" 94 | }, 95 | { 96 | "value": "50", 97 | "attribute_code": "color" 98 | }, 99 | { 100 | "value": "/w/s/wsh11-blue_main.jpg", 101 | "attribute_code": "image" 102 | }, 103 | { 104 | "value": "/w/s/wsh11-blue_main.jpg", 105 | "attribute_code": "small_image" 106 | }, 107 | { 108 | "value": "/w/s/wsh11-blue_main.jpg", 109 | "attribute_code": "thumbnail" 110 | }, 111 | { 112 | "value": "ina-compression-short-28-blue", 113 | "attribute_code": "url_key" 114 | }, 115 | { 116 | "value": "0", 117 | "attribute_code": "msrp_display_actual_price_type" 118 | } 119 | ], 120 | "sgn": "TjofcQd1hEKFgefIFfU6r4ZDTpZgUzQhmRC_vxMYSZU" 121 | }, 122 | { 123 | "price": 49, 124 | "name": "Ina Compression Short-28-Orange", 125 | "sku": "WSH11-28-Orange", 126 | "custom_attributes": [ 127 | { 128 | "value": "0", 129 | "attribute_code": "required_options" 130 | }, 131 | { 132 | "value": "0", 133 | "attribute_code": "has_options" 134 | }, 135 | { 136 | "value": "2", 137 | "attribute_code": "tax_class_id" 138 | }, 139 | { 140 | "value": [ 141 | "28", 142 | "34", 143 | "2" 144 | ], 145 | "attribute_code": "category_ids" 146 | }, 147 | { 148 | "value": "172", 149 | "attribute_code": "size" 150 | }, 151 | { 152 | "value": "56", 153 | "attribute_code": "color" 154 | }, 155 | { 156 | "value": "/w/s/wsh11-orange_main.jpg", 157 | "attribute_code": "image" 158 | }, 159 | { 160 | "value": "/w/s/wsh11-orange_main.jpg", 161 | "attribute_code": "small_image" 162 | }, 163 | { 164 | "value": "/w/s/wsh11-orange_main.jpg", 165 | "attribute_code": "thumbnail" 166 | }, 167 | { 168 | "value": "ina-compression-short-28-orange", 169 | "attribute_code": "url_key" 170 | }, 171 | { 172 | "value": "0", 173 | "attribute_code": "msrp_display_actual_price_type" 174 | } 175 | ], 176 | "sgn": "Ml9tc5g6ISnqIx7Due9HiCWvPBgwQ7YPJDP3XfNVbEM" 177 | }, 178 | { 179 | "price": 49, 180 | "name": "Ina Compression Short-28-Red", 181 | "sku": "WSH11-28-Red", 182 | "custom_attributes": [ 183 | { 184 | "value": "0", 185 | "attribute_code": "required_options" 186 | }, 187 | { 188 | "value": "0", 189 | "attribute_code": "has_options" 190 | }, 191 | { 192 | "value": "2", 193 | "attribute_code": "tax_class_id" 194 | }, 195 | { 196 | "value": [ 197 | "28", 198 | "34", 199 | "2" 200 | ], 201 | "attribute_code": "category_ids" 202 | }, 203 | { 204 | "value": "172", 205 | "attribute_code": "size" 206 | }, 207 | { 208 | "value": "58", 209 | "attribute_code": "color" 210 | }, 211 | { 212 | "value": "/w/s/wsh11-red_main.jpg", 213 | "attribute_code": "image" 214 | }, 215 | { 216 | "value": "/w/s/wsh11-red_main.jpg", 217 | "attribute_code": "small_image" 218 | }, 219 | { 220 | "value": "/w/s/wsh11-red_main.jpg", 221 | "attribute_code": "thumbnail" 222 | }, 223 | { 224 | "value": "ina-compression-short-28-red", 225 | "attribute_code": "url_key" 226 | }, 227 | { 228 | "value": "0", 229 | "attribute_code": "msrp_display_actual_price_type" 230 | } 231 | ], 232 | "sgn": "8rH2F9C4P7wAolgE_N8ha4Kkc3k9SQiZN93B5Oj3rNM" 233 | }, 234 | { 235 | "price": 49, 236 | "name": "Ina Compression Short-29-Blue", 237 | "sku": "WSH11-29-Blue", 238 | "custom_attributes": [ 239 | { 240 | "value": "0", 241 | "attribute_code": "required_options" 242 | }, 243 | { 244 | "value": "0", 245 | "attribute_code": "has_options" 246 | }, 247 | { 248 | "value": "2", 249 | "attribute_code": "tax_class_id" 250 | }, 251 | { 252 | "value": [ 253 | "28", 254 | "34", 255 | "2" 256 | ], 257 | "attribute_code": "category_ids" 258 | }, 259 | { 260 | "value": "173", 261 | "attribute_code": "size" 262 | }, 263 | { 264 | "value": "50", 265 | "attribute_code": "color" 266 | }, 267 | { 268 | "value": "/w/s/wsh11-blue_main.jpg", 269 | "attribute_code": "image" 270 | }, 271 | { 272 | "value": "/w/s/wsh11-blue_main.jpg", 273 | "attribute_code": "small_image" 274 | }, 275 | { 276 | "value": "/w/s/wsh11-blue_main.jpg", 277 | "attribute_code": "thumbnail" 278 | }, 279 | { 280 | "value": "ina-compression-short-29-blue", 281 | "attribute_code": "url_key" 282 | }, 283 | { 284 | "value": "0", 285 | "attribute_code": "msrp_display_actual_price_type" 286 | } 287 | ], 288 | "sgn": "zTgsMQGBOwkjwNxx7TIfl91tI1YsqVfGEcjKGRlyhas" 289 | }, 290 | { 291 | "price": 49, 292 | "name": "Ina Compression Short-29-Orange", 293 | "sku": "WSH11-29-Orange", 294 | "custom_attributes": [ 295 | { 296 | "value": "0", 297 | "attribute_code": "required_options" 298 | }, 299 | { 300 | "value": "0", 301 | "attribute_code": "has_options" 302 | }, 303 | { 304 | "value": "2", 305 | "attribute_code": "tax_class_id" 306 | }, 307 | { 308 | "value": [ 309 | "28", 310 | "34", 311 | "2" 312 | ], 313 | "attribute_code": "category_ids" 314 | }, 315 | { 316 | "value": "173", 317 | "attribute_code": "size" 318 | }, 319 | { 320 | "value": "56", 321 | "attribute_code": "color" 322 | }, 323 | { 324 | "value": "/w/s/wsh11-orange_main.jpg", 325 | "attribute_code": "image" 326 | }, 327 | { 328 | "value": "/w/s/wsh11-orange_main.jpg", 329 | "attribute_code": "small_image" 330 | }, 331 | { 332 | "value": "/w/s/wsh11-orange_main.jpg", 333 | "attribute_code": "thumbnail" 334 | }, 335 | { 336 | "value": "ina-compression-short-29-orange", 337 | "attribute_code": "url_key" 338 | }, 339 | { 340 | "value": "0", 341 | "attribute_code": "msrp_display_actual_price_type" 342 | } 343 | ], 344 | "sgn": "5PFlP6v1jzOtT39LiVgMO1MAONcS-3eCG8yjSBdw38A" 345 | }, 346 | { 347 | "price": 49, 348 | "name": "Ina Compression Short-29-Red", 349 | "sku": "WSH11-29-Red", 350 | "custom_attributes": [ 351 | { 352 | "value": "0", 353 | "attribute_code": "required_options" 354 | }, 355 | { 356 | "value": "0", 357 | "attribute_code": "has_options" 358 | }, 359 | { 360 | "value": "2", 361 | "attribute_code": "tax_class_id" 362 | }, 363 | { 364 | "value": [ 365 | "28", 366 | "34", 367 | "2" 368 | ], 369 | "attribute_code": "category_ids" 370 | }, 371 | { 372 | "value": "173", 373 | "attribute_code": "size" 374 | }, 375 | { 376 | "value": "58", 377 | "attribute_code": "color" 378 | }, 379 | { 380 | "value": "/w/s/wsh11-red_main.jpg", 381 | "attribute_code": "image" 382 | }, 383 | { 384 | "value": "/w/s/wsh11-red_main.jpg", 385 | "attribute_code": "small_image" 386 | }, 387 | { 388 | "value": "/w/s/wsh11-red_main.jpg", 389 | "attribute_code": "thumbnail" 390 | }, 391 | { 392 | "value": "ina-compression-short-29-red", 393 | "attribute_code": "url_key" 394 | }, 395 | { 396 | "value": "0", 397 | "attribute_code": "msrp_display_actual_price_type" 398 | } 399 | ], 400 | "sgn": "HunsCjEGHYLVogZR81EAYNoVRm2qBXQGcxPDeVGSvzg" 401 | } 402 | ], 403 | "category": [ 404 | { 405 | "category_id": 2, 406 | "name": "Default Category" 407 | }, 408 | { 409 | "category_id": 22, 410 | "name": "Bottoms" 411 | }, 412 | { 413 | "category_id": 28, 414 | "name": "Shorts" 415 | }, 416 | { 417 | "category_id": 34, 418 | "name": "Erin Recommends" 419 | } 420 | ] 421 | } --------------------------------------------------------------------------------