diff --git a/README.md b/README.md index ece4641b..a74b42fa 100644 --- a/README.md +++ b/README.md @@ -129,11 +129,11 @@ Example: Button "**P** Add as new product" can be "pressed" by using the `P` key Products can be directly added to the database via looking them up against external services by a barcode. -This can be done in-place using the product picker workflow "External barcode lookup (via plugin)" (the workflow dialog is displayed when entering something unknown in any product input field). +This can be done in-place using the product picker workflow "External barcode lookup" (the workflow dialog is displayed when entering something unknown in any product input field). A plugin for [Open Food Facts](https://world.openfoodfacts.org/) is included and used by default (see the `data/config.php` option `STOCK_BARCODE_LOOKUP_PLUGIN`). -See that plugin or the reference implementation in `data/plugins/DemoBarcodeLookupPlugin.php` if you want to build a plugin. +See that plugin or `plugins/DemoBarcodeLookupPlugin.php` for a commented example implementation if you want to build a plugin. ### Database migrations diff --git a/changelog/52_2.5.0_2019-09-22.md b/changelog/52_2.5.0_2019-09-22.md index efc1fad2..5334feb5 100644 --- a/changelog/52_2.5.0_2019-09-22.md +++ b/changelog/52_2.5.0_2019-09-22.md @@ -9,7 +9,7 @@ - Implemented using [QuaggaJS](https://github.com/serratus/quaggaJS) - camera stream processing happens totally offline / client-side - Please note due to browser security restrictions, this only works when serving Grocy via a secure connection (`https://`) - There is also a `config.php` setting `DISABLE_BROWSER_BARCODE_CAMERA_SCANNING` to disable this, if you don't need it at all (defaults to `false`) -- I you have problems that barcodes are not recognized properly, there is a little "barcode scanner testing page" at [/barcodescannertesting](https://demo.grocy.info/barcodescannertesting) +- If you have problems that barcodes are not recognized properly, there is a little "barcode scanner testing page" at [/barcodescannertesting](https://demo.grocy.info/barcodescannertesting) - => Quick video demo: https://www.youtube.com/watch?v=Y5YH6IJFnfc ### Stock improvements/fixes diff --git a/changelog/77_UNRELEASED_xxxx-xx-xx.md b/changelog/77_UNRELEASED_xxxx-xx-xx.md index 8ffe9473..ef25e8e6 100644 --- a/changelog/77_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/77_UNRELEASED_xxxx-xx-xx.md @@ -10,7 +10,7 @@ ### Stock -- Added a new product picker workflow "External barcode lookup (via plugin)" +- Added a new product picker workflow "External barcode lookup" - This executes the configured barcode lookup plugin with the given barcode - If the lookup was successful, the product edit page of the created product is displayed, where the product setup can be completed (if required) - After that, the transaction is continued with that product as usual diff --git a/config-dist.php b/config-dist.php index 880c3750..e763c5e2 100644 --- a/config-dist.php +++ b/config-dist.php @@ -64,8 +64,9 @@ Setting('BASE_PATH', ''); Setting('BASE_URL', '/'); // The plugin to use for external barcode lookups, -// must be the filename (folder /data/plugins) without the .php extension, -// see /data/plugins/DemoBarcodeLookupPlugin.php for an example implementation +// must be the filename (folder "/plugins" for built-in plugins or "/data/plugins" for user plugins) without the .php extension, +// see /plugins/DemoBarcodeLookupPlugin.php for a commented example implementation +// Leave empty to disable external barcode lookups Setting('STOCK_BARCODE_LOOKUP_PLUGIN', 'OpenFoodFactsBarcodeLookupPlugin'); // If, however, your webserver does not support URL rewriting, set this to true diff --git a/controllers/BaseController.php b/controllers/BaseController.php index f1f15166..d948df20 100644 --- a/controllers/BaseController.php +++ b/controllers/BaseController.php @@ -29,7 +29,7 @@ class BaseController } protected $AppContainer; - private $View; + protected $View; protected function getApiKeyService() { diff --git a/controllers/StockController.php b/controllers/StockController.php index e434c37e..d06c4c4c 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -4,6 +4,7 @@ namespace Grocy\Controllers; use Grocy\Helpers\Grocycode; use Grocy\Services\RecipesService; +use DI\Container; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -11,6 +12,24 @@ class StockController extends BaseController { use GrocycodeTrait; + public function __construct(Container $container) + { + parent::__construct($container); + + try + { + $externalBarcodeLookupPluginName = $this->getStockService()->GetExternalBarcodeLookupPluginName(); + } + catch (\Exception) + { + $externalBarcodeLookupPluginName = ''; + } + finally + { + $this->View->set('ExternalBarcodeLookupPluginName', $externalBarcodeLookupPluginName); + } + } + public function Consume(Request $request, Response $response, array $args) { return $this->renderPage($response, 'consume', [ diff --git a/data/plugins/.gitignore b/data/plugins/.gitignore index a1b771e5..d6b7ef32 100644 --- a/data/plugins/.gitignore +++ b/data/plugins/.gitignore @@ -1,3 +1,2 @@ * !.gitignore -!DemoBarcodeLookupPlugin.php diff --git a/helpers/BaseBarcodeLookupPlugin.php b/helpers/BaseBarcodeLookupPlugin.php index 1978cfeb..88736966 100644 --- a/helpers/BaseBarcodeLookupPlugin.php +++ b/helpers/BaseBarcodeLookupPlugin.php @@ -4,14 +4,19 @@ namespace Grocy\Helpers; abstract class BaseBarcodeLookupPlugin { - final public function __construct($locations, $quantityUnits) + // That's a "self-referencing constant" and forces the child class to define it + public const PLUGIN_NAME = self::PLUGIN_NAME; + + final public function __construct($locations, $quantityUnits, $userSettings) { $this->Locations = $locations; $this->QuantityUnits = $quantityUnits; + $this->UserSettings = $userSettings; } protected $Locations; protected $QuantityUnits; + protected $UserSettings; final public function Lookup($barcode) { @@ -40,8 +45,8 @@ abstract class BaseBarcodeLookupPlugin 'location_id', 'qu_id_purchase', 'qu_id_stock', - 'qu_factor_purchase_to_stock', - 'barcode' + '__qu_factor_purchase_to_stock', + '__barcode' ]; foreach ($minimunNeededProperties as $prop) @@ -58,25 +63,25 @@ abstract class BaseBarcodeLookupPlugin $locationId = $pluginOutput['location_id']; if (FindObjectInArrayByPropertyValue($this->Locations, 'id', $locationId) === null) { - throw new \Exception("Location $locationId is not a valid location id"); + throw new \Exception("Provided location_id ($locationId) is not a valid location id"); } $quIdPurchase = $pluginOutput['qu_id_purchase']; if (FindObjectInArrayByPropertyValue($this->QuantityUnits, 'id', $quIdPurchase) === null) { - throw new \Exception("Location $quIdPurchase is not a valid quantity unit id"); + throw new \Exception("Provided qu_id_purchase ($quIdPurchase) is not a valid quantity unit id"); } $quIdStock = $pluginOutput['qu_id_stock']; if (FindObjectInArrayByPropertyValue($this->QuantityUnits, 'id', $quIdStock) === null) { - throw new \Exception("Location $quIdStock is not a valid quantity unit id"); + throw new \Exception("Provided qu_id_stock ($quIdStock) is not a valid quantity unit id"); } - $quFactor = $pluginOutput['qu_factor_purchase_to_stock']; + $quFactor = $pluginOutput['__qu_factor_purchase_to_stock']; if (empty($quFactor) || !is_numeric($quFactor)) { - throw new \Exception('Quantity unit factor is empty or not a number'); + throw new \Exception('Provided __qu_factor_purchase_to_stock is empty or not a number'); } return $pluginOutput; diff --git a/localization/strings.pot b/localization/strings.pot index 9bcde374..f0e77718 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2424,7 +2424,7 @@ msgid_plural "This means %1$s labels will be printed" msgstr[0] "" msgstr[1] "" -msgid "External barcode lookup (via plugin)" +msgid "External barcode lookup" msgstr "" msgid "Error while executing the barcode lookup plugin" diff --git a/migrations/0240.php b/migrations/0240.php new file mode 100644 index 00000000..68655a3f --- /dev/null +++ b/migrations/0240.php @@ -0,0 +1,10 @@ +Locations contains all locations $this->QuantityUnits contains all quantity units + $this->UserSettings contains all user settings */ /* @@ -41,12 +42,14 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin $location = FindObjectInArrayByPropertyValue($this->Locations, 'name', 'Fridge'); */ + // Provide a name + public const PLUGIN_NAME = 'Demo'; + /* This class must implement the protected abstract function ExecuteLookup($barcode), which is called with the barcode that needs to be looked up and must return an - associative array of the product model or null, when nothing was found for the barcode - - The returned array must be a valid product object (see the "products" database table for all available properties/columns): + associative array of the product model (see the "products" database table for all available properties/columns) + or null when nothing was found for the barcode: [ // Required properties: 'name' => '', @@ -54,12 +57,12 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin 'qu_id_purchase' => 1, // A valid id of a quantity unit object, check against $this->QuantityUnits 'qu_id_stock' => 1, // A valid id of a quantity unit object, check against $this->QuantityUnits - // These are virtual properties (not part of the product object, will be automatically handled as needed) - 'qu_factor_purchase_to_stock' => 1, // Normally 1 when quantity unit stock and purchase is the same - 'barcode' => $barcode // The barcode of the product, maybe just pass through $barcode or manipulate it if necessary + // Required virtual properties (not part of the product object, will be automatically handled as needed): + '__qu_factor_purchase_to_stock' => 1, // Normally 1 when quantity unit stock and purchase is the same + '__barcode' => $barcode // The barcode of the product, maybe just pass through $barcode or manipulate it if necessary - // Optional virtual properties - 'image_url' => '' // When provided, the corresponding image will be downloaded and set as the product picture + // Optional virtual properties (not part of the product object, will be automatically handled as needed): + '__image_url' => '' // When provided, the corresponding image will be downloaded and set as the product picture ] */ protected function ExecuteLookup($barcode) @@ -76,13 +79,27 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin } else { + // Take the preset user setting or otherwise simply the first existing location + $locationId = $this->Locations[0]->id; + if ($this->UserSettings['product_presets_location_id'] != -1) + { + $locationId = $this->UserSettings['product_presets_location_id']; + } + + // Take the preset user setting or otherwise simply the first existing quantity unit + $quId = $this->QuantityUnits[0]->id; + if ($this->UserSettings['product_presets_qu_id'] != -1) + { + $quId = $this->UserSettings['product_presets_qu_id']; + } + return [ 'name' => 'LookedUpProduct_' . RandomString(5), - 'location_id' => $this->Locations[0]->id, // Take the first location as a default - 'qu_id_purchase' => $this->QuantityUnits[0]->id, // Take the first QU as a default - 'qu_id_stock' => $this->QuantityUnits[0]->id, // Take the first QU as a default - 'qu_factor_purchase_to_stock' => 1, - 'barcode' => $barcode + 'location_id' => $locationId, + 'qu_id_purchase' => $quId, + 'qu_id_stock' => $quId, + '__qu_factor_purchase_to_stock' => 1, + '__barcode' => $barcode ]; } } diff --git a/data/plugins/OpenFoodFactsBarcodeLookupPlugin.php b/plugins/OpenFoodFactsBarcodeLookupPlugin.php similarity index 54% rename from data/plugins/OpenFoodFactsBarcodeLookupPlugin.php rename to plugins/OpenFoodFactsBarcodeLookupPlugin.php index 45ea6f3e..8ba8fe19 100644 --- a/data/plugins/OpenFoodFactsBarcodeLookupPlugin.php +++ b/plugins/OpenFoodFactsBarcodeLookupPlugin.php @@ -10,10 +10,12 @@ use GuzzleHttp\Client; class OpenFoodFactsBarcodeLookupPlugin extends BaseBarcodeLookupPlugin { + public const PLUGIN_NAME = 'Open Food Facts'; + protected function ExecuteLookup($barcode) { $webClient = new Client(['http_errors' => false]); - $response = $webClient->request('GET', "https://world.openfoodfacts.net/api/v2/product/$barcode?fields=product_name,image_url"); + $response = $webClient->request('GET', "https://world.openfoodfacts.net/api/v2/product/$barcode?fields=product_name,image_url", ['headers' => ['User-Agent' => 'GrocyOpenFoodFactsBarcodeLookupPlugin/1.0 (https://grocy.info)']]); $statusCode = $response->getStatusCode(); // Guzzle throws exceptions for connection errors, so nothing to do on that here @@ -32,14 +34,28 @@ class OpenFoodFactsBarcodeLookupPlugin extends BaseBarcodeLookupPlugin $imageUrl = $data->product->image_url; } + // Take the preset user setting or otherwise simply the first existing location + $locationId = $this->Locations[0]->id; + if ($this->UserSettings['product_presets_location_id'] != -1) + { + $locationId = $this->UserSettings['product_presets_location_id']; + } + + // Take the preset user setting or otherwise simply the first existing quantity unit + $quId = $this->QuantityUnits[0]->id; + if ($this->UserSettings['product_presets_qu_id'] != -1) + { + $quId = $this->UserSettings['product_presets_qu_id']; + } + return [ 'name' => $data->product->product_name, - 'location_id' => $this->Locations[0]->id, - 'qu_id_purchase' => $this->QuantityUnits[0]->id, - 'qu_id_stock' => $this->QuantityUnits[0]->id, - 'qu_factor_purchase_to_stock' => 1, - 'barcode' => $barcode, - 'image_url' => $imageUrl + 'location_id' => $locationId, + 'qu_id_purchase' => $quId, + 'qu_id_stock' => $quId, + '__qu_factor_purchase_to_stock' => 1, + '__barcode' => $barcode, + '__image_url' => $imageUrl ]; } } diff --git a/public/viewjs/components/productpicker.js b/public/viewjs/components/productpicker.js index 8d8538ee..8001f45e 100644 --- a/public/viewjs/components/productpicker.js +++ b/public/viewjs/components/productpicker.js @@ -250,9 +250,13 @@ $('#product_id_text_input').on('blur', function(e) Grocy.Components.ProductPicker.PopupOpen = false; window.location.href = U('/product/new?flow=InplaceNewProductWithBarcode&barcode=' + encodeURIComponent(input) + '&returnto=' + encodeURIComponent(Grocy.CurrentUrlRelative + "?flow=InplaceAddBarcodeToExistingProduct&barcode=" + input + "&" + embedded) + "&" + embedded); } - }, - barcodepluginlookup: { - label: 'E ' + __t('External barcode lookup (via plugin)'), + } + }; + + if (Grocy.ExternalBarcodeLookupPluginName) + { + buttons.barcodepluginlookup = { + label: 'E ' + __t('External barcode lookup') + ' ' + Grocy.ExternalBarcodeLookupPluginName + '', className: 'btn-dark add-new-product-plugin-dialog-button responsive-button ' + addProductWorkflowsAdditionalCssClasses, callback: function() { @@ -279,8 +283,8 @@ $('#product_id_text_input').on('blur', function(e) } ); } - } - }; + }; + } if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_DISABLE_BROWSER_BARCODE_CAMERA_SCANNING) { @@ -332,7 +336,8 @@ $('#product_id_text_input').on('blur', function(e) size: 'large', backdrop: true, closeButton: false, - buttons: buttons + buttons: buttons, + className: "wider" }).on('keypress', function(e) { if (e.key === 'B' || e.key === 'b') diff --git a/services/BaseService.php b/services/BaseService.php index 1fba730a..13825068 100644 --- a/services/BaseService.php +++ b/services/BaseService.php @@ -71,4 +71,9 @@ class BaseService { return FilesService::getInstance(); } + + protected function getApplicationService() + { + return ApplicationService::getInstance(); + } } diff --git a/services/DemoDataGeneratorService.php b/services/DemoDataGeneratorService.php index 55dc546e..e319424e 100644 --- a/services/DemoDataGeneratorService.php +++ b/services/DemoDataGeneratorService.php @@ -220,6 +220,9 @@ class DemoDataGeneratorService extends BaseService INSERT INTO userfield_values (field_id, object_id, value) VALUES (1, 2, '{$this->__t_sql('Example field value...')}'); INSERT INTO userfield_values (field_id, object_id, value) VALUES (2, 2, '{$this->__t_sql('Example field value...')}'); + INSERT INTO user_settings(user_id, key, value) VALUES (1, 'product_presets_location_id', '3'); -- Pantry + INSERT INTO user_settings(user_id, key, value) VALUES (1, 'product_presets_qu_id', '3'); -- Pack + INSERT INTO migrations (migration) VALUES (-1); "; $this->getDatabaseService()->ExecuteDbStatement($sql); diff --git a/services/StockService.php b/services/StockService.php index af8090c5..0e3b17f3 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -592,9 +592,15 @@ class StockService extends BaseService return $transactionId; } + public function GetExternalBarcodeLookupPluginName() + { + $plugin = $this->LoadExternalBarcodeLookupPlugin(); + return $plugin::PLUGIN_NAME; + } + public function ExternalBarcodeLookup($barcode, $addFoundProduct) { - $plugin = $this->LoadBarcodeLookupPlugin(); + $plugin = $this->LoadExternalBarcodeLookupPlugin(); $pluginOutput = $plugin->Lookup($barcode); if ($pluginOutput !== null) @@ -602,21 +608,24 @@ class StockService extends BaseService // Lookup was successful if ($addFoundProduct === true) { + if ($this->getDatabase()->products()->where('name = :1', $pluginOutput['name'])->fetch() !== null) + { + throw new \Exception('Product "' . $pluginOutput['name'] . '" already exists'); + } + // Add product to database and include new product id in output $productData = $pluginOutput; - unset($productData['barcode'], $productData['qu_factor_purchase_to_stock'], $productData['image_url']); // Virtual lookup plugin properties + unset($productData['__barcode'], $productData['__qu_factor_purchase_to_stock'], $productData['__image_url']); // Virtual lookup plugin properties // Download and save image if provided - if (isset($pluginOutput['image_url']) && !empty($pluginOutput['image_url'])) + if (isset($pluginOutput['__image_url']) && !empty($pluginOutput['__image_url'])) { try { $webClient = new Client(); - $response = $webClient->request('GET', $pluginOutput['image_url']); - $fileName = $pluginOutput['barcode'] . '.' . pathinfo($pluginOutput['image_url'], PATHINFO_EXTENSION); - $fileHandle = fopen($this->getFilesService()->GetFilePath('productpictures', $fileName), 'wb'); - fwrite($fileHandle, $response->getBody()); - fclose($fileHandle); + $response = $webClient->request('GET', $pluginOutput['__image_url'], ['headers' => ['User-Agent' => 'Grocy/' . $this->getApplicationService()->GetInstalledVersion()->Version . ' (https://grocy.info)']]); + $fileName = $pluginOutput['__barcode'] . '.' . pathinfo($pluginOutput['__image_url'], PATHINFO_EXTENSION); + file_put_contents($this->getFilesService()->GetFilePath('productpictures', $fileName), $response->getBody()); $productData['picture_file_name'] = $fileName; } catch (\Exception) @@ -630,7 +639,7 @@ class StockService extends BaseService $this->getDatabase()->product_barcodes()->createRow([ 'product_id' => $newProductRow->id, - 'barcode' => $pluginOutput['barcode'] + 'barcode' => $pluginOutput['__barcode'] ])->save(); if ($pluginOutput['qu_id_stock'] != $pluginOutput['qu_id_purchase']) @@ -639,7 +648,7 @@ class StockService extends BaseService 'product_id' => $newProductRow->id, 'from_qu_id' => $pluginOutput['qu_id_purchase'], 'to_qu_id' => $pluginOutput['qu_id_stock'], - 'factor' => $pluginOutput['qu_factor_purchase_to_stock'], + 'factor' => $pluginOutput['__qu_factor_purchase_to_stock'], ])->save(); } @@ -1730,7 +1739,7 @@ class StockService extends BaseService } } - private function LoadBarcodeLookupPlugin() + private function LoadExternalBarcodeLookupPlugin() { $pluginName = defined('GROCY_STOCK_BARCODE_LOOKUP_PLUGIN') ? GROCY_STOCK_BARCODE_LOOKUP_PLUGIN : ''; if (empty($pluginName)) @@ -1738,11 +1747,18 @@ class StockService extends BaseService throw new \Exception('No barcode lookup plugin defined'); } - $path = GROCY_DATAPATH . "/plugins/$pluginName.php"; - if (file_exists($path)) + // User plugins take precedence + $standardPluginPath = __DIR__ . "/../plugins/$pluginName.php"; + $userPluginPath = GROCY_DATAPATH . "/plugins/$pluginName.php"; + if (file_exists($userPluginPath)) { - require_once $path; - return new $pluginName($this->getDatabase()->locations()->where('active = 1')->fetchAll(), $this->getDatabase()->quantity_units()->fetchAll()); + require_once $userPluginPath; + return new $pluginName($this->getDatabase()->locations()->where('active = 1')->fetchAll(), $this->getDatabase()->quantity_units()->where('active = 1')->fetchAll(), $this->getUsersService()->GetUserSettings(GROCY_USER_ID)); + } + elseif (file_exists($standardPluginPath)) + { + require_once $standardPluginPath; + return new $pluginName($this->getDatabase()->locations()->where('active = 1')->fetchAll(), $this->getDatabase()->quantity_units()->where('active = 1')->fetchAll(), $this->getUsersService()->GetUserSettings(GROCY_USER_ID)); } else { diff --git a/views/components/productpicker.blade.php b/views/components/productpicker.blade.php index e9815c82..d7defad8 100644 --- a/views/components/productpicker.blade.php +++ b/views/components/productpicker.blade.php @@ -4,6 +4,11 @@ @push('componentScripts') @endpush +@push('componentScripts') + +@endpush @endonce @php if(empty($disallowAddProductWorkflows)) { $disallowAddProductWorkflows = false; } @endphp