diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e57f65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea +.DS_Store +plugin/vendor +build/* +plugin/renderer/item/positions.config +plugin/composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a9722c --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Algolia integration for ZOO + +Plugin to allow any ZOO item to be indexed into an algolia index. +Each ZOO application has its own Algolia configuration, allowing for different indexes per-application. + +Each ZOO type can be mapped through a simple drag and drop configuration using the standard ZOO layouts. + +## Installation + +Download [a release here on github](https://github.com/Weble/ZOOalgolia/releases) and install it using Joomla Installer. + +## Usage + +1. Install the plugin +2. Enable it +3. Go into your ZOO application instance configuration and fill in the required settings ![Config](./img/config.jpg) +4. Go into the ZOO layout configuration and map your type. Use the "Label" to set the key of that property. ![Layout](./img/layout.jpg) + +Now, every time you create / save / delete an item from zoo, it will be instantly indexed in Algolia. + +## Console Commands + +If you have [JoomlaCommands](https://github.com/Weble/JoomlaCommands) installed, the plugin provides an handy `algolia:sync` command to deal with syncing from the command line + +```php bin/console algolia:sync {--app=[ID] --type=[type] --ids=1,2,3}``` + +## Build from source + +```./build.sh``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ccd2b06 --- /dev/null +++ b/build.sh @@ -0,0 +1,9 @@ +# Remove old files +rm -f build/*.zip + +# Zip Plugin +cd plugin/ +composer install +zip -qr ../build/plg_system_zooalgolia.zip ./* + +cd ../ diff --git a/img.png b/img.png new file mode 100644 index 0000000..5963b8e Binary files /dev/null and b/img.png differ diff --git a/img/config.jpg b/img/config.jpg new file mode 100644 index 0000000..aee2d06 Binary files /dev/null and b/img/config.jpg differ diff --git a/img/layout.jpg b/img/layout.jpg new file mode 100644 index 0000000..fa7e877 Binary files /dev/null and b/img/layout.jpg differ diff --git a/plugin/composer.json b/plugin/composer.json new file mode 100644 index 0000000..2a05136 --- /dev/null +++ b/plugin/composer.json @@ -0,0 +1,18 @@ +{ + "require": { + "php": "^7.4 || ^8.0", + "algolia/algoliasearch-client-php": "^3.0" + }, + "autoload": { + "psr-4": { + "Weble\\ZOOAlgolia\\": [ + "src" + ] + } + }, + "config": { + "platform": { + "php": "7.4.22" + } + } +} diff --git a/plugin/config/application.xml b/plugin/config/application.xml new file mode 100644 index 0000000..fede9f4 --- /dev/null +++ b/plugin/config/application.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/plugin/install.php b/plugin/install.php new file mode 100644 index 0000000..b37bb45 --- /dev/null +++ b/plugin/install.php @@ -0,0 +1,36 @@ +enqueueMessage($msg, 'warning'); + + return false; + } + } + + function install($parent) + { + // $db = \JFactory::getDBO(); + } +} diff --git a/plugin/renderer/index.html b/plugin/renderer/index.html new file mode 100644 index 0000000..fa6d84e --- /dev/null +++ b/plugin/renderer/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin/renderer/item/algolia.php b/plugin/renderer/item/algolia.php new file mode 100644 index 0000000..6dadf6f --- /dev/null +++ b/plugin/renderer/item/algolia.php @@ -0,0 +1,3 @@ + diff --git a/plugin/renderer/item/index.html b/plugin/renderer/item/index.html new file mode 100644 index 0000000..fa6d84e --- /dev/null +++ b/plugin/renderer/item/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin/renderer/item/metadata.xml b/plugin/renderer/item/metadata.xml new file mode 100644 index 0000000..dc5db54 --- /dev/null +++ b/plugin/renderer/item/metadata.xml @@ -0,0 +1,7 @@ + + + + Algolia + Map ZOO items to Algolia + + diff --git a/plugin/renderer/item/positions.xml b/plugin/renderer/item/positions.xml new file mode 100644 index 0000000..c7b8557 --- /dev/null +++ b/plugin/renderer/item/positions.xml @@ -0,0 +1,6 @@ + + + + Search + + diff --git a/plugin/src/AlgoliaSync.php b/plugin/src/AlgoliaSync.php new file mode 100644 index 0000000..1fa68fa --- /dev/null +++ b/plugin/src/AlgoliaSync.php @@ -0,0 +1,549 @@ +application = $application; + $this->zoo = $application->app; + $this->renderer = $this->zoo->renderer->create('item', ['path' => $this->zoo->path]); + + if ($application->getParams()->get('global.config.algolia_app_id') && $application->getParams()->get('global.config.algolia_app_id')) { + $this->client = SearchClient::create( + $application->getParams()->get('global.config.algolia_app_id'), + $application->getParams()->get('global.config.algolia_secret_key') + ); + } + + if ($this->client && $application->getParams()->get('global.config.algolia_index')) { + $this->index = $this->client->initIndex($application->getParams()->get('global.config.algolia_index')); + } + } + + public function isConfigured(): bool + { + return $this->client && $this->index; + } + + public function batchSync(array $items): void + { + if (!$this->isConfigured()) { + return; + } + + $save = []; + $delete = []; + foreach ($items as $item) { + $data = $this->algoliaData($item); + + if (!$data) { + $delete[] = $item; + continue; + } + + $save[] = $data; + } + + + if (count($delete) > 0) { + $this->index->deleteObjects($delete, [ + 'objectIDKey' => 'id' + ]); + } + + if (count($save) > 0) { + $this->index->saveObjects($save, [ + 'objectIDKey' => 'id' + ]); + } + } + + public function batchDelete(array $items): void + { + if (!$this->isConfigured()) { + return; + } + + if (count($items) > 0) { + $this->index->deleteObjects($items); + } + } + + public function clearSync(): void + { + if (!$this->isConfigured()) { + return; + } + + $this->index->clearObjects(); + } + + public function sync(\Item $item): bool + { + if (!$this->isConfigured()) { + return false; + } + + $data = $this->algoliaData($item); + + if ($data) { + return $this->index->saveObject($data, [ + 'objectIDKey' => 'id' + ])->valid(); + } + + return $this->index->deleteObject($item->id)->valid(); + } + + private function algoliaData(\Item $item): ?array + { + if (!$item->isPublished()) { + return null; + } + + /** @var Application $application */ + $application = $this->application; + + /** @var Type $type */ + $type = $item->getType(); + + $this->categories = $application->getCategoryTree(); + $data = [ + 'id' => $item->id, + 'url' => [], + 'category_ids' => array_filter(array_merge($item->getRelatedCategoryIds(), array_flatten(array_map(function($id) { + /** @var Category $category */ + $category = $this->categories[$id] ?? null; + if (!$category) { + return []; + } + + return array_map(function($parent) { + return $parent->id; + }, $category->getPathway()); + + }, $item->getRelatedCategoryIds())))) + ]; + + foreach (LanguageHelper::getContentLanguages() as $lang => $languageDetails) { + $data['url'][$lang] = $this->getItemUrls($item, $lang); + }; + + + $config = new Registry($this->renderer->getConfig('root:plugins/system/zooalgolia/renderer/item')->get($application->getGroup() . '.' . $type->identifier . '.algolia')); + $config = $config->get('search', []); + foreach ($config as $elementConfig) { + $element = $item->getElement($elementConfig['element']); + + if (!$element) { + continue; + } + + $key = $elementConfig['element']; + + if ($elementConfig['altlabel'] ?? false) { + $key = $elementConfig['altlabel']; + } + + $value = $this->elementValueFor($element, $elementConfig); + + $data[$key] = $value; + + $parts = explode(".", $key); + + if (count($parts) > 1) { + $newKey = array_shift($parts); + $previousData = $data[$newKey] ?? []; + $language = array_shift($parts); + $data[$newKey] = $previousData + [$language => $value]; + + unset($data[$key]); + + } + } + + return $data; + } + + private function elementValueFor(\Element $element, array $params) + { + if ($element instanceof \ElementItemName) { + return $element->getItem()->name; + } + + if ($element instanceof \ElementItemPrimaryCategory) { + $category = $element->getItem()->getPrimaryCategory(); + + return $this->getDataForCategory($category); + } + + if ($element instanceof \ElementTextPro || $element instanceof ElementTextareaPro) { + + $values = array_filter(array_map(function ($item) use ($params) { + $value = $item['value'] ?? ''; + $value = strip_tags($value); + $max_length = $params['specific._max_car'] ?? 0; + $tr_suffix = $params['specific._max_car_suffix'] ?? ''; + + if ($max_length > 0) { + $value = $item->app->zlstring->truncate($value, $max_length, $tr_suffix); + } + + return strlen($value) > 0 ? $value : null; + }, $element->data())); + + $repeatable = $element->config->get('repeatable', false); + if ($params['filter']['_limit'] ?? null === 1) { + $repeatable = false; + } + + + if ($repeatable) { + return $values; + } + + return array_shift($values); + } + + if ($element instanceof \ElementItemCategory) { + $categories = $element->getItem()->getRelatedCategories(); + + $data = []; + foreach ($categories as $category) { + $data[] = $this->getDataForCategory($category); + } + + return $data; + } + + if ($element instanceof \ElementFilesPro) { + + if (!$element->data()) { + return null; + } + + /* If image */ + if ($element instanceof \ElementImagePro) { + + $values = []; + + foreach ($element->data() as $item) { + + if (!isset($item['file'])) { + continue; + } + + + $values[] = '/' . $item['file']; + } + + if ($element->config->get('repeatable', false)) { + return $values; + } + + return $values ? array_shift($values) : null; + } + + /* If file */ + $values = array_filter(array_map(function ($item) use ($element) { + + if (!isset($item['file'])) { + return null; + } + + $file = $this->zoo->zoo->resizeImage(JPATH_ROOT . '/' . $item['file'], 200, 300); + + $source_dir = $element->getConfig()->files['_source_dir']; + + if ($source_dir) { + $file = Folder::move($file, $source_dir); + } + + $url = $this->zoo->path->relative($file); + + return $url ? '/' . $url : null; + + }, $element->data())); + + if ($element->config->get('repeatable', false)) { + return $values; + } + + return $values ? array_shift($values) : null; + } + + if ($element instanceof \ElementOption) { + + $values = $element->get('option', []); + + $options = []; + foreach ($element->config->get('option', []) as $option) { + if (in_array($option['value'], $values)) { + $options[] = $option['name']; + } + } + + return [ + 'values' => $element->get('option', []), + 'names' => $options + ]; + } + + if ($element instanceof \ElementRepeatable) { + + $values = array_filter(array_map(function ($item) { + $value = $item['value'] ?? ''; + return strlen($value) > 0 ? $value : null; + }, $element->data())); + + if ($element->config->get('repeatable', false)) { + return $values; + } + + return array_shift($values); + } + + if ($element instanceof \ElementItemTag) { + return $element->getItem()->getTags(); + } + + if ($value = $element->get('value', null)) { + return $value; + } + + return null; + } + + protected function findMenuItem($type, $id, $lang) + { + $zoo = App::getInstance('zoo'); + if ($this->menuItems === null) { + $this->menuItems = array_fill_keys( + [ + 'frontpage', + 'category', + 'item', + 'submission', + 'mysubmissions' + ], + [] + ); + + foreach (LanguageHelper::getContentLanguages() as $langCode => $language) { + $menu_items = $zoo->system->application->getMenu('site')->getItems([ + 'language', + 'component_id' + ], [ + $langCode, + \JComponentHelper::getComponent('com_zoo')->id + ]) ?: []; + + /** @var MenuItem $menu_item */ + foreach ($menu_items as $menu_item) { + /** @var Registry $menuItemParams */ + $menuItemParams = $zoo->parameter->create($menu_item->params); + $menuItemLanguage = $menu_item->language; + + switch (@$menu_item->query['view']) { + case 'frontpage': + $this->menuItems['frontpage'][$menuItemParams->get('application')][$menuItemLanguage] = $menu_item; + break; + case 'category': + $this->menuItems['category'][$menuItemParams->get('category')][$menuItemLanguage] = $menu_item; + break; + case 'item': + $this->menuItems['item'][$menuItemParams->get('item_id')][$menuItemLanguage] = $menu_item; + break; + case 'submission': + $this->menuItems[(@$menu_item->query['layout'] == 'submission' ? 'submission' : 'mysubmissions')][$menuItemParams->get('submission')][$menuItemLanguage] = $menu_item; + break; + } + } + } + } + + return @$this->menuItems[$type][$id][$lang] ?: @$this->menuItems[$type][$id]['*']; + } + + private function getItemUrls(\Item $item, $lang) + { + $urls = []; + // Priority 1: direct link to item + if ($menu_item = $this->findMenuItem('item', $item->id, $lang)) { + return [ + 'default' => str_replace('/item/', '/', + Route::link('site', $menu_item->link . '&Itemid=' . $menu_item->id)) + ]; + } + + $menu_item_frontpage = $this->findMenuItem('frontpage', $item->application_id, $lang); + + // build item link + $langCode = $lang === 'en-GB' ? 'en' : 'it'; + $link = $this->getLinkBase() . '&task=item&item_id=' . $item->id . '&lang=' . $langCode; + $categories = $item->getRelatedCategories(true); + $primary_category = $item->getPrimaryCategory(); + + if (!$categories && $menu_item_frontpage) { + return [ + 'default' => str_replace('/item/', '/', + Route::link('site', $link . '&Itemid=' . $menu_item_frontpage->id)) + ]; + } + + if (!$categories) { + return ['default' => str_replace('/item/', '/', Route::link('site', $link))]; + } + + foreach ($categories as $category) { + + $link_cat = $link; + $itemid = null; + + /* If not category */ + if (!$category || !$category->id) { + $urls['default'] = str_replace('/item/', '/', Route::link('site', $link_cat)); + continue; + } + + /* Else */ + // direct link to category + if ($menu_item = $this->findMenuItem('category', $category->id, $lang)) { + $itemid = $menu_item->id; + // find in category path + } elseif ($menu_item = $this->findInCategoryPath($category, $lang)) { + $itemid = $menu_item->id; + } elseif ($menu_item_frontpage) { + $itemid = $menu_item_frontpage->id; + } + + if ($itemid) { + $link_cat .= '&Itemid=' . $itemid; + } + + if ($category->id) { + $urls[$category->id] = str_replace('/item/', '/', Route::link('site', $link_cat)); + } + + if ($primary_category && $primary_category->id == $category->id) { + $urls['default'] = str_replace('/item/', '/', Route::link('site', $link_cat)); + } + } + + return $urls; + } + + private function getCategoryUrl(\Category $category, $lang) + { + $urls = []; + // Priority 1: direct link to item + if ($menu_item = $this->findMenuItem('category', $category->id, $lang)) { + return str_replace('/category/', '/', Route::link('site', $menu_item->link . '&Itemid=' . $menu_item->id)); + } + + $menu_item_frontpage = $this->findMenuItem('frontpage', $category->application_id, $lang); + + // build category link + $langCode = $lang === 'en-GB' ? 'en' : 'it'; + $link = $this->getLinkBase() . '&task=category&category_id=' . $category->id . '&lang=' . $langCode; + + $itemid = null; + if ($menu_item = $this->findInCategoryPath($category, $lang)) { + $itemid = $menu_item->id; + } elseif ($menu_item_frontpage) { + $itemid = $menu_item_frontpage->id; + } + + return str_replace('/category/', '/', Route::link('site', $link . '&Itemid=' . $itemid)); + } + + /** + * Finds the category in the pathway + * + * @param Category $category + * @return stdClass menu item + * @since 2.0 + */ + protected function findInCategoryPath($category, $lang) + { + foreach ($category->getPathway() as $id => $cat) { + if ($menu_item = $this->findMenuItem('category', $id, $lang)) { + return $menu_item; + } + } + } + + /** + * Gets this route helpers link base + * + * @return string the link base + * @since 2.0 + */ + public function getLinkBase() + { + return 'index.php?option=com_zoo'; + } + + + private function getDataForCategory(?Category $category = null, bool $pathway = true): ?array + { + if (!$category) { + return null; + } + + if (!isset($this->categories[$category->id])) { + return null; + } + + /** @var Category $category */ + $category = $this->categories[$category->id]; + + $image = $category->getParams()->get('content.teaser_image'); + $data = [ + 'id' => $category->id, + 'name' => [], + 'url' => [], + 'image' => $image ? '/' . $image : null + ]; + + if ($pathway) { + $data['path'] = []; + foreach ($category->getPathWay() as $cat) { + $data['path'][] = $this->getDataForCategory($cat, false); + } + } + + foreach (LanguageHelper::getContentLanguages() as $lang => $languageDetail) { + $data['name'][$lang] = $category->getParams()->get('content.name_translation')[$lang] ?? $category->name; + $data['url'][$lang] = $this->getCategoryUrl($category, $lang); + } + + return $data; + } +} diff --git a/plugin/src/AlgoliaSyncCommand.php b/plugin/src/AlgoliaSyncCommand.php new file mode 100644 index 0000000..9e14a92 --- /dev/null +++ b/plugin/src/AlgoliaSyncCommand.php @@ -0,0 +1,82 @@ +setDescription('Import ZOO items into Algolia'); + $this->addOption('app', 'a', InputArgument::OPTIONAL, 'The id of the app to import types from'); + $this->addOption('type', 't', InputArgument::OPTIONAL, 'The type to import'); + $this->addOption('ids', null, InputArgument::OPTIONAL, 'The comma separated list of the ids of the items to import'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + require_once(JPATH_BASE.'/plugins/system/zlframework/config.php'); + require_once JPATH_SITE . '/plugins/system/zooalgolia/vendor/autoload.php'; + + $zoo = App::getInstance('zoo'); + + if ($input->getOption('app')) { + $applications = [$zoo->table->application->get($input->getOption('app'))]; + } else { + /** @var \Application[] $applications */ + $applications = $zoo->table->application->all(); + } + + $type = $input->getOption('type'); + + $ids = $input->getOption('ids'); + if ($ids) { + $ids = array_map('intval', explode(",", $ids)); + } + + foreach ($applications as $application) { + $algoliaSync = new AlgoliaSync($application); + if (!$algoliaSync->isConfigured()) { + continue; + } + + $output->writeln('Import Items from ' . $application->name); + + /** @var Item[] $items */ + if ($ids) { + $items = $zoo->table->item->all(['conditions' => 'application_id = ' . $application->id . ' AND id IN ( ' . implode(",", $ids) . ')']); + $total = count($items); + } else { + $items = $zoo->table->item->findAll($application->id); + $total = $application->getItemCount(); + } + + + $progress = new ProgressBar($output, $total); + + foreach ($items as $item) { + + if ($type !== null && $type !== $item->getType()->id) { + continue; + } + + $progress->advance(); + $algoliaSync->sync($item); + } + + $progress->finish(); + } + + return 0; + } +} diff --git a/plugin/zooalgolia.php b/plugin/zooalgolia.php new file mode 100644 index 0000000..96ddd24 --- /dev/null +++ b/plugin/zooalgolia.php @@ -0,0 +1,220 @@ +init(); + } + + public function onGetConsoleCommands(ConsoleApplication $console) + { + $console->addCommands([ + new AlgoliaSyncCommand() + ]); + } + + protected function init() + { + /** @var App $zoo */ + $zoo = App::getInstance('zoo'); + + // register plugin path + if ($path = $zoo->path->path('root:plugins/system/zooalgolia')) { + $zoo->path->register($path, 'zooalgolia'); + } + + $zoo->event->dispatcher->connect('layout:init', [ + $this, + 'initTypeLayouts' + ]); + + $zoo->event->dispatcher->connect('item:deleted', [ + $this, + 'removeItems' + ]); + $zoo->event->dispatcher->connect('item:saved', [ + $this, + 'sync' + ]); + + // only if not submission + if (!strstr(\Joomla\CMS\Factory::getApplication()->input->getCmd('controller'), 'submission')) { + $zoo->event->dispatcher->connect('application:init', array( + $this, + 'applicationAlgoliaConfiguration' + )); + } + } + + public function applicationAlgoliaConfiguration(AppEvent $event) + { + /** @var Application $app */ + $app = $event->getSubject(); + + // Call the helper method + $file = __DIR__ . '/config/application.xml'; + + $this->addApplicationParams($app, $file); + } + + public function sync(AppEvent $event) + { + /** @var \Item $item */ + $item = $event->getSubject(); + + $algoliaSync = new AlgoliaSync($item->getApplication()); + $algoliaSync->sync($item); + } + + public function removeItems(AppEvent $event) + { + /** @var \Item $item */ + $item = $event->getSubject(); + + $algoliaSync = new AlgoliaSync($item->getApplication()); + $algoliaSync->batchDelete([$event->getSubject()->id]); + } + + public function initTypeLayouts(AppEvent $event) + { + $zoo = App::getInstance('zoo'); + $extensions = (array)$event->getReturnValue(); + + // clean all previous layout references + $newExtensions = array(); + foreach ($extensions as $ext) { + if (stripos($ext['name'], 'zooalgolia') === false) { + $newExtensions[] = $ext; + } + } + + // add new ones + $newExtensions[] = [ + 'name' => 'Algolia', + 'path' => $zoo->path->path('zooalgolia:'), + 'type' => 'plugin' + ]; + + $event->setReturnValue($newExtensions); + } + + private function addApplicationParams(Application $app, string $file) + { + // Custom XML File + $xml = simplexml_load_file($file); + + + // Appication XML file + $old_file = $app->app->path->path($app->getResource() . $app->metaxml_file); + $old_xml = simplexml_load_file($old_file); + + + // Application is right? + if (!isset($xml->application)) { + return; + } + + if (!$this->generateNewXmlFile($old_xml, $xml, $app)) { + return; + } + + // Save the new file and set it as the default one + $new_file = $app->app->path->path($app->getResource()) . '/' . \Joomla\Filesystem\File::stripExt($app->metaxml_file) . '_zooalgolia.xml'; + + // Save the new version + $data = $old_xml->asXML(); + \Joomla\Filesystem\File::write($new_file, $data); + + // set it as the default one + $app->metaxml_file = \Joomla\Filesystem\File::stripExt($app->metaxml_file) . '_zooalgolia.xml'; + } + + private function appendChild(\SimpleXMLElement $parent, \SimpleXMLElement $child): void + { + // use dom for this kind of things + $domparent = dom_import_simplexml($parent); + $domchild = dom_import_simplexml($child); + + // Import + $domchild = $domparent->ownerDocument->importNode($domchild, true); + + // Append + $domparent->appendChild($domchild); + } + + private function generateNewXmlFile(\SimpleXMLElement $old_xml, \SimpleXMLElement $xml, Application $app): bool + { + $app_file_changed = false; + + foreach ($xml->application as $a) { + // Check the parameter group + $group = (string)$a->attributes()->group ? (string)$a->attributes()->group : 'all'; + if ($group !== 'all' && $group !== $app->application_group) { + continue; + } + + if (!isset($a->params)) { + continue; + } + + foreach ($a->params as $param) { + // Second level grouping + $group = (string)$param->attributes()->group ? (string)$param->attributes()->group : '_default'; + $new_params = new \SimpleXMLElement(''); + $new_params->addAttribute('group', $group); + + if (!@$old_xml->params) { + continue; + } + + $param_added = false; + // Merge with already existing param groups + foreach ($old_xml->params as $ops) { + + if ((string)$ops->attributes()->group !== $group) { + continue; + } + + $param_added = true; + + // Check for addPath + if (($a->params->attributes()->addpath != '') && !($old_xml->params->attributes()->addpath)) { + @$ops->addAttribute('addpath', $a->params->attributes()->addpath); + $app_file_changed = true; + } + + // Add the parameters for this group + foreach ($param->param as $p) { + // If it doesn't exists already + if (!count($ops->xpath("param[@name='" . $p->attributes()->name . "']"))) { + // Push changes with default + $p->attributes()->default = $this->params->get($p->attributes()->name, $p->attributes()->default); + $this->appendChild($ops, $p); + $app_file_changed = true; + } + } + } + + // Create a new param group if necessary + if (!$param_added) { + $this->appendChild($old_xml, $param); + $app_file_changed = true; + } + } + } + + return $app_file_changed; + } +} diff --git a/plugin/zooalgolia.xml b/plugin/zooalgolia.xml new file mode 100644 index 0000000..8de9276 --- /dev/null +++ b/plugin/zooalgolia.xml @@ -0,0 +1,26 @@ + + + ZOO - Algolia Integration + 2.0.1 + October 2021 + (C) 2021 Weble Srl + GNU GPLv2 or later + Weble Srl + daniele@weble.it + https://weble.it + Algolia Integration for ZOO + zooalgolia + install.php + + zooalgolia.php + renderer + vendor + config + + + +
+
+
+
+