diff --git a/.gitignore b/.gitignore
index 2eddb0df63..6f0aad4e82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@
/config/connexions.yml
.DS_Store
/vendor
+/plugins
composer.phar
behat.yml
/datas
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a151f62b45..ca1705783c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@
- Add bin/developer console for developement purpose.
- Add possibility to delete a basket from the workzone basket browser.
- Add localized labels for databox documentary fields.
+ - Add plugin architecture for third party modules and customization.
* 3.7.12 (2013-05-13)
diff --git a/bin/console b/bin/console
index e8eb5cf321..48ac46beb3 100755
--- a/bin/console
+++ b/bin/console
@@ -24,8 +24,10 @@ use Alchemy\Phrasea\Command\RecordAdd;
use Alchemy\Phrasea\Command\RescanTechnicalDatas;
use Alchemy\Phrasea\Command\UpgradeDBDatas;
use Alchemy\Phrasea\CLI;
+use Alchemy\Phrasea\Command\Plugin\AddPlugin;
+use Alchemy\Phrasea\Command\Plugin\RemovePlugin;
-require_once __DIR__ . '/../vendor/autoload.php';
+require_once __DIR__ . '/../lib/autoload.php';
try {
$app = new CLI("
@@ -90,6 +92,9 @@ try {
$app->command(new RescanTechnicalDatas('records:rescan-technical-datas'));
$app->command(new BuildMissingSubdefs('records:build-missing-subdefs'));
+ $app->command(new AddPlugin());
+ $app->command(new RemovePlugin());
+
$result_code = is_int($app->run()) ? : 1;
} catch (\Exception $e) {
$result_code = 1;
diff --git a/bin/developer b/bin/developer
index 849f3be29d..94126f12f8 100755
--- a/bin/developer
+++ b/bin/developer
@@ -36,7 +36,7 @@ use Doctrine\ORM\Tools\Console\Command\ConvertMappingCommand;
use Doctrine\ORM\Tools\Console\Command\RunDqlCommand;
use Doctrine\ORM\Tools\Console\Command\ValidateSchemaCommand;
-require_once __DIR__ . '/../vendor/autoload.php';
+require_once __DIR__ . '/../lib/autoload.php';
try {
$cli = new CLI("
diff --git a/composer.json b/composer.json
index 9594c84fae..d831160c28 100644
--- a/composer.json
+++ b/composer.json
@@ -16,8 +16,10 @@
"facebook/php-sdk" : "~3.0",
"gedmo/doctrine-extensions" : "~2.3.0",
"google-plus/api-client" : "~0.6.1",
+ "guzzle/guzzle" : "~3.0",
"imagine/imagine" : "dev-ColorSpaces@dev",
"jms/serializer" : "~0.10",
+ "justinrainbow/json-schema" : "~1.3",
"mediavorus/mediavorus" : "~0.3",
"media-alchemyst/media-alchemyst" : "~0.2.3",
"monolog/monolog" : "~1.3.0",
@@ -27,6 +29,7 @@
"neutron/silex-filesystem-provider": "dev-master",
"neutron/sphinxsearch-api" : "~2.0.6",
"neutron/recaptcha" : "~0.1.0",
+ "neutron/temporary-filesystem" : "~2.0",
"php-xpdf/php-xpdf" : "~0.1.1",
"phpexiftool/phpexiftool" : "~0.2, >=0.2.2",
"silex/silex" : "~1.0.0",
diff --git a/composer.lock b/composer.lock
index 64ce9978ac..08b7c8e15f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -3,7 +3,7 @@
"This file locks the dependencies of your project to a known state",
"Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"
],
- "hash": "7fd45681e93a7fe1d0f4e691818ad4e2",
+ "hash": "06eddf18808e8091de82ec3b5d6b699e",
"packages": [
{
"name": "BadFaith/BadFaith",
@@ -1091,6 +1091,64 @@
],
"time": "2013-03-28 16:41:24"
},
+ {
+ "name": "justinrainbow/json-schema",
+ "version": "1.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/justinrainbow/json-schema.git",
+ "reference": "1.3.1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/1.3.1",
+ "reference": "1.3.1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "bin": [
+ "bin/validate-json"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "JsonSchema": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Igor Wiedler",
+ "email": "igor@wiedler.ch",
+ "homepage": "http://wiedler.ch/igor/"
+ },
+ {
+ "name": "Bruno Prieto Reis",
+ "email": "bruno.p.reis@gmail.com"
+ },
+ {
+ "name": "Justin Rainbow",
+ "email": "justin.rainbow@gmail.com"
+ },
+ {
+ "name": "Robert Schönthal",
+ "email": "seroscho@googlemail.com",
+ "homepage": "http://digitalkaoz.net"
+ }
+ ],
+ "description": "A library to validate a json schema.",
+ "homepage": "https://github.com/justinrainbow/json-schema",
+ "keywords": [
+ "json",
+ "schema"
+ ],
+ "time": "2013-02-21 04:49:21"
+ },
{
"name": "media-alchemyst/media-alchemyst",
"version": "0.2.5",
@@ -1540,6 +1598,43 @@
],
"time": "2012-10-23 02:05:12"
},
+ {
+ "name": "neutron/temporary-filesystem",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/romainneutron/Temporary-Filesystem.git",
+ "reference": "2.0.1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/romainneutron/Temporary-Filesystem/zipball/2.0.1",
+ "reference": "2.0.1",
+ "shasum": ""
+ },
+ "require": {
+ "symfony/filesystem": ">=2.0,<3.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "Neutron": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Romain Neutron",
+ "email": "imprec@gmail.com",
+ "homepage": "http://www.lickmychip.com/"
+ }
+ ],
+ "description": "Symfony filesystem extension to handle temporary files",
+ "time": "2013-04-09 08:38:21"
+ },
{
"name": "php-ffmpeg/php-ffmpeg",
"version": "0.2.4",
@@ -2722,21 +2817,21 @@
},
{
"name": "behat/mink",
- "version": "v1.4.3",
+ "version": "v1.5.0",
"source": {
"type": "git",
"url": "https://github.com/Behat/Mink.git",
- "reference": "v1.4.3"
+ "reference": "v1.5.0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Behat/Mink/zipball/v1.4.3",
- "reference": "v1.4.3",
+ "url": "https://api.github.com/repos/Behat/Mink/zipball/v1.5.0",
+ "reference": "v1.5.0",
"shasum": ""
},
"require": {
"php": ">=5.3.1",
- "symfony/css-selector": ">=2.0,<2.4-dev"
+ "symfony/css-selector": ">=2.0,<3.0"
},
"suggest": {
"behat/mink-browserkit-driver": "extremely fast headless driver for Symfony\\Kernel-based apps (Sf2, Silex)",
@@ -2747,7 +2842,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-develop": "1.4.x-dev"
+ "dev-develop": "1.5.x-dev"
}
},
"autoload": {
@@ -2773,24 +2868,24 @@
"testing",
"web"
],
- "time": "2013-03-02 15:53:18"
+ "time": "2013-04-13 23:39:27"
},
{
"name": "behat/mink-browserkit-driver",
- "version": "v1.0.5",
+ "version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/Behat/MinkBrowserKitDriver.git",
- "reference": "v1.0.5"
+ "reference": "v1.1.0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Behat/MinkBrowserKitDriver/zipball/v1.0.5",
- "reference": "v1.0.5",
+ "url": "https://api.github.com/repos/Behat/MinkBrowserKitDriver/zipball/v1.1.0",
+ "reference": "v1.1.0",
"shasum": ""
},
"require": {
- "behat/mink": ">=1.4.3,<1.5",
+ "behat/mink": ">=1.5,<1.6",
"php": ">=5.3.1",
"symfony/browser-kit": ">=2.0,<3.0",
"symfony/dom-crawler": ">=2.0,<3.0"
@@ -2801,7 +2896,7 @@
"type": "mink-driver",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "1.1.x-dev"
}
},
"autoload": {
@@ -2828,30 +2923,29 @@
"browser",
"testing"
],
- "time": "2013-04-13 12:17:15"
+ "time": "2013-04-13 23:46:30"
},
{
"name": "behat/mink-extension",
- "version": "v1.0.1",
+ "version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/Behat/MinkExtension.git",
- "reference": "v1.0.1"
+ "reference": "v1.1.0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Behat/MinkExtension/zipball/v1.0.1",
- "reference": "v1.0.1",
+ "url": "https://api.github.com/repos/Behat/MinkExtension/zipball/v1.1.0",
+ "reference": "v1.1.0",
"shasum": ""
},
"require": {
- "behat/behat": ">=2.4,<2.5-dev",
- "behat/mink": ">=1.4,<1.5-dev",
+ "behat/behat": ">=2.4.5,<2.5",
+ "behat/mink": ">=1.4.3,<1.6-dev",
"php": ">=5.3.2"
},
"require-dev": {
- "behat/mink-goutte-driver": "1.0.*",
- "fabpot/goutte": "1.0.*@dev"
+ "behat/mink-goutte-driver": ">=1.0,<2.0"
},
"type": "behat-extension",
"extra": {
@@ -2883,7 +2977,7 @@
"test",
"web"
],
- "time": "2013-02-19 23:49:16"
+ "time": "2013-05-23 12:32:57"
},
{
"name": "behat/mink-goutte-driver",
@@ -2938,27 +3032,27 @@
},
{
"name": "behat/mink-selenium2-driver",
- "version": "v1.0.6",
+ "version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/Behat/MinkSelenium2Driver.git",
- "reference": "v1.0.6"
+ "reference": "v1.1.0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Behat/MinkSelenium2Driver/zipball/v1.0.6",
- "reference": "v1.0.6",
+ "url": "https://api.github.com/repos/Behat/MinkSelenium2Driver/zipball/v1.1.0",
+ "reference": "v1.1.0",
"shasum": ""
},
"require": {
- "behat/mink": ">=1.4.3,<1.5",
+ "behat/mink": ">=1.5,<1.6",
"instaclick/php-webdriver": ">=1.0.12.0,<1.1",
"php": ">=5.3.1"
},
"type": "mink-driver",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "1.1.x-dev"
}
},
"autoload": {
@@ -2992,7 +3086,7 @@
"testing",
"webdriver"
],
- "time": "2013-04-13 12:56:28"
+ "time": "2013-04-13 23:51:01"
},
{
"name": "doctrine/data-fixtures",
@@ -3037,7 +3131,7 @@
],
"authors": [
{
- "name": "Jonathan H. Wage",
+ "name": "Jonathan Wage",
"email": "jonwage@gmail.com",
"homepage": "http://www.jwage.com/"
}
diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php
index 8d29579711..2672f79248 100644
--- a/lib/Alchemy/Phrasea/Application.php
+++ b/lib/Alchemy/Phrasea/Application.php
@@ -89,9 +89,11 @@ use Alchemy\Phrasea\Core\Provider\NotificationDelivererServiceProvider;
use Alchemy\Phrasea\Core\Provider\ORMServiceProvider;
use Alchemy\Phrasea\Core\Provider\PhraseanetServiceProvider;
use Alchemy\Phrasea\Core\Provider\PhraseaVersionServiceProvider;
+use Alchemy\Phrasea\Core\Provider\PluginServiceProvider;
use Alchemy\Phrasea\Core\Provider\RegistrationServiceProvider;
use Alchemy\Phrasea\Core\Provider\SearchEngineServiceProvider;
use Alchemy\Phrasea\Core\Provider\TaskManagerServiceProvider;
+use Alchemy\Phrasea\Core\Provider\TemporaryFilesystemServiceProvider;
use Alchemy\Phrasea\Core\Provider\TokensServiceProvider;
use Alchemy\Phrasea\Core\Provider\UnicodeServiceProvider;
use Alchemy\Phrasea\Exception\InvalidArgumentException;
@@ -241,6 +243,7 @@ class Application extends SilexApplication
$this->register(new InstallerServiceProvider());
$this->register(new PhraseanetServiceProvider());
$this->register(new PhraseaVersionServiceProvider());
+ $this->register(new PluginServiceProvider());
$this->register(new PHPExiftoolServiceProvider());
$this->register(new ReCaptchaServiceProvider());
@@ -262,6 +265,7 @@ class Application extends SilexApplication
$this->register(new ServiceControllerServiceProvider());
$this->register(new SwiftmailerServiceProvider());
$this->register(new TaskManagerServiceProvider());
+ $this->register(new TemporaryFilesystemServiceProvider());
$this->register(new TokensServiceProvider());
$this->register(new TwigServiceProvider(), array(
'twig.options' => array(
@@ -397,6 +401,10 @@ class Application extends SilexApplication
return $data[1];
};
+
+ call_user_func(function ($app) {
+ require $app['plugins.directory'] . '/services.php';
+ }, $this);
}
/**
diff --git a/lib/Alchemy/Phrasea/Command/Plugin/AbstractPluginCommand.php b/lib/Alchemy/Phrasea/Command/Plugin/AbstractPluginCommand.php
new file mode 100644
index 0000000000..e241c92d98
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Plugin/AbstractPluginCommand.php
@@ -0,0 +1,41 @@
+write("Validating plugins...");
+ foreach ($this->container['plugins.explorer'] as $directory) {
+ $manifests[] = $manifest = $this->container['plugins.plugins-validator']->validatePlugin($directory);
+ }
+ $output->writeln(" OK");
+
+ return $manifests;
+ }
+
+ protected function updateConfigFiles(InputInterface $input, OutputInterface $output)
+ {
+ $manifests = $this->validatePlugins($input, $output);
+
+ $output->write("Updating config files...");
+ $this->container['plugins.autoloader-generator']->write($manifests);
+ $output->writeln(" OK");
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Command/Plugin/AddPlugin.php b/lib/Alchemy/Phrasea/Command/Plugin/AddPlugin.php
new file mode 100644
index 0000000000..ceaa403933
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Plugin/AddPlugin.php
@@ -0,0 +1,61 @@
+setDescription('Installs a plugin to Phraseanet')
+ ->addArgument('source', InputArgument::REQUIRED, 'The source is a folder');
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output)
+ {
+ $source = $input->getArgument('source');
+
+ $temporaryDir = $this->container['temporary-filesystem']->createTemporaryDirectory();
+
+ $output->write("Importing $source...");
+ $this->container['plugins.importer']->import($source, $temporaryDir);
+ $output->writeln(" OK");
+
+ $output->write("Validating plugin...");
+ $manifest = $this->container['plugins.plugins-validator']->validatePlugin($temporaryDir);
+ $output->writeln(" OK found ".$manifest->getName()."");
+
+ $targetDir = $this->container['plugins.directory'] . DIRECTORY_SEPARATOR . $manifest->getName();
+
+ $output->write("Setting up composer...");
+ $this->container['plugins.composer-installer']->install($temporaryDir);
+ $output->writeln(" OK");
+
+ $output->write("Installing plugin ".$manifest->getName()."...");
+ $this->container['filesystem']->mirror($temporaryDir, $targetDir);
+ $output->writeln(" OK");
+
+ $output->write("Removing temporary directory...");
+ $this->container['filesystem']->remove($temporaryDir);
+ $output->writeln(" OK");
+
+ $this->updateConfigFiles($input, $output);
+
+ return 0;
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Command/Plugin/RemovePlugin.php b/lib/Alchemy/Phrasea/Command/Plugin/RemovePlugin.php
new file mode 100644
index 0000000000..8d26f5e22d
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Command/Plugin/RemovePlugin.php
@@ -0,0 +1,43 @@
+setDescription('Removes a plugin given its name')
+ ->addArgument('name', InputArgument::REQUIRED, 'The name of the plugin');
+ }
+
+ protected function doExecute(InputInterface $input, OutputInterface $output)
+ {
+ $name = $input->getArgument('name');
+
+ $path = $this->container['plugins.directory'] . DIRECTORY_SEPARATOR . $name;
+
+ $output->write("Removing $name...");
+ $this->container['filesystem']->remove($path);
+ $output->writeln(" OK");
+
+ $this->updateConfigFiles($input, $output);
+
+ return 0;
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Core/Provider/PluginServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/PluginServiceProvider.php
new file mode 100644
index 0000000000..9c77ef5729
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Core/Provider/PluginServiceProvider.php
@@ -0,0 +1,86 @@
+share(function (Application $app) {
+ return new JsonValidator();
+ });
+
+ $app['plugins.manifest-validator'] = $app->share(function (Application $app) {
+ return ManifestValidator::create($app);
+ });
+
+ $app['plugins.plugins-validator'] = $app->share(function (Application $app) {
+ return new PluginValidator($app['plugins.manifest-validator']);
+ });
+
+ $app['plugins.import-strategy'] = $app->share(function (Application $app) {
+ return new ImportStrategy();
+ });
+
+ $app['plugins.autoloader-generator'] = $app->share(function (Application $app) {
+ return new AutoloaderGenerator($app['plugins.directory']);
+ });
+
+ $app['plugins.guzzle'] = $app->share(function (Application $app) {
+ return new Guzzle();
+ });
+
+ $app['plugins.composer-installer'] = $app->share(function (Application $app) {
+ $phpBinary = $app['phraseanet.registry']->get('php_binary');
+ if (!is_executable($phpBinary)) {
+ $finder = new ExecutableFinder();
+ $phpBinary = $finder->find('php');
+ }
+
+ return new ComposerInstaller($app['plugins.directory'], $app['plugins.guzzle'], $phpBinary);
+ });
+ $app['plugins.explorer'] = $app->share(function (Application $app) {
+ return new PluginsExplorer($app['plugins.directory']);
+ });
+
+ $app['plugins.importer'] = $app->share(function (Application $app) {
+ return new Importer($app['plugins.import-strategy'], array(
+ 'plugins.importer.folder-importer' => $app['plugins.importer.folder-importer'],
+ ));
+ });
+
+ $app['plugins.importer.folder-importer'] = $app->share(function (Application $app) {
+ return new FolderImporter($app['filesystem']);
+ });
+ }
+
+ public function boot(Application $app)
+ {
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Core/Provider/TemporaryFilesystemServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/TemporaryFilesystemServiceProvider.php
new file mode 100644
index 0000000000..33ebf9d38a
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Core/Provider/TemporaryFilesystemServiceProvider.php
@@ -0,0 +1,30 @@
+share(function (Application $app) {
+ return new TemporaryFilesystem($app['filesystem']);
+ });
+ }
+
+ public function boot(Application $app)
+ {
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/Exception/ComposerInstallException.php b/lib/Alchemy/Phrasea/Plugin/Exception/ComposerInstallException.php
new file mode 100644
index 0000000000..a4606f63a5
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/Exception/ComposerInstallException.php
@@ -0,0 +1,18 @@
+errors = $errors;
+ parent::__construct($message);
+ }
+
+ public function getErrors()
+ {
+ return $this->errors;
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/Exception/PluginValidationException.php b/lib/Alchemy/Phrasea/Plugin/Exception/PluginValidationException.php
new file mode 100644
index 0000000000..b00aff0543
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/Exception/PluginValidationException.php
@@ -0,0 +1,18 @@
+fs = $fs;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function import($source, $target)
+ {
+ try {
+ $this->fs->mirror($source, $target);
+ } catch (FsException $e) {
+ try {
+ $this->fs->remove($target);
+ } catch (FsException $e) {
+
+ }
+
+ throw new ImportFailureException(sprintf('Unable to import from `%s` to `%s`', $source, $target), $e->getCode(), $e);
+ }
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/Importer/ImportStrategy.php b/lib/Alchemy/Phrasea/Plugin/Importer/ImportStrategy.php
new file mode 100644
index 0000000000..3c58814645
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/Importer/ImportStrategy.php
@@ -0,0 +1,27 @@
+importers = $importers;
+ $this->strategy = $strategy;
+ }
+
+ /**
+ *
+ * @param string $source
+ * @param string $target
+ *
+ * @throws ImportFailureException
+ */
+ public function import($source, $target)
+ {
+ $strategy = $this->strategy->detect($source);
+
+ if (!isset($this->importers[$strategy])) {
+ throw new ImportFailureException(sprintf('Unable to get an import for source `%s`', $source));
+ }
+
+ $this->importers[$strategy]->import($source, $target);
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/Importer/ImporterInterface.php b/lib/Alchemy/Phrasea/Plugin/Importer/ImporterInterface.php
new file mode 100644
index 0000000000..1a87e708ae
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/Importer/ImporterInterface.php
@@ -0,0 +1,25 @@
+pluginDirectory = $pluginDirectory;
+ }
+
+ public function write($manifests)
+ {
+ $this
+ ->doWrite('autoload.php', $this->createLoader($manifests))
+ ->doWrite('services.php', $this->createServices($manifests));
+
+ return $this;
+ }
+
+ private function doWrite($file, $data)
+ {
+ if (false === file_put_contents($this->pluginDirectory . DIRECTORY_SEPARATOR . $file, $data)) {
+ throw new RegistrationFailureException(sprintf('Failed to write %s', $file));
+ }
+
+ return $this;
+ }
+
+ private function createLoader($manifests)
+ {
+ $buffer = <<getName() . DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR . "autoload.php";
+ $buffer .= <<getServices() as $service) {
+ $class = $service['class'];
+ $buffer .= <<register($class::create(\$app));
+EOF;
+ }
+ }
+
+ $buffer .= <<guzzle = $guzzle;
+ $this->pluginsDirectory = $pluginsDirectory;
+ $this->phpExecutable = $phpExecutable;
+ $this->composer = $this->pluginsDirectory . DIRECTORY_SEPARATOR . 'composer.phar';
+ }
+
+ public function install($directory)
+ {
+ $process = $this->createProcessBuilder()
+ ->add('install')
+ ->add('--working-dir')
+ ->add($directory)
+ ->add('--no-dev')
+ ->add('--optimize-autoloader')
+ ->getProcess();
+
+ try {
+ $process->run();
+ } catch (ProcessException $e) {
+ throw new ComposerInstallException(sprintf('Unable to composer install %s', $directory), $e->getCode(), $e);
+ }
+
+ if (!$process->isSuccessful()) {
+ throw new ComposerInstallException(sprintf('Unable to composer install %s', $directory));
+ }
+ }
+
+ /**
+ * @return ProcessBuilder
+ */
+ private function createProcessBuilder()
+ {
+ if (!file_exists($this->composer)) {
+ $this->installComposer();
+ } else {
+ $process = ProcessBuilder::create(array(
+ $this->phpExecutable, $this->composer, 'self-update'
+ ))->getProcess();
+ $process->run();
+ }
+
+ return ProcessBuilder::create(array($this->phpExecutable, $this->composer));
+ }
+
+ private function installComposer()
+ {
+ $installer = $this->pluginsDirectory . DIRECTORY_SEPARATOR . 'installer';
+ $handle = fopen($installer, 'w+');
+
+ $request = $this->guzzle->get('https://getcomposer.org/installer', null, $handle);
+
+ try {
+ $response = $request->send();
+ fclose($handle);
+ } catch (GuzzleException $e) {
+ fclose($handle);
+ throw new ComposerInstallException('Unable to download composer install script.');
+ }
+
+ if (200 !== $response->getStatusCode()) {
+ @unlink($installer);
+ throw new ComposerInstallException('Unable to download composer install script.');
+ }
+
+ $dir = getcwd();
+ if (!@chdir($this->pluginsDirectory)) {
+ throw new ComposerInstallException('Unable to move to plugins directory for composer install.');
+ }
+
+ $process = ProcessBuilder::create(array($this->phpExecutable, $installer))->getProcess();
+
+ try {
+ $process->run();
+ @unlink($installer);
+ } catch (ProcessException $e) {
+ @unlink($installer);
+ throw new ComposerInstallException('Unable run composer install script.');
+ }
+
+ if (!@chdir($dir)) {
+ throw new ComposerInstallException('Unable to move to plugins directory for composer install.');
+ }
+
+ if (!$process->isSuccessful()) {
+ throw new ComposerInstallException('Composer install failed.');
+ }
+
+ if (!file_exists($this->composer)) {
+ throw new ComposerInstallException('Composer install failed.');
+ }
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/Management/PluginsExplorer.php b/lib/Alchemy/Phrasea/Plugin/Management/PluginsExplorer.php
new file mode 100644
index 0000000000..ba8d5296ab
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/Management/PluginsExplorer.php
@@ -0,0 +1,47 @@
+pluginsDirectory = $pluginsDirectory;
+ }
+
+ public function getIterator()
+ {
+ return $this->getFinder()->getIterator();
+ }
+
+ public function count()
+ {
+ return $this->getFinder()->count();
+ }
+
+ private function getFinder()
+ {
+ $finder = Finder::create();
+
+ return $finder
+ ->ignoreDotFiles(true)
+ ->ignoreVCS(true)
+ ->useBestAdapter()
+ ->directories()
+ ->in($this->pluginsDirectory)
+ ->depth(0);
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/PluginProviderInterface.php b/lib/Alchemy/Phrasea/Plugin/PluginProviderInterface.php
new file mode 100644
index 0000000000..b7dde714fb
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/PluginProviderInterface.php
@@ -0,0 +1,29 @@
+data = $data;
+ }
+
+ public function getName()
+ {
+ return $this->get('name');
+ }
+
+ public function getDescription()
+ {
+ return $this->get('description');
+ }
+
+ public function getKeywords()
+ {
+ return $this->get('keywords');
+ }
+
+ public function getAuthors()
+ {
+ return $this->get('authors');
+ }
+
+ public function getHomepage()
+ {
+ return $this->get('homepage');
+ }
+
+ public function getLicense()
+ {
+ return $this->get('license');
+ }
+
+ public function getVersion()
+ {
+ return $this->get('version');
+ }
+
+ public function getMinimumPhraseanetVersion()
+ {
+ return $this->get('minimum-phraseanet-version');
+ }
+
+ public function getMaximumPhraseanetVersion()
+ {
+ return $this->get('maximum-phraseanet-version');
+ }
+
+ public function getServices()
+ {
+ return $this->get('services');
+ }
+
+ public function getExtra()
+ {
+ return $this->get('extra');
+ }
+
+ private function get($key)
+ {
+ return isset($this->data[$key]) ? $this->data[$key] : null;
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/Schema/ManifestValidator.php b/lib/Alchemy/Phrasea/Plugin/Schema/ManifestValidator.php
new file mode 100644
index 0000000000..ec60088ea5
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/Schema/ManifestValidator.php
@@ -0,0 +1,89 @@
+validator = $validator;
+ $this->version = $version;
+ $this->schemaData = $schemaData;
+ }
+
+ public function validate($data)
+ {
+ if (!is_object($data)) {
+ throw new InvalidArgumentException('Json Schema must be an object');
+ }
+
+ $this->validator->reset();
+ $this->validator->check($data, $this->schemaData);
+
+ if (!$this->validator->isValid()) {
+ $errors = array();
+ foreach ((array) $this->validator->getErrors() as $error) {
+ $errors[] = ($error['property'] ? $error['property'].' : ' : '').$error['message'];
+ }
+ throw new JsonValidationException('Manifest file does not match the expected JSON schema', $errors);
+ }
+
+ if (!preg_match('/^[a-z0-9-_]+$/i', $data->name)) {
+ throw new JsonValidationException('Does not match the expected JSON schema', array('"name" must not contains only alphanumeric caracters'));
+ }
+
+ if (isset($data->{'minimum-phraseanet-version'})) {
+ if (true !== version_compare($this->version->getNumber(), $data->{'minimum-phraseanet-version'}, '>=')) {
+ throw new JsonValidationException(sprintf(
+ 'Version incomptibility : Minimum Phraseanet version required is %s, current version is %s',
+ $data->{'minimum-phraseanet-version'},
+ $this->version->getNumber()
+ ));
+ }
+ }
+
+ if (isset($data->{'maximum-phraseanet-version'})) {
+ if (true !== version_compare($this->version->getNumber(), $data->{'maximum-phraseanet-version'}, '<')) {
+ throw new JsonValidationException(sprintf(
+ 'Version incomptibility : Maximum Phraseanet version required is %s, current version is %s',
+ $data->{'maximum-phraseanet-version'},
+ $this->version->getNumber()
+ ));
+ }
+ }
+ }
+
+ public static function create(Application $app)
+ {
+ $data = @json_decode(@file_get_contents($app['plugins.schema']));
+
+ if (JSON_ERROR_NONE !== json_last_error()) {
+ throw new InvalidArgumentException(sprintf('Unable to read %s', $app['plugins.schema']));
+ }
+
+ return new static($app['plugins.json-validator'], $data, $app['phraseanet.version']);
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/Schema/PluginValidator.php b/lib/Alchemy/Phrasea/Plugin/Schema/PluginValidator.php
new file mode 100644
index 0000000000..0b0cadbdde
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/Schema/PluginValidator.php
@@ -0,0 +1,80 @@
+manifestValidator = $manifestValidator;
+ }
+
+ public function validatePlugin($directory)
+ {
+ $this->ensureComposer($directory);
+ $this->ensureManifest($directory);
+
+ $manifest = $directory . DIRECTORY_SEPARATOR . 'manifest.json';
+ $data = @json_decode(@file_get_contents($manifest));
+
+ if (JSON_ERROR_NONE !== json_last_error()) {
+ throw new PluginValidationException(sprintf('Unable to parse file %s', $manifest));
+ }
+
+ try {
+ $this->manifestValidator->validate($data);
+ } catch (JsonValidationException $e) {
+ throw new PluginValidationException('Manifest file is invalid', $e->getCode(), $e);
+ }
+
+ return new Manifest($this->objectToArray($data));
+ }
+
+ private function ensureManifest($directory)
+ {
+ $manifest = $directory . DIRECTORY_SEPARATOR . 'manifest.json';
+ $this->ensureFile($manifest);
+ }
+
+ private function ensureComposer($directory)
+ {
+ $composer = $directory . DIRECTORY_SEPARATOR . 'composer.json';
+ $this->ensureFile($composer);
+ }
+
+ private function ensureFile($file)
+ {
+ if (!file_exists($file) || !is_file($file) || !is_readable($file)) {
+ throw new PluginValidationException(sprintf('Required file %s is not present.', $file));
+ }
+ }
+
+ private function objectToArray($data)
+ {
+ if (is_object($data)) {
+ $data = get_object_vars($data);
+ }
+
+ if (is_array($data)) {
+ return array_map(array($this, 'objectToArray'), $data);
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/Alchemy/Phrasea/Plugin/resources/example.json b/lib/Alchemy/Phrasea/Plugin/resources/example.json
new file mode 100644
index 0000000000..5f0116601e
--- /dev/null
+++ b/lib/Alchemy/Phrasea/Plugin/resources/example.json
@@ -0,0 +1,22 @@
+{
+ "name": "Class Connector",
+ "description" : "A custom class connector",
+ "keywords" : ["connector"],
+ "authors" : [
+ {
+ "name" : "Author name",
+ "homepage" : "http://example.com",
+ "email" : "email@example.com"
+ }
+ ],
+ "homepage" : "http://example.com/project/example",
+ "license" : "MIT",
+ "version" : "0.1",
+ "minimum-phraseanet-version": "3.8",
+ "maximum-phraseanet-version": "3.9",
+ "services" : [
+ {
+ "class": "Vendor\\Connector\\PluginServiceInterface"
+ }
+ ]
+}
diff --git a/lib/autoload.php b/lib/autoload.php
new file mode 100644
index 0000000000..b62f6f9c5c
--- /dev/null
+++ b/lib/autoload.php
@@ -0,0 +1,3 @@
+getMock('Symfony\Component\Console\Input\InputInterface');
+ $input->expects($this->once())
+ ->method('getArgument')
+ ->with($this->equalTo('source'))
+ ->will($this->returnValue($source));
+
+ $output = $this->getMock('Symfony\Component\Console\Output\OutputInterface');
+
+ $command = new AddPlugin();
+ $command->setContainer(self::$DI['app']);
+
+ $manifest = $this->createManifestMock();
+ $manifest->expects($this->any())
+ ->method('getName')
+ ->will($this->returnValue($source));
+
+ self::$DI['app']['temporary-filesystem'] = $this->createTemporaryFilesystemMock();
+ self::$DI['app']['plugins.autoloader-generator'] = $this->createPluginsAutoloaderGeneratorMock();
+ self::$DI['app']['plugins.explorer'] = array(self::$DI['app']['plugins.directory'].'/TestPlugin');
+ self::$DI['app']['plugins.plugins-validator'] = $this->createPluginsValidatorMock();
+ self::$DI['app']['filesystem'] = $this->createFilesystemMock();
+ self::$DI['app']['plugins.composer-installer'] = $this->createComposerInstallerMock();
+ self::$DI['app']['plugins.importer'] = $this->createPluginsImporterMock();
+
+ self::$DI['app']['temporary-filesystem']->expects($this->once())
+ ->method('createTemporaryDirectory')
+ ->will($this->returnValue('tempdir'));
+
+ self::$DI['app']['plugins.importer']->expects($this->once())
+ ->method('import')
+ ->with($source, 'tempdir');
+
+ // the plugin is checked when updating config files
+ self::$DI['app']['plugins.plugins-validator']->expects($this->at(0))
+ ->method('validatePlugin')
+ ->with('tempdir')
+ ->will($this->returnValue($manifest));
+
+ self::$DI['app']['plugins.plugins-validator']->expects($this->at(1))
+ ->method('validatePlugin')
+ ->with(self::$DI['app']['plugins.directory'].'/TestPlugin')
+ ->will($this->returnValue($manifest));
+
+ self::$DI['app']['plugins.composer-installer']->expects($this->once())
+ ->method('install')
+ ->with('tempdir');
+
+ self::$DI['app']['filesystem']->expects($this->at(0))
+ ->method('mirror')
+ ->with('tempdir', self::$DI['app']['plugins.directory'].'/TestPlugin');
+
+ self::$DI['app']['filesystem']->expects($this->at(1))
+ ->method('remove')
+ ->with('tempdir');
+
+ self::$DI['app']['plugins.autoloader-generator']->expects($this->once())
+ ->method('write')
+ ->with(array($manifest));
+
+ $result = $command->execute($input, $output);
+
+ $this->assertSame(0, $result);
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Command/Plugin/PluginCommandTestCase.php b/tests/Alchemy/Tests/Phrasea/Command/Plugin/PluginCommandTestCase.php
new file mode 100644
index 0000000000..d0affce512
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Command/Plugin/PluginCommandTestCase.php
@@ -0,0 +1,62 @@
+getMockBuilder('Neutron\TemporaryFilesystem\TemporaryFilesystem')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function createPluginsImporterMock()
+ {
+ return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Importer\Importer')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function createPluginsValidatorMock()
+ {
+ return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Schema\PluginValidator')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function createManifestMock()
+ {
+ return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Schema\Manifest')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function createComposerInstallerMock()
+ {
+ return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Management\ComposerInstaller')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function createFilesystemMock()
+ {
+ return $this->getMockBuilder('Symfony\Component\Filesystem\Filesystem')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function createPluginsExplorerMock()
+ {
+ return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Management\PluginsExplorer')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+
+ protected function createPluginsAutoloaderGeneratorMock()
+ {
+ return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Management\AutoloaderGenerator')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Command/Plugin/RemovePluginTest.php b/tests/Alchemy/Tests/Phrasea/Command/Plugin/RemovePluginTest.php
new file mode 100644
index 0000000000..bf81a8ec74
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Command/Plugin/RemovePluginTest.php
@@ -0,0 +1,33 @@
+getMock('Symfony\Component\Console\Input\InputInterface');
+ $input->expects($this->once())
+ ->method('getArgument')
+ ->with($this->equalTo('name'))
+ ->will($this->returnValue($name));
+
+ $output = $this->getMock('Symfony\Component\Console\Output\OutputInterface');
+
+ $command = new RemovePlugin();
+ $command->setContainer(self::$DI['app']);
+
+ self::$DI['app']['filesystem'] = $this->createFilesystemMock();
+ self::$DI['app']['filesystem']->expects($this->once())
+ ->method('remove')
+ ->with(self::$DI['app']['plugins.directory'].'/'.$name);
+
+ $result = $command->execute($input, $output);
+
+ $this->assertSame(0, $result);
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Core/Provider/PluginServiceProviderTest.php b/tests/Alchemy/Tests/Phrasea/Core/Provider/PluginServiceProviderTest.php
new file mode 100644
index 0000000000..df94de41b7
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Core/Provider/PluginServiceProviderTest.php
@@ -0,0 +1,129 @@
+register(new PluginServiceProvider());
+
+ $this->assertFileExists($app['plugins.schema']);
+ $this->assertTrue(is_file($app['plugins.schema']));
+ }
+
+ public function testPluginDirIsDefined()
+ {
+ $app = new Application();
+ $app->register(new PluginServiceProvider());
+
+ $this->assertFileExists($app['plugins.directory']);
+ $this->assertTrue(is_dir($app['plugins.directory']));
+ }
+
+ public function testInstallerUsesPhpConf()
+ {
+ $finder = new ExecutableFinder();
+ $php = $finder->find('php');
+
+ if (null === $php) {
+ $this->markTestSkipped('Unable to detect PHP binary');
+ }
+
+ $app = new Application();
+ $app['phraseanet.registry'] = $this->createRegistryMock();
+ $app['phraseanet.registry']->expects($this->once())
+ ->method('get')
+ ->with('php_binary')
+ ->will($this->returnValue($php));
+
+ $app->register(new PluginServiceProvider());
+
+ $this->assertInstanceOf('Alchemy\Phrasea\Plugin\Management\ComposerInstaller', $app['plugins.composer-installer']);
+ }
+
+ public function testInstallerCanDetectPhpConf()
+ {
+ $app = new Application();
+ $app['phraseanet.registry'] = $this->createRegistryMock();
+ $app['phraseanet.registry']->expects($this->once())
+ ->method('get')
+ ->with('php_binary')
+ ->will($this->returnValue(null));
+
+ $app->register(new PluginServiceProvider());
+
+ $this->assertInstanceOf('Alchemy\Phrasea\Plugin\Management\ComposerInstaller', $app['plugins.composer-installer']);
+ }
+
+ private function createRegistryMock()
+ {
+ return $this->getMockBuilder('registry')
+ ->disableOriginalConstructor()
+ ->getMock();
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Core/Provider/TemporaryFilesystemServiceProviderTest.php b/tests/Alchemy/Tests/Phrasea/Core/Provider/TemporaryFilesystemServiceProviderTest.php
new file mode 100644
index 0000000000..89b39e1c47
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Core/Provider/TemporaryFilesystemServiceProviderTest.php
@@ -0,0 +1,16 @@
+
+ * Jordi Boggiano
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Autoload;
+
+/**
+ * ClassLoader implements a PSR-0 class loader
+ *
+ * See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md
+ *
+ * $loader = new \Composer\Autoload\ClassLoader();
+ *
+ * // register classes with namespaces
+ * $loader->add('Symfony\Component', __DIR__.'/component');
+ * $loader->add('Symfony', __DIR__.'/framework');
+ *
+ * // activate the autoloader
+ * $loader->register();
+ *
+ * // to enable searching the include path (eg. for PEAR packages)
+ * $loader->setUseIncludePath(true);
+ *
+ * In this example, if you try to use a class in the Symfony\Component
+ * namespace or one of its children (Symfony\Component\Console for instance),
+ * the autoloader will first look for the class under the component/
+ * directory, and it will then fallback to the framework/ directory if not
+ * found before giving up.
+ *
+ * This class is loosely based on the Symfony UniversalClassLoader.
+ *
+ * @author Fabien Potencier
+ * @author Jordi Boggiano
+ */
+class ClassLoader
+{
+ private $prefixes = array();
+ private $fallbackDirs = array();
+ private $useIncludePath = false;
+ private $classMap = array();
+
+ public function getPrefixes()
+ {
+ return call_user_func_array('array_merge', $this->prefixes);
+ }
+
+ public function getFallbackDirs()
+ {
+ return $this->fallbackDirs;
+ }
+
+ public function getClassMap()
+ {
+ return $this->classMap;
+ }
+
+ /**
+ * @param array $classMap Class to filename map
+ */
+ public function addClassMap(array $classMap)
+ {
+ if ($this->classMap) {
+ $this->classMap = array_merge($this->classMap, $classMap);
+ } else {
+ $this->classMap = $classMap;
+ }
+ }
+
+ /**
+ * Registers a set of classes, merging with any others previously set.
+ *
+ * @param string $prefix The classes prefix
+ * @param array|string $paths The location(s) of the classes
+ * @param bool $prepend Prepend the location(s)
+ */
+ public function add($prefix, $paths, $prepend = false)
+ {
+ if (!$prefix) {
+ if ($prepend) {
+ $this->fallbackDirs = array_merge(
+ (array) $paths,
+ $this->fallbackDirs
+ );
+ } else {
+ $this->fallbackDirs = array_merge(
+ $this->fallbackDirs,
+ (array) $paths
+ );
+ }
+
+ return;
+ }
+
+ $first = $prefix[0];
+ if (!isset($this->prefixes[$first][$prefix])) {
+ $this->prefixes[$first][$prefix] = (array) $paths;
+
+ return;
+ }
+ if ($prepend) {
+ $this->prefixes[$first][$prefix] = array_merge(
+ (array) $paths,
+ $this->prefixes[$first][$prefix]
+ );
+ } else {
+ $this->prefixes[$first][$prefix] = array_merge(
+ $this->prefixes[$first][$prefix],
+ (array) $paths
+ );
+ }
+ }
+
+ /**
+ * Registers a set of classes, replacing any others previously set.
+ *
+ * @param string $prefix The classes prefix
+ * @param array|string $paths The location(s) of the classes
+ */
+ public function set($prefix, $paths)
+ {
+ if (!$prefix) {
+ $this->fallbackDirs = (array) $paths;
+
+ return;
+ }
+ $this->prefixes[substr($prefix, 0, 1)][$prefix] = (array) $paths;
+ }
+
+ /**
+ * Turns on searching the include path for class files.
+ *
+ * @param bool $useIncludePath
+ */
+ public function setUseIncludePath($useIncludePath)
+ {
+ $this->useIncludePath = $useIncludePath;
+ }
+
+ /**
+ * Can be used to check if the autoloader uses the include path to check
+ * for classes.
+ *
+ * @return bool
+ */
+ public function getUseIncludePath()
+ {
+ return $this->useIncludePath;
+ }
+
+ /**
+ * Registers this instance as an autoloader.
+ *
+ * @param bool $prepend Whether to prepend the autoloader or not
+ */
+ public function register($prepend = false)
+ {
+ spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+ }
+
+ /**
+ * Unregisters this instance as an autoloader.
+ */
+ public function unregister()
+ {
+ spl_autoload_unregister(array($this, 'loadClass'));
+ }
+
+ /**
+ * Loads the given class or interface.
+ *
+ * @param string $class The name of the class
+ * @return bool|null True if loaded, null otherwise
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->findFile($class)) {
+ include $file;
+
+ return true;
+ }
+ }
+
+ /**
+ * Finds the path to the file where the class is defined.
+ *
+ * @param string $class The name of the class
+ *
+ * @return string|false The path if found, false otherwise
+ */
+ public function findFile($class)
+ {
+ // work around for PHP 5.3.0 - 5.3.2 https://bugs.php.net/50731
+ if ('\\' == $class[0]) {
+ $class = substr($class, 1);
+ }
+
+ if (isset($this->classMap[$class])) {
+ return $this->classMap[$class];
+ }
+
+ if (false !== $pos = strrpos($class, '\\')) {
+ // namespaced class name
+ $classPath = strtr(substr($class, 0, $pos), '\\', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ $className = substr($class, $pos + 1);
+ } else {
+ // PEAR-like class name
+ $classPath = null;
+ $className = $class;
+ }
+
+ $classPath .= strtr($className, '_', DIRECTORY_SEPARATOR) . '.php';
+
+ $first = $class[0];
+ if (isset($this->prefixes[$first])) {
+ foreach ($this->prefixes[$first] as $prefix => $dirs) {
+ if (0 === strpos($class, $prefix)) {
+ foreach ($dirs as $dir) {
+ if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) {
+ return $dir . DIRECTORY_SEPARATOR . $classPath;
+ }
+ }
+ }
+ }
+ }
+
+ foreach ($this->fallbackDirs as $dir) {
+ if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) {
+ return $dir . DIRECTORY_SEPARATOR . $classPath;
+ }
+ }
+
+ if ($this->useIncludePath && $file = stream_resolve_include_path($classPath)) {
+ return $file;
+ }
+
+ return $this->classMap[$class] = false;
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_classmap.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_classmap.php
new file mode 100644
index 0000000000..d3ef7827a3
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_classmap.php
@@ -0,0 +1,10 @@
+ $baseDir . '/src/Vendor/PluginService.php',
+);
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_namespaces.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_namespaces.php
new file mode 100644
index 0000000000..43ccccf9a9
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_namespaces.php
@@ -0,0 +1,10 @@
+ array($baseDir . '/src'),
+);
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_real.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_real.php
new file mode 100644
index 0000000000..10cf2abce2
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_real.php
@@ -0,0 +1,43 @@
+ $path) {
+ $loader->set($namespace, $path);
+ }
+
+ $classMap = require __DIR__ . '/autoload_classmap.php';
+ if ($classMap) {
+ $loader->addClassMap($classMap);
+ }
+
+ $loader->register(true);
+
+ return $loader;
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/composer.json b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/composer.json
new file mode 100644
index 0000000000..9e71751641
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/composer.json
@@ -0,0 +1,10 @@
+{
+ "name" : "test/test",
+ "description" : "test file",
+ "license" : "MIT",
+ "autoload": {
+ "psr-0": {
+ "Vendor" : "src"
+ }
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/manifest.json b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/manifest.json
new file mode 100644
index 0000000000..b4b5f747ca
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/manifest.json
@@ -0,0 +1,4 @@
+{
+ "name": "Test Plugin",
+ "description" : "A custom class connector",
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/src/Vendor/PluginService.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/src/Vendor/PluginService.php
new file mode 100644
index 0000000000..19b9dfd44a
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/src/Vendor/PluginService.php
@@ -0,0 +1,24 @@
+
+ * Jordi Boggiano
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Autoload;
+
+/**
+ * ClassLoader implements a PSR-0 class loader
+ *
+ * See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md
+ *
+ * $loader = new \Composer\Autoload\ClassLoader();
+ *
+ * // register classes with namespaces
+ * $loader->add('Symfony\Component', __DIR__.'/component');
+ * $loader->add('Symfony', __DIR__.'/framework');
+ *
+ * // activate the autoloader
+ * $loader->register();
+ *
+ * // to enable searching the include path (eg. for PEAR packages)
+ * $loader->setUseIncludePath(true);
+ *
+ * In this example, if you try to use a class in the Symfony\Component
+ * namespace or one of its children (Symfony\Component\Console for instance),
+ * the autoloader will first look for the class under the component/
+ * directory, and it will then fallback to the framework/ directory if not
+ * found before giving up.
+ *
+ * This class is loosely based on the Symfony UniversalClassLoader.
+ *
+ * @author Fabien Potencier
+ * @author Jordi Boggiano
+ */
+class ClassLoader
+{
+ private $prefixes = array();
+ private $fallbackDirs = array();
+ private $useIncludePath = false;
+ private $classMap = array();
+
+ public function getPrefixes()
+ {
+ return call_user_func_array('array_merge', $this->prefixes);
+ }
+
+ public function getFallbackDirs()
+ {
+ return $this->fallbackDirs;
+ }
+
+ public function getClassMap()
+ {
+ return $this->classMap;
+ }
+
+ /**
+ * @param array $classMap Class to filename map
+ */
+ public function addClassMap(array $classMap)
+ {
+ if ($this->classMap) {
+ $this->classMap = array_merge($this->classMap, $classMap);
+ } else {
+ $this->classMap = $classMap;
+ }
+ }
+
+ /**
+ * Registers a set of classes, merging with any others previously set.
+ *
+ * @param string $prefix The classes prefix
+ * @param array|string $paths The location(s) of the classes
+ * @param bool $prepend Prepend the location(s)
+ */
+ public function add($prefix, $paths, $prepend = false)
+ {
+ if (!$prefix) {
+ if ($prepend) {
+ $this->fallbackDirs = array_merge(
+ (array) $paths,
+ $this->fallbackDirs
+ );
+ } else {
+ $this->fallbackDirs = array_merge(
+ $this->fallbackDirs,
+ (array) $paths
+ );
+ }
+
+ return;
+ }
+
+ $first = $prefix[0];
+ if (!isset($this->prefixes[$first][$prefix])) {
+ $this->prefixes[$first][$prefix] = (array) $paths;
+
+ return;
+ }
+ if ($prepend) {
+ $this->prefixes[$first][$prefix] = array_merge(
+ (array) $paths,
+ $this->prefixes[$first][$prefix]
+ );
+ } else {
+ $this->prefixes[$first][$prefix] = array_merge(
+ $this->prefixes[$first][$prefix],
+ (array) $paths
+ );
+ }
+ }
+
+ /**
+ * Registers a set of classes, replacing any others previously set.
+ *
+ * @param string $prefix The classes prefix
+ * @param array|string $paths The location(s) of the classes
+ */
+ public function set($prefix, $paths)
+ {
+ if (!$prefix) {
+ $this->fallbackDirs = (array) $paths;
+
+ return;
+ }
+ $this->prefixes[substr($prefix, 0, 1)][$prefix] = (array) $paths;
+ }
+
+ /**
+ * Turns on searching the include path for class files.
+ *
+ * @param bool $useIncludePath
+ */
+ public function setUseIncludePath($useIncludePath)
+ {
+ $this->useIncludePath = $useIncludePath;
+ }
+
+ /**
+ * Can be used to check if the autoloader uses the include path to check
+ * for classes.
+ *
+ * @return bool
+ */
+ public function getUseIncludePath()
+ {
+ return $this->useIncludePath;
+ }
+
+ /**
+ * Registers this instance as an autoloader.
+ *
+ * @param bool $prepend Whether to prepend the autoloader or not
+ */
+ public function register($prepend = false)
+ {
+ spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+ }
+
+ /**
+ * Unregisters this instance as an autoloader.
+ */
+ public function unregister()
+ {
+ spl_autoload_unregister(array($this, 'loadClass'));
+ }
+
+ /**
+ * Loads the given class or interface.
+ *
+ * @param string $class The name of the class
+ * @return bool|null True if loaded, null otherwise
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->findFile($class)) {
+ include $file;
+
+ return true;
+ }
+ }
+
+ /**
+ * Finds the path to the file where the class is defined.
+ *
+ * @param string $class The name of the class
+ *
+ * @return string|false The path if found, false otherwise
+ */
+ public function findFile($class)
+ {
+ // work around for PHP 5.3.0 - 5.3.2 https://bugs.php.net/50731
+ if ('\\' == $class[0]) {
+ $class = substr($class, 1);
+ }
+
+ if (isset($this->classMap[$class])) {
+ return $this->classMap[$class];
+ }
+
+ if (false !== $pos = strrpos($class, '\\')) {
+ // namespaced class name
+ $classPath = strtr(substr($class, 0, $pos), '\\', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ $className = substr($class, $pos + 1);
+ } else {
+ // PEAR-like class name
+ $classPath = null;
+ $className = $class;
+ }
+
+ $classPath .= strtr($className, '_', DIRECTORY_SEPARATOR) . '.php';
+
+ $first = $class[0];
+ if (isset($this->prefixes[$first])) {
+ foreach ($this->prefixes[$first] as $prefix => $dirs) {
+ if (0 === strpos($class, $prefix)) {
+ foreach ($dirs as $dir) {
+ if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) {
+ return $dir . DIRECTORY_SEPARATOR . $classPath;
+ }
+ }
+ }
+ }
+ }
+
+ foreach ($this->fallbackDirs as $dir) {
+ if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) {
+ return $dir . DIRECTORY_SEPARATOR . $classPath;
+ }
+ }
+
+ if ($this->useIncludePath && $file = stream_resolve_include_path($classPath)) {
+ return $file;
+ }
+
+ return $this->classMap[$class] = false;
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/vendor/composer/autoload_classmap.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/vendor/composer/autoload_classmap.php
new file mode 100644
index 0000000000..af4ad5805a
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/vendor/composer/autoload_classmap.php
@@ -0,0 +1,9 @@
+ $path) {
+ $loader->set($namespace, $path);
+ }
+
+ $classMap = require __DIR__ . '/autoload_classmap.php';
+ if ($classMap) {
+ $loader->addClassMap($classMap);
+ }
+
+ $loader->register(true);
+
+ return $loader;
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Importer/FolderImporterTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/FolderImporterTest.php
new file mode 100644
index 0000000000..5e04521a26
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/FolderImporterTest.php
@@ -0,0 +1,44 @@
+createFilesystemMock();
+
+ $source = 'test-plugin';
+ $target = __DIR__;
+
+ $fs->expects($this->once())
+ ->method('mirror')
+ ->with($source, $target);
+
+ $importer = new FolderImporter($fs);
+ $importer->import($source, $target);
+ }
+
+ /**
+ * @expectedException Alchemy\Phrasea\Plugin\Exception\ImportFailureException
+ */
+ public function testImportFailed()
+ {
+ $fs = $this->createFilesystemMock();
+
+ $source = 'test-plugin';
+ $target = __DIR__;
+
+ $fs->expects($this->once())
+ ->method('mirror')
+ ->with($source, $target)
+ ->will($this->throwException(new IOException('Error')));
+
+ $importer = new FolderImporter($fs);
+ $importer->import($source, $target);
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImportStrategyTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImportStrategyTest.php
new file mode 100644
index 0000000000..c394bb0cca
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImportStrategyTest.php
@@ -0,0 +1,44 @@
+assertEquals('plugins.importer.folder-importer', $importer->detect($source));
+ }
+
+ /**
+ * @dataProvider provideInvalidFolderSources
+ * @expectedException Alchemy\Phrasea\Plugin\Exception\ImportFailureException
+ */
+ public function testDetectFailure($source)
+ {
+ $importer = new ImportStrategy();
+ $importer->detect($source);
+ }
+
+ public function provideFolderSources()
+ {
+ return array(
+ array(__DIR__),
+ array(dirname(__DIR__)),
+ );
+ }
+
+ public function provideInvalidFolderSources()
+ {
+ return array(
+ array('/path/to/invalid/dir'),
+ array(__FILE__),
+ );
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImporterTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImporterTest.php
new file mode 100644
index 0000000000..aa0bbd9f0e
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImporterTest.php
@@ -0,0 +1,51 @@
+getMock('Alchemy\Phrasea\Plugin\Importer\ImportStrategy');
+ $strategy->expects($this->once())
+ ->method('detect')
+ ->with($source)
+ ->will($this->returnValue('elephant'));
+
+ $importerInterface = $this->getMock('Alchemy\Phrasea\Plugin\Importer\ImporterInterface');
+ $importerInterface->expects($this->once())
+ ->method('import')
+ ->with($source, $target);
+
+ $importer = new Importer($strategy, array('elephant' => $importerInterface));
+ $importer->import($source, $target);
+ }
+
+ /**
+ * @expectedException Alchemy\Phrasea\Plugin\Exception\ImportFailureException
+ */
+ public function testImportFailure()
+ {
+ $source = 'here';
+ $target = 'there';
+
+ $strategy = $this->getMock('Alchemy\Phrasea\Plugin\Importer\ImportStrategy');
+ $strategy->expects($this->once())
+ ->method('detect')
+ ->with($source)
+ ->will($this->returnValue('elephant'));
+
+ $importerInterface = $this->getMock('Alchemy\Phrasea\Plugin\Importer\ImporterInterface');
+ $importerInterface->expects($this->never())
+ ->method('import');
+
+ $importer = new Importer($strategy, array('rhinoceros' => $importerInterface));
+ $importer->import($source, $target);
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Management/AutoloaderGeneratorTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Management/AutoloaderGeneratorTest.php
new file mode 100644
index 0000000000..267cba71dd
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Management/AutoloaderGeneratorTest.php
@@ -0,0 +1,63 @@
+cleanup($files);
+
+ $generator = new AutoloaderGenerator($pluginsDir);
+ $generator->write(array(new Manifest(json_decode(file_get_contents($pluginDir . '/manifest.json'), true))));
+
+ $finder = new ExecutableFinder();
+ $php = $finder->find('php');
+
+ if (null === $php) {
+ $this->markTestSkipped('Php executable not found.');
+ }
+
+ foreach ($files as $file ) {
+ $this->assertFileExists($file);
+ $process = ProcessBuilder::create(array($php, '-l', $file))->getProcess();
+ $process->run();
+ $this->assertTrue($process->isSuccessful(), basename($file) . ' is valid');
+ }
+
+ // test autoload
+ $this->assertFalse(class_exists('Vendor\PluginService'));
+ $loader = require $pluginsDir . '/autoload.php';
+ $this->assertInstanceOf('Composer\Autoload\ClassLoader', $loader);
+ $this->assertTrue(class_exists('Vendor\PluginService'));
+
+ // load services
+ $app = new Application();
+ $retrievedApp = require $pluginsDir . '/services.php';
+
+ $this->assertSame($app, $retrievedApp);
+ $this->assertEquals('hello world', $app['plugin-test']);
+
+ $this->cleanup($files);
+ }
+
+ private function cleanup($files)
+ {
+ foreach ($files as $file) {
+ if (file_exists($file)) {
+ unlink($file);
+ }
+ }
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Management/ComposerInstallerTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Management/ComposerInstallerTest.php
new file mode 100644
index 0000000000..abe7bd7671
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Management/ComposerInstallerTest.php
@@ -0,0 +1,40 @@
+find('php');
+
+ $vendorDir = __DIR__ . '/../Fixtures/PluginDir/TestPlugin/vendor';
+ $installFile = __DIR__ . '/installer';
+ $composer = __DIR__ . '/composer.phar';
+
+ $fs->remove(array($composer, $installFile, $vendorDir));
+
+ if (null === $php) {
+ $this->markTestSkipped('Unable to find PHP executable.');
+ }
+
+ $installer = new ComposerInstaller(__DIR__, new Guzzle(), $php);
+ $installer->install(__DIR__ . '/../Fixtures/PluginDir/TestPlugin');
+
+ $this->assertFileExists($composer);
+ unlink($composer);
+
+ $this->assertFileNotExists($installFile);
+ $this->assertFileExists($vendorDir);
+
+ $fs->remove($vendorDir);
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Management/PluginsExplorerTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Management/PluginsExplorerTest.php
new file mode 100644
index 0000000000..f5565c9d23
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Management/PluginsExplorerTest.php
@@ -0,0 +1,29 @@
+assertCount(1, $explorer);
+ }
+
+ public function testGetIterator()
+ {
+ $explorer = new PluginsExplorer(__DIR__ . '/../Fixtures/PluginDir');
+
+ $dirs = array();
+
+ foreach ($explorer as $dir) {
+ $dirs[] = (string) realpath($dir);
+ }
+
+ $this->assertSame(array(realpath(__DIR__ . '/../Fixtures/PluginDir/TestPlugin')), $dirs);
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/PluginTestCase.php b/tests/Alchemy/Tests/Phrasea/Plugin/PluginTestCase.php
new file mode 100644
index 0000000000..e1cd027f17
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/PluginTestCase.php
@@ -0,0 +1,33 @@
+getMock('Symfony\Component\Filesystem\Filesystem');
+ }
+
+ protected function getSchema()
+ {
+ return file_get_contents($this->getSchemaPath());
+ }
+
+ protected function getSchemaPath()
+ {
+ return __DIR__ . '/../../../../../lib/conf.d/plugin-schema.json';
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestTest.php
new file mode 100644
index 0000000000..07bdc72495
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestTest.php
@@ -0,0 +1,30 @@
+assertEquals('TestPlugin', $manifest->getName());
+ $this->assertEquals('A custom class connector', $manifest->getDescription());
+ $this->assertEquals(array('connector'), $manifest->getKeywords());
+ $this->assertEquals(array(array(
+ 'name' => 'Author name',
+ 'homepage' => 'http://example.com',
+ 'email' => 'email@example.com',
+ )), $manifest->getAuthors());
+ $this->assertEquals('http://example.com/project/example', $manifest->getHomepage());
+ $this->assertEquals('MIT', $manifest->getLicense());
+ $this->assertEquals('0.1', $manifest->getVersion());
+ $this->assertEquals('3.8', $manifest->getMinimumPhraseanetVersion());
+ $this->assertEquals('3.9', $manifest->getMaximumPhraseanetVersion());
+ $this->assertEquals(array(array('class' => 'Vendor\PluginService')), $manifest->getServices());
+ $this->assertEquals(array('property' => 'value'), $manifest->getExtra());
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestValidatorTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestValidatorTest.php
new file mode 100644
index 0000000000..5fb5a1bc99
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestValidatorTest.php
@@ -0,0 +1,80 @@
+createValidator();
+ $validator->validate(json_decode(file_get_contents($file)));
+ }
+
+ public function provideGoodManifestFiles()
+ {
+ return array(
+ array(__DIR__ . '/../Fixtures/manifest-good-big.json'),
+ array(__DIR__ . '/../Fixtures/manifest-good-minimal.json'),
+ );
+ }
+
+ /**
+ * @expectedException Alchemy\Phrasea\Plugin\Exception\JsonValidationException
+ * @dataProvider provideWrongManifestFiles
+ */
+ public function testValidateWrongOnes($file)
+ {
+ $validator = $this->createValidator();
+ $validator->validate(json_decode(file_get_contents($file)));
+ }
+
+ public function provideWrongManifestFiles()
+ {
+ return array(
+ array(__DIR__ . '/../Fixtures/manifest-wrong1.json'),
+ array(__DIR__ . '/../Fixtures/manifest-wrong2.json'),
+ array(__DIR__ . '/../Fixtures/manifest-wrong3.json'),
+ array(__DIR__ . '/../Fixtures/manifest-wrong4.json'),
+ array(__DIR__ . '/../Fixtures/manifest-wrong5-min-version.json'),
+ array(__DIR__ . '/../Fixtures/manifest-wrong6-max-version.json'),
+ );
+ }
+
+ /**
+ * @expectedException Alchemy\Phrasea\Exception\InvalidArgumentException
+ */
+ public function testValidateInvalidData()
+ {
+ $validator = $this->createValidator();
+ $validator->validate(array());
+ }
+
+ /**
+ * @expectedException Alchemy\Phrasea\Exception\InvalidArgumentException
+ */
+ public function testConstructWithInvalidSchema()
+ {
+ new ManifestValidator(new JsonSchemaValidator(), array(), self::$DI['app']['phraseanet.version']);
+ }
+
+ public function testCreate()
+ {
+ $validator = ManifestValidator::create(self::$DI['app']);
+
+ $this->assertInstanceOf('Alchemy\Phrasea\Plugin\Schema\ManifestValidator', $validator);
+ }
+
+ private function createValidator()
+ {
+ $schema = json_decode($this->getSchema());
+
+ return new ManifestValidator(new JsonSchemaValidator(), $schema, self::$DI['app']['phraseanet.version']);
+ }
+}
diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Schema/PluginValidatorTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/PluginValidatorTest.php
new file mode 100644
index 0000000000..b477faa68f
--- /dev/null
+++ b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/PluginValidatorTest.php
@@ -0,0 +1,47 @@
+createManifestValidator());
+ $validator->validatePlugin($directory);
+ }
+
+ /**
+ * @dataProvider providePluginDirs
+ */
+ public function testValidatePlugin($directory)
+ {
+ $validator = new PluginValidator($this->createManifestValidator());
+ $validator->validatePlugin($directory);
+ }
+
+ public function providePluginDirs()
+ {
+ return array(
+ array(__DIR__ . '/../Fixtures/PluginDir/TestPlugin'),
+ );
+ }
+
+ public function provideInvalidPluginDirs()
+ {
+ return array(
+ array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginInvalidManifest'),
+ array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginMissingComposer'),
+ array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginMissingManifest'),
+ array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginWrongManifest'),
+ );
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 6aff6af6ba..5d043317fd 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -1,5 +1,5 @@
add('Alchemy\\Tests', __DIR__);
$loader->add('', __DIR__ . "/classes");
diff --git a/www/api.php b/www/api.php
index a0159a2140..2f87fc6873 100644
--- a/www/api.php
+++ b/www/api.php
@@ -14,7 +14,7 @@
* @license http://opensource.org/licenses/gpl-3.0 GPLv3
* @link www.phraseanet.com
*/
-require_once __DIR__ . '/../vendor/autoload.php';
+require_once __DIR__ . '/../lib/autoload.php';
$app = require __DIR__ . '/../lib/Alchemy/Phrasea/Application/Api.php';
diff --git a/www/index.php b/www/index.php
index a8794dd050..e9c76071b0 100644
--- a/www/index.php
+++ b/www/index.php
@@ -14,7 +14,7 @@
* @license http://opensource.org/licenses/gpl-3.0 GPLv3
* @link www.phraseanet.com
*/
-require_once __DIR__ . "/../vendor/autoload.php";
+require_once __DIR__ . "/../lib/autoload.php";
$environment = 'prod';
$app = require __DIR__ . '/../lib/Alchemy/Phrasea/Application/Root.php';
diff --git a/www/index_dev.php b/www/index_dev.php
index d4064c8b54..54a8856d07 100644
--- a/www/index_dev.php
+++ b/www/index_dev.php
@@ -14,7 +14,7 @@
* @license http://opensource.org/licenses/gpl-3.0 GPLv3
* @link www.phraseanet.com
*/
-require_once __DIR__ . "/../vendor/autoload.php";
+require_once __DIR__ . "/../lib/autoload.php";
$environment = 'dev';
$app = require __DIR__ . '/../lib/Alchemy/Phrasea/Application/Root.php';