Merge branch 'master' into PHRAS-1378_es-max-result-window_MASTER

This commit is contained in:
Nicolas Maillat
2020-11-13 19:55:15 +01:00
committed by GitHub
89 changed files with 27528 additions and 11068 deletions

View File

@@ -5,6 +5,8 @@ namespace Alchemy\Phrasea\Application;
use Alchemy\EmbedProvider\EmbedServiceProvider;
use Alchemy\Phrasea\Application;
use Alchemy\Phrasea\ControllerProvider as Providers;
use Alchemy\Phrasea\PhraseanetService\Provider\PSAdminServiceProvider;
use Alchemy\Phrasea\PhraseanetService\Provider\PSExposeServiceProvider;
use Alchemy\Phrasea\Report\ControllerProvider\ProdReportControllerProvider;
use Alchemy\Phrasea\WorkerManager\Provider\ControllerServiceProvider as WorkerManagerProvider;
use Assert\Assertion;
@@ -30,6 +32,7 @@ class RouteLoader
'/admin/subdefs' => Providers\Admin\Subdefs::class,
'/admin/task-manager' => Providers\Admin\TaskManager::class,
'/admin/worker-manager' => WorkerManagerProvider::class,
'/admin/phraseanet-service' => PSAdminServiceProvider::class,
'/admin/users' => Providers\Admin\Users::class,
'/client/' => Providers\Client\Root::class,
'/datafiles' => Providers\Datafiles::class,
@@ -45,6 +48,7 @@ class RouteLoader
'/prod/bridge/' => Providers\Prod\Bridge::class,
'/prod/download' => Providers\Prod\Download::class,
'/prod/export/' => Providers\Prod\Export::class,
'/prod/expose/' => PSExposeServiceProvider::class,
'/prod/feeds' => Providers\Prod\Feed::class,
'/prod/language' => Providers\Prod\Language::class,
'/prod/lazaret/' => Providers\Prod\Lazaret::class,

View File

@@ -59,6 +59,8 @@ class LanguageController
'feed_require_fields' => $translator->trans('Vous n\'avez pas rempli tous les champ requis'),
'feed_require_feed' => $translator->trans('Vous n\'avez pas selectionne de fil de publication'),
'removeTitle' => $translator->trans('panier::Supression d\'un element d\'un reportage'),
'removeExposePublication' => $translator->trans('expose::Your are about to delete a publication from expose, please confirm your action !'),
'removeAssetPublication' => $translator->trans('expose::Your are about to delete an asset from a publication, please confirm your action !'),
'confirmRemoveReg' => $translator->trans('panier::Attention, vous etes sur le point de supprimer un element du reportage. Merci de confirmer votre action.'),
'advsearch_title' => $translator->trans('phraseanet::recherche avancee'),
'bask_rename' => $translator->trans('panier:: renommer le panier'),
@@ -116,6 +118,7 @@ class LanguageController
'attention' => $translator->trans('Attention !'),
'mapMarkerEdit' => $translator->trans('Edit position'),
'mapMarkerAdd' => $translator->trans('Add a position'),
'Change position' => $translator->trans('prod:mapbox Change position'),
'mapMarkerMoveLabel' => $translator->trans('Drag and drop the pin to move position'),
'mapMarkerEditCancel' => $translator->trans('Cancel'),
'mapMarkerEditSubmit' => $translator->trans('Submit'),
@@ -156,8 +159,15 @@ class LanguageController
'description notice' => $translator->trans('prod:mapboxgl: description notice'),
'title-map-dialog' => $translator->trans('prod:mapboxgl: title map dialog'),
'create new user' => $translator->trans('prod:push: create new user'),
'prod:mapboxjs: title notice' => $translator->trans('prod:mapboxjs: title notice'),
'prod:mapboxjs: description notice' => $translator->trans('prod:mapboxjs: description notice'),
'prod:mapboxjs: title info' => $translator->trans('prod:mapboxjs: title info'),
'prod:mapboxjs: description info : right click to add position' => $translator->trans('prod:mapboxjs: description info : right click to add position'),
'prod:mapboxgl: title info' => $translator->trans('prod:mapboxgl: title info'),
'prod:mapboxgl: description info : right click to add position' => $translator->trans('prod:mapboxgl: description info : right click to add position'),
'prod:videoeditor:subtitletab:message:: error' => $translator->trans('prod:videoeditor:subtitletab:message:: error'),
'prod:videoeditor:subtitletab:message:: success' => $translator->trans('prod:videoeditor:subtitletab:message:: success'),
'Edit expose title' => $translator->trans('prod:workzone:expose:modal:: title'),
]);
}
}

View File

@@ -3,6 +3,8 @@
namespace Alchemy\Phrasea\ControllerProvider;
use Alchemy\EmbedProvider\EmbedServiceProvider;
use Alchemy\Phrasea\PhraseanetService\Provider\PSAdminServiceProvider;
use Alchemy\Phrasea\PhraseanetService\Provider\PSExposeServiceProvider;
use Silex\Application;
use Silex\ServiceProviderInterface;
@@ -55,6 +57,8 @@ class ControllerProviderServiceProvider implements ServiceProviderInterface
Admin\Subdefs::class => [],
Admin\TaskManager::class => [],
\Alchemy\Phrasea\WorkerManager\Provider\ControllerServiceProvider::class => [],
PSAdminServiceProvider::class => [],
PSExposeServiceProvider::class => [],
Admin\Users::class => [],
Client\Root::class => [],
Datafiles::class => [],

View File

@@ -0,0 +1,55 @@
<?php
namespace Alchemy\Phrasea\PhraseanetService\Controller;
use Alchemy\Phrasea\Application as PhraseaApplication;
use Alchemy\Phrasea\Controller\Controller;
use Alchemy\Phrasea\PhraseanetService\Form\PSExposeConfigurationType;
use Symfony\Component\HttpFoundation\Request;
class PSAdminController extends Controller
{
public function indexAction(PhraseaApplication $app)
{
return $this->render('admin/phraseanet-service/index.html.twig');
}
public function authAction()
{
return $this->render('admin/phraseanet-service/auth.html.twig');
}
public function exposeAction(PhraseaApplication $app, Request $request)
{
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service'], null);
$form = $app->form(new PSExposeConfigurationType(), $exposeConfiguration);
$form->handleRequest($request);
if ($form->isValid()) {
$app['conf']->set(['phraseanet-service', 'expose-service'], $form->getData());
return $app->redirectPath('ps_admin');
}
return $this->render('admin/phraseanet-service/expose.html.twig', [
'form' => $form->createView()
]);
}
public function notifyAction()
{
return $this->render('admin/phraseanet-service/notify.html.twig');
}
public function reportAction()
{
return $this->render('admin/phraseanet-service/report.html.twig');
}
public function uploaderAction()
{
return $this->render('admin/phraseanet-service/uploader.html.twig');
}
}

View File

@@ -0,0 +1,630 @@
<?php
namespace Alchemy\Phrasea\PhraseanetService\Controller;
use Alchemy\Phrasea\Application as PhraseaApplication;
use Alchemy\Phrasea\Controller\Controller;
use Alchemy\Phrasea\WorkerManager\Event\ExposeUploadEvent;
use Alchemy\Phrasea\WorkerManager\Event\WorkerEvents;
use GuzzleHttp\Client;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
class PSExposeController extends Controller
{
/**
* Set access token on session 'password_access_token'
* @param PhraseaApplication $app
* @param Request $request
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function authenticateAction(PhraseaApplication $app, Request $request)
{
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$request->request->get('exposeName')];
if ($exposeConfiguration == null) {
return $this->app->json([
'success' => false,
'message' => 'Please, set configuration in admin!'
]);
}
$oauthClient = new Client(['base_uri' => $exposeConfiguration['auth_base_uri'], 'http_errors' => false]);
try {
$response = $oauthClient->post('/oauth/v2/token', [
'json' => [
'client_id' => $exposeConfiguration['auth_client_id'],
'client_secret' => $exposeConfiguration['auth_client_secret'],
'grant_type' => 'password',
'username' => $request->request->get('auth-username'),
'password' => $request->request->get('auth-password') ]
]);
} catch(\Exception $e) {
return $this->app->json([
'success' => false,
'message' => $e->getMessage()
]);
}
if ($response->getStatusCode() !== 200) {
return $this->app->json([
'success' => false,
'message' => 'Status code: '. $response->getStatusCode()
]);
}
$tokenBody = $response->getBody()->getContents();
$tokenBody = json_decode($tokenBody,true);
$session = $this->getSession();
$session->set('password_access_token', $tokenBody['access_token']);
return $this->app->json([
'success' => true
]);
}
/**
* Get list of publication
* Use param "format=json" to retrieve a json
*
* @param PhraseaApplication $app
* @param Request $request
* @return string|\Symfony\Component\HttpFoundation\JsonResponse
*/
public function listPublicationAction(PhraseaApplication $app, Request $request)
{
if ($request->get('exposeName') == null) {
return $this->render("prod/WorkZone/ExposeList.html.twig", [
'publications' => [],
]);
}
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$request->get('exposeName')];
$session = $this->getSession();
if (!$session->has('password_access_token') && $exposeConfiguration['connection_kind'] == 'password' && $request->get('format') != 'json') {
return $this->render("prod/WorkZone/ExposeOauthLogin.html.twig", [
'exposeName' => $request->get('exposeName')
]);
}
$accessToken = $this->getAndSaveToken($exposeConfiguration);
if ($exposeConfiguration == null ) {
return $this->render("prod/WorkZone/ExposeList.html.twig", [
'publications' => [],
]);
}
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
$response = $exposeClient->get('/publications?flatten=true&order[createdAt]=desc', [
'headers' => [
'Authorization' => 'Bearer '. $accessToken,
'Content-Type' => 'application/json'
]
]);
$exposeFrontBasePath = \p4string::addEndSlash($exposeConfiguration['expose_front_uri']);
$publications = [];
if ($response->getStatusCode() == 200) {
$body = json_decode($response->getBody()->getContents(),true);
$publications = $body['hydra:member'];
}
if ($request->get('format') == 'json') {
return $app->json([
'publications' => $publications
]);
}
return $this->render("prod/WorkZone/ExposeList.html.twig", [
'publications' => $publications,
'exposeFrontBasePath' => $exposeFrontBasePath
]);
}
/**
* Require params "exposeName" and "publicationId"
* optional param "onlyAssets" equal to 1 to return only assets list
*
* @param PhraseaApplication $app
* @param Request $request
* @return string
*/
public function getPublicationAction(PhraseaApplication $app, Request $request)
{
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$request->get('exposeName')];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$publication = [];
$resPublication = $exposeClient->get('/publications/' . $request->get('publicationId') , [
'headers' => [
'Authorization' => 'Bearer '. $accessToken,
'Content-Type' => 'application/json'
]
]);
if ($resPublication->getStatusCode() != 200) {
return $app->json([
'success' => false,
'message' => "An error occurred when getting publication: status-code " . $resPublication->getStatusCode()
]);
}
if ($resPublication->getStatusCode() == 200) {
$publication = json_decode($resPublication->getBody()->getContents(),true);
}
if ($request->get('onlyAssets')) {
return $this->render("prod/WorkZone/ExposePublicationAssets.html.twig", [
'assets' => $publication['assets'],
'publicationId' => $publication['id']
]);
}
return $this->render("prod/WorkZone/ExposeEdit.html.twig", [
'publication' => $publication,
'exposeName' => $request->get('exposeName')
]);
}
/**
* Require params "exposeName" and "publicationId"
* optionnal param "page"
*
* @param PhraseaApplication $app
* @param Request $request
* @return string|\Symfony\Component\HttpFoundation\JsonResponse
*/
public function getPublicationAssetsAction(PhraseaApplication $app, Request $request)
{
$page = $request->get('page')?:1;
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$request->get('exposeName')];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$resPublication = $exposeClient->get('/publications/' . $request->get('publicationId') . '/assets?page=' . $page , [
'headers' => [
'Authorization' => 'Bearer '. $accessToken,
'Content-Type' => 'application/json'
]
]);
if ($resPublication->getStatusCode() != 200) {
return $app->json([
'success' => false,
'message' => "An error occurred when getting publication assets: status-code " . $resPublication->getStatusCode()
]);
}
$pubAssets = [];
$totalItems = 0;
if ($resPublication->getStatusCode() == 200) {
$body = json_decode($resPublication->getBody()->getContents(),true);
$pubAssets = $body['hydra:member'];
$totalItems = $body['hydra:totalItems'];
}
return $this->render("prod/WorkZone/ExposePublicationAssets.html.twig", [
'pubAssets' => $pubAssets,
'publicationId' => $request->get('publicationId'),
'totalItems' => $totalItems,
'page' => $page
]);
}
/**
* Require params "exposeName"
*
* @param PhraseaApplication $app
* @param Request $request
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function listProfileAction(PhraseaApplication $app, Request $request)
{
if ( $request->get('exposeName') == null) {
return $app->json([
'profiles' => [],
'basePath' => []
]);
}
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$request->get('exposeName')];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$profiles = [];
$basePath = '';
$resProfile = $exposeClient->get('/publication-profiles' , [
'headers' => [
'Authorization' => 'Bearer '. $accessToken,
'Content-Type' => 'application/json'
]
]);
if ($resProfile->getStatusCode() != 200) {
return $app->json([
'success' => false,
'message' => "An error occurred when getting publication: status-code " . $resProfile->getStatusCode()
]);
}
if ($resProfile->getStatusCode() == 200) {
$body = json_decode($resProfile->getBody()->getContents(),true);
$profiles = $body['hydra:member'];
$basePath = $body['@id'];
}
return $app->json([
'profiles' => $profiles,
'basePath' => $basePath
]);
}
/**
* Create a publication
* Require params "exposeName" and "publicationData"
*
* @param PhraseaApplication $app
* @param Request $request
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function createPublicationAction(PhraseaApplication $app, Request $request)
{
$exposeName = $request->get('exposeName');
if ( $exposeName == null) {
return $app->json([
'success' => false,
'message' => "ExposeName required, select one!"
]);
}
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$exposeName];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
try {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->postPublication($exposeClient, $accessToken, json_decode($request->get('publicationData'), true));
if ($response->getStatusCode() == 401) {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->postPublication($exposeClient, $accessToken, json_decode($request->get('publicationData'), true));
}
if ($response->getStatusCode() !== 201) {
return $app->json([
'success' => false,
'message' => "An error occurred when creating publication: status-code " . $response->getStatusCode()
]);
}
$publicationsResponse = json_decode($response->getBody(),true);
} catch (\Exception $e) {
return $app->json([
'success' => false,
'message' => "An error occurred when creating publication!"
]);
}
$path = empty($publicationsResponse['slug']) ? $publicationsResponse['id'] : $publicationsResponse['slug'] ;
$url = \p4string::addEndSlash($exposeConfiguration['expose_front_uri']) . $path;
$link = "<a style='color:blue;' target='_blank' href='" . $url . "'>" . $url . "</a>";
return $app->json([
'success' => true,
'message' => "Publication successfully created!",
'link' => $link
]);
}
/**
* Update a publication
* Require params "exposeName" and "publicationId"
*
* @param PhraseaApplication $app
* @param Request $request
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function updatePublicationAction(PhraseaApplication $app, Request $request)
{
$exposeName = $request->get('exposeName');
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$exposeName];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
try {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->putPublication($exposeClient, $request->get('publicationId'), $accessToken, json_decode($request->get('publicationData'), true));
if ($response->getStatusCode() == 401) {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->putPublication($exposeClient, $request->get('publicationId'), $accessToken, json_decode($request->get('publicationData'), true));
}
if ($response->getStatusCode() !== 200) {
return $app->json([
'success' => false,
'message' => "An error occurred when updating publication: status-code " . $response->getStatusCode()
]);
}
} catch (\Exception $e) {
return $app->json([
'success' => false,
'message' => "An error occurred when updating publication! ". $e->getMessage()
]);
}
return $app->json([
'success' => true,
'message' => "Publication successfully updated!"
]);
}
/**
* Delete a Publication
* require params "exposeName" and "publicationId"
*
* @param PhraseaApplication $app
* @param Request $request
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function deletePublicationAction(PhraseaApplication $app, Request $request)
{
$exposeName = $request->get('exposeName');
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$exposeName];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
try {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->removePublication($exposeClient, $request->get('publicationId'), $accessToken);
if ($response->getStatusCode() == 401) {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->removePublication($exposeClient, $request->get('publicationId'), $accessToken);
}
if ($response->getStatusCode() !== 204) {
return $app->json([
'success' => false,
'message' => "An error occurred when deleting publication: status-code " . $response->getStatusCode()
]);
}
} catch (\Exception $e) {
return $app->json([
'success' => false,
'message' => "An error occurred when deleting publication!"
]);
}
return $app->json([
'success' => true,
'message' => "Publication successfully deleted!"
]);
}
/**
* Delete asset from publication
* require params "exposeName" ,"publicationId" and "assetId"
*
* @param PhraseaApplication $app
* @param Request $request
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function deletePublicationAssetAction(PhraseaApplication $app, Request $request)
{
$exposeName = $request->get('exposeName');
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$exposeName];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
try {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->removeAssetPublication($exposeClient, $request->get('publicationId'), $request->get('assetId'), $accessToken);
if ($response->getStatusCode() == 401) {
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$response = $this->removeAssetPublication($exposeClient, $request->get('publicationId'), $request->get('assetId'), $accessToken);
}
if ($response->getStatusCode() !== 204) {
return $app->json([
'success' => false,
'message' => "An error occurred when deleting asset: status-code " . $response->getStatusCode()
]);
}
} catch (\Exception $e) {
return $app->json([
'success' => false,
'message' => "An error occurred when deleting asset!"
]);
}
return $app->json([
'success' => true,
'message' => "Asset successfully removed from publication!"
]);
}
/**
* Add assets in a publication
* Require params "lst" , "exposeName" and "publicationId"
* "lst" is a list of record as "baseId_recordId"
*
* @param PhraseaApplication $app
* @param Request $request
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function addPublicationAssetsAction(PhraseaApplication $app, Request $request)
{
$exposeName = $request->get('exposeName');
$publicationId = $request->get('publicationId');
$lst = $request->get('lst');
if ($publicationId == null) {
return $app->json([
'success' => false,
'message' => 'Need to give publicationId to add asset in publication!'
]);
}
$exposeConfiguration = $app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$exposeName];
$accessToken = $this->getAndSaveToken($exposeConfiguration);
$this->getEventDispatcher()->dispatch(WorkerEvents::EXPOSE_UPLOAD_ASSETS, new ExposeUploadEvent($lst, $exposeName, $publicationId, $accessToken));
return $app->json([
'success' => true,
'message' => " Record (s) to be added to the publication!"
]);
}
/**
* Get Token and save in session
* @param $config
*
* @return mixed
*/
private function getAndSaveToken($config)
{
$session = $this->getSession();
$accessToken = '';
if ($config['connection_kind'] == 'password') {
$accessToken = $session->get('password_access_token');
} elseif ($config['connection_kind'] == 'client_credentials') {
if ($session->has('credential_access_token')) {
$accessToken = $session->get('credential_access_token');
} else {
$oauthClient = new Client();
try {
$response = $oauthClient->post($config['expose_base_uri'] . '/oauth/v2/token', [
'json' => [
'client_id' => $config['expose_client_id'],
'client_secret' => $config['expose_client_secret'],
'grant_type' => 'client_credentials',
'scope' => 'publish'
]
]);
} catch(\Exception $e) {
return null;
}
if ($response->getStatusCode() !== 200) {
return null;
}
$tokenBody = $response->getBody()->getContents();
$tokenBody = json_decode($tokenBody,true);
$session->set('credential_access_token', $tokenBody['access_token']);
$accessToken = $tokenBody['access_token'];
}
}
return $accessToken;
}
private function postPublication(Client $exposeClient, $token, $publicationData)
{
return $exposeClient->post('/publications', [
'headers' => [
'Authorization' => 'Bearer '. $token,
'Content-Type' => 'application/json'
],
'json' => $publicationData
]);
}
private function putPublication(Client $exposeClient, $publicationId, $token, $publicationData)
{
return $exposeClient->put('/publications/' . $publicationId, [
'headers' => [
'Authorization' => 'Bearer '. $token,
'Content-Type' => 'application/json'
],
'json' => $publicationData
]);
}
private function removePublication(Client $exposeClient, $publicationId, $token)
{
return $exposeClient->delete('/publications/' . $publicationId, [
'headers' => [
'Authorization' => 'Bearer '. $token
]
]);
}
private function removeAssetPublication(Client $exposeClient, $publicationId, $assetId, $token)
{
$exposeClient->delete('/publication-assets/'.$publicationId.'/'.$assetId, [
'headers' => [
'Authorization' => 'Bearer '. $token
]
]);
return $exposeClient->delete('/assets/'. $assetId, [
'headers' => [
'Authorization' => 'Bearer '. $token
]
]);
}
/**
* @return EventDispatcherInterface
*/
private function getEventDispatcher()
{
return $this->app['dispatcher'];
}
/**
* @return Session
*/
private function getSession()
{
return $this->app['session'];
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Alchemy\Phrasea\PhraseanetService\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Exception;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
class PSExposeConfigurationType extends AbstractType implements DataMapperInterface
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('activated', CheckboxType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: activate Phraseanet-service expose',
'required' => false,
'attr' => [
'class' => 'activate-expose',
]
])
->add('exposes', CollectionType::class, [
'label' => false,
'entry_type' => PSExposeConnectionType::class,
'prototype' => true,
'allow_add' => true,
'allow_delete' => true,
])
->setDataMapper($this)
;
}
/**
* @inheritDoc
*/
public function mapDataToForms($data, $forms)
{
// there is no data yet, so nothing to prepopulate
if ($data === null) {
return;
}
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
foreach ($data['exposes'] as $key => $config) {
$data['exposes'][$key]['expose_name'] = $key;
}
$forms['activated']->setData($data['activated']);
$forms['exposes']->setData(array_values($data['exposes']));
}
/**
* Data structure like this
*
* expose-service:
* activated: true
* exposes:
* expose_test:
* activate_expose: true
* connection_kind: account
* expose_front_uri: 'localhost:8080'
* expose_base_uri: 'localhost:8082'
* client_secret: secret
* client_id: id
*
* @inheritDoc
*/
public function mapFormsToData($forms, &$data)
{
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
$data = null;
$data['activated'] = $forms['activated']->getData();
/** @var FormInterface[] $exposeConfigForms */
$exposeConfigForms = iterator_to_array($forms['exposes']);
foreach ($exposeConfigForms as $exposeConfigForm) {
$config = $exposeConfigForm->getData();
$exposeName = $config['expose_name'];
unset($config['expose_name']);
$data['exposes'][$exposeName] = $config;
}
}
public function getName()
{
return 'ps_expose_configuration';
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Alchemy\Phrasea\PhraseanetService\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
class PSExposeConnectionType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('activate_expose', CheckboxType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Activate this expose',
'required' => false
])
->add('connection_kind', ChoiceType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Connection Kind',
'required' => true,
'attr' => [
'class' => 'auth-connection'
],
'choices' => [
'client_credentials' => 'client_credentials',
'password' => 'password'
]
])
->add('expose_name', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Name',
'attr' => [
'class' => 'expose-name'
]
])
->add('expose_front_uri', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Expose Front base uri',
'attr' => [
'class' => 'input-xxlarge'
]
])
->add('expose_base_uri', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Expose Base Uri api',
'attr' => [
'class' => 'input-xxlarge'
]
])
->add('expose_client_secret', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Expose Client secret',
'required' => false,
'attr' => [
'class' => 'input-xxlarge'
]
])
->add('expose_client_id', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Expose Client ID',
'required' => false,
'attr' => [
'class' => 'input-xxlarge'
]
])
->add('auth_base_uri', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Auth Base Uri ',
'required' => false,
'attr' => [
'class' => 'input-xxlarge'
]
])
->add('auth_client_secret', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Auth Client secret',
'required' => false,
'attr' => [
'class' => 'input-xxlarge'
]
])
->add('auth_client_id', TextType::class, [
'label' => 'admin:phrasea-service-setting:tab:expose:: Auth Client ID',
'required' => false,
'attr' => [
'class' => 'input-xxlarge'
]
])
;
}
public function getName()
{
return 'ps_expose_connection';
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Alchemy\Phrasea\PhraseanetService\Provider;
use Alchemy\Phrasea\Application as PhraseaApplication;
use Alchemy\Phrasea\ControllerProvider\ControllerProviderTrait;
use Alchemy\Phrasea\PhraseanetService\Controller\PSAdminController;
use Silex\Application;
use Silex\ControllerCollection;
use Silex\ControllerProviderInterface;
use Silex\ServiceProviderInterface;
class PSAdminServiceProvider implements ControllerProviderInterface, ServiceProviderInterface
{
use ControllerProviderTrait;
/**
* Registers services on the given app.
*
* This method should only be used to configure services and parameters.
* It should not get services.
*/
public function register(Application $app)
{
$app['controller.ps.admin'] = $app->share(function (PhraseaApplication $app) {
return new PSAdminController($app);
});
}
/**
* Returns routes to connect to the given application.
*
* @param Application $app An Application instance
*
* @return ControllerCollection A ControllerCollection instance
*/
public function connect(Application $app)
{
$controllers = $this->createAuthenticatedCollection($app);
$controllers->match('/', 'controller.ps.admin:indexAction')
->method('GET')
->bind('ps_admin');
$controllers->match('/auth', 'controller.ps.admin:authAction')
->method('GET|POST')
->bind('ps_admin_auth')
;
$controllers->match('/expose', 'controller.ps.admin:exposeAction')
->method('GET|POST')
->bind('ps_admin_expose')
;
$controllers->match('/uploader', 'controller.ps.admin:uploaderAction')
->method('GET|POST')
->bind('ps_admin_uploader')
;
$controllers->match('/notify', 'controller.ps.admin:notifyAction')
->method('GET|POST')
->bind('ps_admin_notify')
;
$controllers->match('/report', 'controller.ps.admin:reportAction')
->method('GET|POST')
->bind('ps_admin_report')
;
return $controllers;
}
/**
* Bootstraps the application.
*
* This method is called after all services are registered
* and should be used for "dynamic" configuration (whenever
* a service must be requested).
*/
public function boot(Application $app)
{
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Alchemy\Phrasea\PhraseanetService\Provider;
use Alchemy\Phrasea\Application as PhraseaApplication;
use Alchemy\Phrasea\ControllerProvider\ControllerProviderTrait;
use Alchemy\Phrasea\PhraseanetService\Controller\PSExposeController;
use Silex\Application;
use Silex\ControllerProviderInterface;
use Silex\ServiceProviderInterface;
class PSExposeServiceProvider implements ControllerProviderInterface, ServiceProviderInterface
{
use ControllerProviderTrait;
/**
* @inheritDoc
*/
public function register(Application $app)
{
$app['controller.ps.expose'] = $app->share(function (PhraseaApplication $app) {
return new PSExposeController($app);
});
}
/**
* @inheritDoc
*/
public function connect(Application $app)
{
$controllers = $this->createAuthenticatedCollection($app);
$controllers->match('/authenticate/', 'controller.ps.expose:authenticateAction')
->method('POST')
->bind('ps_expose_authenticate');
$controllers->match('/create-publication/', 'controller.ps.expose:createPublicationAction')
->method('POST')
->bind('ps_expose_create_publication');
$controllers->match('/update-publication/{publicationId}', 'controller.ps.expose:updatePublicationAction')
->method('POST|PUT')
->bind('ps_expose_update_publication');
$controllers->match('/list-publication/', 'controller.ps.expose:listPublicationAction')
->method('GET')
->bind('ps_expose_list_publication');
$controllers->match('/get-publication/{publicationId}/assets', 'controller.ps.expose:getPublicationAssetsAction')
->method('GET')
->bind('ps_expose_get_publication_assets');
$controllers->match('/get-publication/{publicationId}', 'controller.ps.expose:getPublicationAction')
->method('GET')
->bind('ps_expose_get_publication');
$controllers->match('/list-profile', 'controller.ps.expose:listProfileAction')
->method('GET')
->bind('ps_expose_get_publication_profile');
$controllers->match('/delete-publication/{publicationId}/', 'controller.ps.expose:deletePublicationAction')
->method('POST|DELETE')
->bind('ps_expose_delete_publication');
$controllers->match('/publication/delete-asset/{publicationId}/{assetId}/', 'controller.ps.expose:deletePublicationAssetAction')
->method('POST|DELETE')
->bind('ps_expose_publication_delete_asset');
$controllers->match('/publication/add-assets', 'controller.ps.expose:addPublicationAssetsAction')
->method('POST')
->bind('ps_expose_publication_add_assets');
return $controllers;
}
/**
* @inheritDoc
*/
public function boot(Application $app)
{
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Alchemy\Phrasea\WorkerManager\Event;
use Symfony\Component\EventDispatcher\Event as SfEvent;
class ExposeUploadEvent extends SfEvent
{
private $lst;
private $exposeName;
private $publicationId;
private $accessToken;
public function __construct($lst, $exposeName, $publicationId, $accessToken)
{
$this->lst = $lst;
$this->exposeName = $exposeName;
$this->publicationId = $publicationId;
$this->accessToken = $accessToken;
}
public function getLst()
{
return $this->lst;
}
public function getExposeName()
{
return $this->exposeName;
}
public function getPublicationId()
{
return $this->publicationId;
}
public function getAccessToken()
{
return $this->accessToken;
}
}

View File

@@ -19,4 +19,6 @@ final class WorkerEvents
const EXPORT_MAIL_FAILURE = 'export.worker_mail_failure';
const WEBHOOK_DELIVER_FAILURE = 'webhook.deliver_failure';
const EXPOSE_UPLOAD_ASSETS = 'expose.upload_assets';
}

View File

@@ -10,6 +10,7 @@ use Alchemy\Phrasea\WorkerManager\Worker\AssetsIngestWorker;
use Alchemy\Phrasea\WorkerManager\Worker\CreateRecordWorker;
use Alchemy\Phrasea\WorkerManager\Worker\DeleteRecordWorker;
use Alchemy\Phrasea\WorkerManager\Worker\ExportMailWorker;
use Alchemy\Phrasea\WorkerManager\Worker\ExposeUploadWorker;
use Alchemy\Phrasea\WorkerManager\Worker\Factory\CallableWorkerFactory;
use Alchemy\Phrasea\WorkerManager\Worker\MainQueueWorker;
use Alchemy\Phrasea\WorkerManager\Worker\PopulateIndexWorker;
@@ -131,6 +132,11 @@ class AlchemyWorkerServiceProvider implements PluginProviderInterface
->setApplicationBox($app['phraseanet.appbox']);
}));
$app['alchemy_worker.type_based_worker_resolver']->addFactory(MessagePublisher::EXPOSE_UPLOAD_TYPE, new CallableWorkerFactory(function () use ($app) {
return (new ExposeUploadWorker($app))
->setApplicationBox($app['phraseanet.appbox']);
}));
$app['alchemy_worker.type_based_worker_resolver']->addFactory(MessagePublisher::SUBTITLE_TYPE, new CallableWorkerFactory(function () use ($app) {
return (new SubtitleWorker($app['repo.worker-job'], $app['conf'], new LazyLocator($app, 'phraseanet.appbox'), $app['alchemy_worker.logger']))
->setFileSystemLocator(new LazyLocator($app, 'filesystem'))

View File

@@ -21,6 +21,7 @@ use Alchemy\Phrasea\WorkerManager\Queue\MessagePublisher;
use Alchemy\Phrasea\WorkerManager\Queue\WebhookPublisher;
use Alchemy\Phrasea\WorkerManager\Subscriber\AssetsIngestSubscriber;
use Alchemy\Phrasea\WorkerManager\Subscriber\ExportSubscriber;
use Alchemy\Phrasea\WorkerManager\Subscriber\ExposeSubscriber;
use Alchemy\Phrasea\WorkerManager\Subscriber\RecordSubscriber;
use Alchemy\Phrasea\WorkerManager\Subscriber\SearchengineSubscriber;
use Alchemy\Phrasea\WorkerManager\Subscriber\SubtitleSubscriber;
@@ -70,6 +71,7 @@ class QueueWorkerServiceProvider implements PluginProviderInterface
$dispatcher->addSubscriber(new SearchengineSubscriber($app['alchemy_worker.message.publisher'], new LazyLocator($app, 'repo.worker-running-job')));
$dispatcher->addSubscriber(new WebhookSubscriber($app['alchemy_worker.message.publisher']));
$dispatcher->addSubscriber(new SubtitleSubscriber(new LazyLocator($app, 'repo.worker-job'), $app['alchemy_worker.message.publisher']));
$dispatcher->addSubscriber(new ExposeSubscriber($app['alchemy_worker.message.publisher']));
return $dispatcher;
})

View File

@@ -31,7 +31,8 @@ class AMQPConnection
MessagePublisher::POPULATE_INDEX_TYPE => MessagePublisher::POPULATE_INDEX_QUEUE,
MessagePublisher::DELETE_RECORD_TYPE => MessagePublisher::DELETE_RECORD_QUEUE,
MessagePublisher::MAIN_QUEUE_TYPE => MessagePublisher::MAIN_QUEUE,
MessagePublisher::SUBTITLE_TYPE => MessagePublisher::SUBTITLE_QUEUE
MessagePublisher::SUBTITLE_TYPE => MessagePublisher::SUBTITLE_QUEUE,
MessagePublisher::EXPOSE_UPLOAD_TYPE => MessagePublisher::EXPOSE_UPLOAD_QUEUE
];
// the corresponding worker queues and retry queues, loop queue

View File

@@ -9,17 +9,18 @@ use Psr\Log\LoggerInterface;
class MessagePublisher
{
const EXPORT_MAIL_TYPE = 'exportMail';
const SUBDEF_CREATION_TYPE = 'subdefCreation';
const WRITE_METADATAS_TYPE = 'writeMetadatas';
const ASSETS_INGEST_TYPE = 'assetsIngest';
const CREATE_RECORD_TYPE = 'createRecord';
const DELETE_RECORD_TYPE = 'deleteRecord';
const WEBHOOK_TYPE = 'webhook';
const POPULATE_INDEX_TYPE = 'populateIndex';
const PULL_ASSETS_TYPE = 'pullAssets';
const SUBTITLE_TYPE = 'subtitle';
const MAIN_QUEUE_TYPE = 'mainQueue';
const EXPORT_MAIL_TYPE = 'exportMail';
const SUBDEF_CREATION_TYPE = 'subdefCreation';
const WRITE_METADATAS_TYPE = 'writeMetadatas';
const ASSETS_INGEST_TYPE = 'assetsIngest';
const CREATE_RECORD_TYPE = 'createRecord';
const DELETE_RECORD_TYPE = 'deleteRecord';
const WEBHOOK_TYPE = 'webhook';
const POPULATE_INDEX_TYPE = 'populateIndex';
const PULL_ASSETS_TYPE = 'pullAssets';
const SUBTITLE_TYPE = 'subtitle';
const MAIN_QUEUE_TYPE = 'mainQueue';
const EXPOSE_UPLOAD_TYPE = 'exposeUpload';
const MAIN_QUEUE = 'main-queue';
@@ -35,6 +36,7 @@ class MessagePublisher
const DELETE_RECORD_QUEUE = 'deleterecord-queue';
const POPULATE_INDEX_QUEUE = 'populateindex-queue';
const PULL_QUEUE = 'pull-queue';
const EXPOSE_UPLOAD_QUEUE = 'exposeupload-queue';
// retry queue
// we can use these retry queue with TTL, so when message expires it is requeued to the corresponding worker queue

View File

@@ -0,0 +1,49 @@
<?php
namespace Alchemy\Phrasea\WorkerManager\Subscriber;
use Alchemy\Phrasea\WorkerManager\Event\ExposeUploadEvent;
use Alchemy\Phrasea\WorkerManager\Event\WorkerEvents;
use Alchemy\Phrasea\WorkerManager\Queue\MessagePublisher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ExposeSubscriber implements EventSubscriberInterface
{
/** @var MessagePublisher $messagePublisher */
private $messagePublisher;
public function __construct(MessagePublisher $messagePublisher)
{
$this->messagePublisher = $messagePublisher;
}
public function onExposeUploadAssets(ExposeUploadEvent $event)
{
foreach (explode(";", $event->getLst()) as $bas_rec) {
$basrec = explode('_', $bas_rec);
if (count($basrec) != 2) {
continue;
}
$payload = [
'message_type' => MessagePublisher::EXPOSE_UPLOAD_TYPE,
'payload' => [
'recordId' => (int) $basrec[1],
'databoxId' => (int) $basrec[0],
'exposeName' => $event->getExposeName(),
'publicationId' => $event->getPublicationId(),
'accessToken' => $event->getAccessToken()
]
];
$this->messagePublisher->publishMessage($payload, MessagePublisher::EXPOSE_UPLOAD_QUEUE);
}
}
public static function getSubscribedEvents()
{
return [
WorkerEvents::EXPOSE_UPLOAD_ASSETS => 'onExposeUploadAssets',
];
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace Alchemy\Phrasea\WorkerManager\Worker;
use Alchemy\Phrasea\Application\Helper\ApplicationBoxAware;
use Alchemy\Phrasea\Model\Entities\WorkerRunningJob;
use Alchemy\Phrasea\Model\Repositories\WorkerRunningJobRepository;
use Alchemy\Phrasea\Twig\PhraseanetExtension;
use Alchemy\Phrasea\WorkerManager\Queue\MessagePublisher;
use GuzzleHttp\Client;
class ExposeUploadWorker implements WorkerInterface
{
use ApplicationBoxAware;
/** @var WorkerRunningJobRepository $repoWorker*/
private $repoWorker;
/** @var MessagePublisher $messagePublisher */
private $messagePublisher;
private $app;
public function __construct($app)
{
$this->app = $app;
$this->repoWorker = $app['repo.worker-running-job'];
$this->messagePublisher = $app['alchemy_worker.message.publisher'];
}
public function process(array $payload)
{
$em = $this->repoWorker->getEntityManager();
$em->beginTransaction();
$date = new \DateTime();
$message = [
'message_type' => MessagePublisher::EXPOSE_UPLOAD_TYPE,
'payload' => $payload
];
$workerRunningJob = new WorkerRunningJob();
try {
$workerRunningJob
->setWork(MessagePublisher::EXPOSE_UPLOAD_TYPE)
->setDataboxId($payload['databoxId'])
->setRecordId($payload['recordId'])
->setWorkOn($payload['exposeName'])
->setPayload($message)
->setPublished($date->setTimestamp($payload['published']))
->setStatus(WorkerRunningJob::RUNNING)
;
$em->persist($workerRunningJob);
$em->flush();
$em->commit();
} catch (\Exception $e) {
$em->rollback();
}
$exposeConfiguration = $this->app['conf']->get(['phraseanet-service', 'expose-service', 'exposes'], []);
$exposeConfiguration = $exposeConfiguration[$payload['exposeName']];
$exposeClient = new Client(['base_uri' => $exposeConfiguration['expose_base_uri'], 'http_errors' => false]);
$record = $this->findDataboxById($payload['databoxId'])->get_record($payload['recordId']);
try {
$helpers = new PhraseanetExtension($this->app);
$canSeeBusiness = $helpers->isGrantedOnCollection($record->getBaseId(), [\ACL::CANMODIFRECORD]);
$captionsByfield = $record->getCaption($helpers->getCaptionFieldOrder($record, $canSeeBusiness));
$description = "<dl>";
foreach ($captionsByfield as $name => $value) {
if ($helpers->getCaptionFieldGuiVisible($record, $name) == 1) {
$description .= "<dt>" . $helpers->getCaptionFieldLabel($record, $name). "</dt>";
$description .= "<dd>" . $helpers->getCaptionField($record, $name, $value). "</dd>";
}
}
$description .= "</dl>";
$databox = $record->getDatabox();
$caption = $record->get_caption();
$lat = $lng = null;
foreach ($databox->get_meta_structure() as $meta) {
if (strpos(strtolower($meta->get_name()), 'longitude') !== FALSE && $caption->has_field($meta->get_name())) {
// retrieve value for the corresponding field
$fieldValues = $record->get_caption()->get_field($meta->get_name())->get_values();
$fieldValue = array_pop($fieldValues);
$lng = $fieldValue->getValue();
} elseif (strpos(strtolower($meta->get_name()), 'latitude') !== FALSE && $caption->has_field($meta->get_name())) {
// retrieve value for the corresponding field
$fieldValues = $record->get_caption()->get_field($meta->get_name())->get_values();
$fieldValue = array_pop($fieldValues);
$lat = $fieldValue->getValue();
}
}
$multipartData = [
[
'name' => 'file',
'contents' => fopen($record->get_subdef('document')->getRealPath(), 'r')
],
[
'name' => 'publication_id',
'contents' => $payload['publicationId'],
],
[
'name' => 'slug',
'contents' => 'asset_'. $record->getId()
],
[
'name' => 'description',
'contents' => $description
]
];
if ($lat !== null) {
array_push($multipartData, [
'name' => 'lat',
'contents' => $lat
]);
}
if ($lng !== null) {
array_push($multipartData, [
'name' => 'lng',
'contents' => $lng
]);
}
$response = $exposeClient->post('/assets', [
'headers' => [
'Authorization' => 'Bearer ' . $payload['accessToken']
],
'multipart' => $multipartData
]);
if ($response->getStatusCode() !==201) {
$this->messagePublisher->pushLog("An error occurred when creating asset: status-code " . $response->getStatusCode());
}
$assetsResponse = json_decode($response->getBody(),true);
// add preview sub-definition
$this->postSubDefinition(
$exposeClient,
$payload['accessToken'],
$record->get_subdef('preview')->getRealPath(),
$assetsResponse['id'],
'preview',
true
);
// add thumbnail sub-definition
$this->postSubDefinition(
$exposeClient,
$payload['accessToken'],
$record->get_subdef('thumbnail')->getRealPath(),
$assetsResponse['id'],
'thumbnail',
false,
true
);
} catch (\Exception $e) {
$this->messagePublisher->pushLog("An error occurred when creating asset!");
}
// tell that the upload is finished
$this->repoWorker->reconnect();
$em->getConnection()->beginTransaction();
try {
$workerRunningJob->setStatus(WorkerRunningJob::FINISHED);
$workerRunningJob->setFinished(new \DateTime('now'));
$em->persist($workerRunningJob);
$em->flush();
$em->commit();
} catch (\Exception $e) {
$this->messagePublisher->pushLog("Error when wanting to update database :" . $e->getMessage());
$em->rollback();
}
}
private function postSubDefinition(Client $exposeClient, $token, $path, $assetId, $subdefName, $isPreview = false, $isThumbnail = false)
{
return $exposeClient->post('/sub-definitions', [
'headers' => [
'Authorization' => 'Bearer ' .$token
],
'multipart' => [
[
'name' => 'file',
'contents' => fopen($path, 'r')
],
[
'name' => 'asset_id',
'contents' => $assetId,
],
[
'name' => 'name',
'contents' => $subdefName
],
[
'name' => 'use_as_preview',
'contents' => $isPreview
],
[
'name' => 'use_as_thumbnail',
'contents' => $isThumbnail
]
]
]);
}
}