Skip to content

Commit

Permalink
image strategy: set
Browse files Browse the repository at this point in the history
  • Loading branch information
patrick-bigbridge committed Oct 1, 2018
1 parent 85bb8c8 commit 5c85517
Show file tree
Hide file tree
Showing 8 changed files with 254 additions and 13 deletions.
12 changes: 12 additions & 0 deletions Api/ImportConfig.php
Expand Up @@ -136,6 +136,18 @@ class ImportConfig
const EXISTING_IMAGE_STRATEGY_CHECK_IMPORT_DIR = 'check-import-dir';
const EXISTING_IMAGE_STRATEGY_HTTP_CACHING = 'http-caching';

/**
* How to deal with the imported images?
* - add: only add new images and replace existing images with the same name
* - set: like add, but delete existing images that are not named in the import
*
* @var string
*/
public $imageStrategy = self::IMAGE_STRATEGY_ADD;

const IMAGE_STRATEGY_ADD = 'add'; // Only add and update images
const IMAGE_STRATEGY_SET = 'set'; // Add and update images; and also remove existing product images not named in the import

/**
* How to handle products that change type?
*
Expand Down
5 changes: 5 additions & 0 deletions Api/ProductImportWebApi.php
Expand Up @@ -17,6 +17,7 @@ class ProductImportWebApi implements ProductImportWebApiInterface
const OPTION_IMAGE_CACHING = "image-caching";
const OPTION_AUTO_CREATE_CATEGORIES = 'auto-create-categories';
const OPTION_PATH_SEPARATOR = 'path-separator';
const OPTION_IMAGE = 'image';
const OPTION_IMAGE_SOURCE_DIR = 'image-source-dir';
const OPTION_IMAGE_CACHE_DIR = 'image-cache-dir';
const OPTION_URL_KEY_SOURCE = "url-key-source";
Expand Down Expand Up @@ -90,6 +91,10 @@ protected function buildConfig(array $parameters)
$config->imageSourceDir = $parameters[self::OPTION_IMAGE_SOURCE_DIR];
}

if (isset($parameters[self::OPTION_IMAGE])) {
$config->imageStrategy = $parameters[self::OPTION_IMAGE];
}

if (isset($parameters[self::OPTION_IMAGE_CACHE_DIR])) {
$config->imageCacheDir = $parameters[self::OPTION_IMAGE_CACHE_DIR];
}
Expand Down
9 changes: 9 additions & 0 deletions Console/Command/ProductImportCommand.php
Expand Up @@ -35,6 +35,7 @@ class ProductImportCommand extends Command
const OPTION_SKIP_XSD = "skip-xsd";
const OPTION_REDIRECTS = 'redirects';
const OPTION_CATEGORY_PATH_URLS = "category-path-urls";
const OPTION_IMAGE = "image";

/** @var ObjectManagerInterface */
protected $objectManager;
Expand Down Expand Up @@ -90,6 +91,13 @@ protected function configure()
'Changing product type: allowed, forbidden, non-destructive',
ImportConfig::PRODUCT_TYPE_CHANGE_NON_DESTRUCTIVE
),
new InputOption(
self::OPTION_IMAGE,
null,
InputOption::VALUE_OPTIONAL,
'Image handling: add (add or update), set (add, update and delete)',
ImportConfig::IMAGE_STRATEGY_ADD
),
new InputOption(
self::OPTION_IMAGE_CACHING,
null,
Expand Down Expand Up @@ -182,6 +190,7 @@ protected function execute(InputInterface $input, OutputInterface $output)
$config->dryRun = $input->getOption(self::OPTION_DRY_RUN);
$config->autoCreateCategories = $input->getOption(self::OPTION_AUTO_CREATE_CATEGORIES);
$config->productTypeChange = $input->getOption(self::OPTION_PRODUCT_TYPE_CHANGE);
$config->imageStrategy = $input->getOption(self::OPTION_IMAGE);
$config->existingImageStrategy = $input->getOption(self::OPTION_IMAGE_CACHING);
$config->autoCreateOptionAttributes = $input->getOption(self::OPTION_AUTO_CREATE_OPTION);
$config->categoryNamePathSeparator = $input->getOption(self::OPTION_PATH_SEPARATOR);
Expand Down
15 changes: 15 additions & 0 deletions Model/Resource/MetaData.php
Expand Up @@ -3,6 +3,7 @@
namespace BigBridge\ProductImport\Model\Resource;

use BigBridge\ProductImport\Api\Data\Product;
use BigBridge\ProductImport\Api\Data\ProductStoreView;
use BigBridge\ProductImport\Model\Data\EavAttributeInfo;
use BigBridge\ProductImport\Model\Data\LinkInfo;
use BigBridge\ProductImport\Model\Persistence\Magento2DbConnection;
Expand Down Expand Up @@ -259,6 +260,9 @@ class MetaData
/** @var LinkInfo[] */
public $linkInfo;

/** @var int[] */
public $imageAttributeIds;

/**
* MetaData constructor.
*
Expand Down Expand Up @@ -350,6 +354,7 @@ public function init()
$this->productAttributeSetMap = $this->getProductAttributeSetMap();
$this->mediaGalleryAttributeId = $this->getMediaGalleryAttributeId();
$this->productEavAttributeInfo = $this->getProductEavAttributeInfo();
$this->imageAttributeIds = $this->getImageAttributeIds();
}

/**
Expand Down Expand Up @@ -561,6 +566,16 @@ protected function getProductEavAttributeInfo()
return $info;
}

protected function getImageAttributeIds()
{
return [
$this->productEavAttributeInfo[ProductStoreView::BASE_IMAGE]->attributeId,
$this->productEavAttributeInfo[ProductStoreView::SMALL_IMAGE]->attributeId,
$this->productEavAttributeInfo[ProductStoreView::SWATCH_IMAGE]->attributeId,
$this->productEavAttributeInfo[ProductStoreView::THUMBNAIL_IMAGE]->attributeId,
];
}

protected function getMediaGalleryAttributeId()
{
$attributeTable = $this->db->getFullTableName(self::ATTRIBUTE_TABLE);
Expand Down
3 changes: 2 additions & 1 deletion Model/Resource/ProductStorage.php
Expand Up @@ -282,7 +282,8 @@ protected function saveProducts(array $validProducts, ImportConfig $config)
$this->productEntityStorage->insertWebsiteIds($validProducts);
$this->stockItemStorage->storeStockItems($validProducts);
$this->linkedProductStorage->updateLinkedProducts($validProducts);
$this->imageStorage->storeProductImages($validProducts);
$this->imageStorage->storeProductImages($validProducts,
$config->imageStrategy === ImportConfig::IMAGE_STRATEGY_SET);
$this->tierPriceStorage->updateTierPrices($validProducts);

// url_rewrite (must be done after url_key and category_id)
Expand Down
90 changes: 78 additions & 12 deletions Model/Resource/Storage/ImageStorage.php
Expand Up @@ -184,26 +184,34 @@ protected function getDownloadableTemporaryStoragePath(DownloadableProduct $prod

/**
* @param Product[] $products
* @param bool $removeObsoleteImages
*/
public function storeProductImages(array $products)
public function storeProductImages(array $products, bool $removeObsoleteImages)
{
foreach ($products as $product) {

if (empty($product->getImages())) {
continue;
}

$this->storeImages($product);
$this->storeImages($product, $removeObsoleteImages);
}

$this->insertImageRoles($products);
}

protected function storeImages(Product $product)
protected function storeImages(Product $product, bool $removeObsoleteImages)
{
// important! if no images are specified, do not remove all images
if (empty($product->getImages())) {
return;
}

$imageData = $this->loadExistingImageData($product);

// separates new from existing images
// add valueId and actualStoragePath to existing images
list($existingImages, $newImages) = $this->splitNewAndExistingImages($product);
list($existingImages, $newImages) = $this->splitNewAndExistingImages($product, $imageData);

// if specified in the config, remove obsolete images
if ($removeObsoleteImages) {
$this->removeObsoleteImages($product, $existingImages, $imageData);
}

// stores images and metadata
// add valueId and actualStoragePath to new images
Expand All @@ -223,10 +231,65 @@ protected function storeImages(Product $product)
}
}

public function splitNewAndExistingImages(Product $product)
/**
* Removes all images in $imageData (raw database information) that are not found in $existingImages (new import values)
* from gallery tables, product attributes, and file system.
*
* @param Product $product
* @param array $existingImages
* @param array $imageData
*/
protected function removeObsoleteImages(Product $product, array $existingImages, array $imageData)
{
$obsoleteValueIds = [];

// walk through existing raw database information
foreach ($imageData as $imageDatum) {

$storagePath = $imageDatum['value'];

// check if available in current import (new or update)
$found = false;
foreach ($existingImages as $image) {
if ($image->getActualStoragePath() === $storagePath) {
$found = true;
}
}

if (!$found) {

// entry from gallery tables
$obsoleteValueIds[] = $imageDatum['value_id'];

// remove from all image role attributes
$this->db->execute("
DELETE FROM `{$this->metaData->productEntityTable}_varchar`
WHERE
entity_id = ? AND
attribute_id IN (" . $this->db->getMarks($this->metaData->imageAttributeIds) . ") AND
value = ?
", array_merge(
[$product->id],
$this->metaData->imageAttributeIds,
[$storagePath]
));

// remove from file system
@unlink(self::PRODUCT_IMAGE_PATH . $storagePath);
}
}

$this->db->deleteMultiple($this->metaData->mediaGalleryTable, 'value_id', $obsoleteValueIds);
}

/**
* Load data from existing product images
*
* @param Product $product
*/
protected function loadExistingImageData(Product $product)
{
// get data from existing product images
$imageData = $this->db->fetchAllAssoc("
return $this->db->fetchAllAssoc("
SELECT M.`value_id`, M.`value`, M.`disabled`
FROM {$this->metaData->mediaGalleryTable} M
INNER JOIN {$this->metaData->mediaGalleryValueToEntityTable} E ON E.`value_id` = M.`value_id`
Expand All @@ -235,7 +298,10 @@ public function splitNewAndExistingImages(Product $product)
$product->id,
$this->metaData->mediaGalleryAttributeId
]);
}

public function splitNewAndExistingImages(Product $product, array $imageData)
{
$existingImages = [];
$newImages = [];

Expand Down
123 changes: 123 additions & 0 deletions Test/Integration/ImportTest.php
Expand Up @@ -545,6 +545,85 @@ public function testImages()
$importer->flush();

$this->checkImageData($product2, $media, $values);

// remove two images
// image strategy: set

$config = new ImportConfig();
$config->imageStrategy = ImportConfig::IMAGE_STRATEGY_SET;

$config->resultCallback = function(Product $product) use (&$errors) {
$errors = array_merge($errors, $product->getErrors());
};

$importer = self::$factory->createImporter($config);

$product3 = new SimpleProduct("ducky1-product-import");
$product3->setAttributeSetByName("Default");
$global = $product3->global();
$global->setName("Ducky 1");
$global->setPrice('1.00');

$image = $product3->addImage(__DIR__ . '/../images/duck1.jpg');
$product3->global()->setImageGalleryInformation($image, "First duck", 1, true);
$product3->global()->setImageRole($image, ProductStoreView::THUMBNAIL_IMAGE);

$importer->importSimpleProduct($product3);
$importer->flush();

$attributeId = self::$metaData->mediaGalleryAttributeId;

$this->assertEquals([], $errors);
$this->assertTrue(file_exists(BP . '/pub/media/catalog/product/d/u/duck1.jpg'));
$this->assertFalse(file_exists(BP . '/pub/media/catalog/product/d/u/duck2.png'));

$media = [
[$attributeId, '/d/u/duck1.jpg', 'image', '0'],
];

$values = [
['0', $product3->id, 'First duck', '1', '0']
];

$this->checkImageData($product3, $media, $values);

$productS = self::$repository->get("ducky1-product-import", false, 0, true);
$this->assertEquals('/d/u/duck1.jpg', $productS->getThumbnail());
$this->assertEquals(null, $productS->getImage());
$this->assertEquals(null, $productS->getSmallImage());

// no images? do not remove images

$product4 = new SimpleProduct("ducky1-product-import");
$product4->setAttributeSetByName("Default");
$global = $product4->global();
$global->setName("Ducky 1");
$global->setPrice('1.00');

$importer->importSimpleProduct($product4);
$importer->flush();

$attributeId = self::$metaData->mediaGalleryAttributeId;

$this->assertEquals([], $errors);
$this->assertTrue(file_exists(BP . '/pub/media/catalog/product/d/u/duck1.jpg'));
$this->assertFalse(file_exists(BP . '/pub/media/catalog/product/d/u/duck2.png'));

$media = [
[$attributeId, '/d/u/duck1.jpg', 'image', '0'],
];

$values = [
['0', $product4->id, 'First duck', '1', '0']
];

$this->checkImageData($product4, $media, $values);

$productS = self::$repository->get("ducky1-product-import", false, 0, true);
$this->assertEquals('/d/u/duck1.jpg', $productS->getThumbnail());
$this->assertEquals(null, $productS->getImage());
$this->assertEquals(null, $productS->getSmallImage());

}

private function checkImageData($product, $mediaData, $valueData)
Expand Down Expand Up @@ -579,6 +658,50 @@ private function checkImageData($product, $mediaData, $valueData)
$this->assertEquals($valueData, $results);
}

/**
* @throws Exception
*/
public function testGetExistingProduct()
{
$errors = [];

$config = new ImportConfig();

$config->resultCallback = function (Product $product) use (&$errors) {
$errors = array_merge($errors, $product->getErrors());
};

$importer = self::$factory->createImporter($config);

$product1 = new VirtualProduct("spooky-action-at-distance");
$product1->setAttributeSetByName("Default");
$global = $product1->global();
$global->setName("Spooky");
$global->setPrice('1.11');

$importer->importSimpleProduct($product1);
$importer->flush();

$this->assertEquals([], $errors);

// update by sku
$product2 = $importer->getExistingProductBySku("spooky-action-at-distance");
$importer->importSimpleProduct($product2);
$importer->flush();

$this->assertEquals(VirtualProduct::class, get_class($product2));
$this->assertEquals([], $errors);

// update by id
$product3 = $importer->getExistingProductById($product1->id);
$importer->importSimpleProduct($product3);
$importer->flush();

$this->assertEquals(VirtualProduct::class, get_class($product3));
$this->assertEquals($product1->id, $product3->id);
$this->assertEquals([], $errors);
}

/**
* @throws Exception
*/
Expand Down
10 changes: 10 additions & 0 deletions doc/importer.md
Expand Up @@ -502,6 +502,16 @@ Again, this can be store on the store view level:

Note: the library does not remove existing images that are not mentioned by any of your addImage calls.

### Image strategy

By default, the importer does not delete images. Images are only added and updated.

If you want the importer to delete existing product images that are not present in the current import, use this

$config->imageStrategy = ImportConfig::IMAGE_STRATEGY_SET;
However, the importer will still not remove all images if none are added to a product. This is a safety precaution.

### Image caching

Downloading images can be a slow process. That's why the library offers different strategies of dealing with images.
Expand Down

0 comments on commit 5c85517

Please sign in to comment.