Files
Phraseanet/lib/Alchemy/Phrasea/Controller/Api/V3/V3SearchController.php
jygaulier 01791108fa PHRAS-4151_stories-reference-in-api (#4602)
* add `include[]=results.records.stories` to searchV3 api

* cleanup

* cleanup

* doc
2025-05-15 19:28:13 +02:00

560 lines
20 KiB
PHP

<?php
namespace Alchemy\Phrasea\Controller\Api\V3;
use Alchemy\Phrasea\Application\Helper\DispatcherAware;
use Alchemy\Phrasea\Application\Helper\JsonBodyAware;
use Alchemy\Phrasea\Collection\Reference\CollectionReference;
use Alchemy\Phrasea\Controller\Api\InstanceIdAware;
use Alchemy\Phrasea\Controller\Api\Result;
use Alchemy\Phrasea\Controller\Controller;
use Alchemy\Phrasea\Databox\DataboxGroupable;
use Alchemy\Phrasea\Databox\Record\LegacyRecordRepository;
use Alchemy\Phrasea\Databox\Subdef\MediaSubdefService;
use Alchemy\Phrasea\Fractal\CallbackTransformer;
use Alchemy\Phrasea\Fractal\IncludeResolver;
use Alchemy\Phrasea\Fractal\SearchResultTransformerResolver;
use Alchemy\Phrasea\Fractal\TraceableArraySerializer;
use Alchemy\Phrasea\Media\MediaSubDefinitionUrlGenerator;
use Alchemy\Phrasea\Model\Manipulator\UserManipulator;
use Alchemy\Phrasea\Model\RecordReferenceInterface;
use Alchemy\Phrasea\Record\RecordCollection;
use Alchemy\Phrasea\Record\RecordReferenceCollection;
use Alchemy\Phrasea\Search\CaptionView;
use Alchemy\Phrasea\Search\PermalinkTransformer;
use Alchemy\Phrasea\Search\PermalinkView;
use Alchemy\Phrasea\Search\RecordTransformer;
use Alchemy\Phrasea\Search\RecordView;
use Alchemy\Phrasea\Search\SearchResultView;
use Alchemy\Phrasea\Search\StoryView;
use Alchemy\Phrasea\Search\SubdefTransformer;
use Alchemy\Phrasea\Search\SubdefView;
use Alchemy\Phrasea\Search\TechnicalDataTransformer;
use Alchemy\Phrasea\Search\TechnicalDataView;
use Alchemy\Phrasea\Search\V3SearchCompositeResultTransformer;
use Alchemy\Phrasea\Search\V3SearchResultTransformer;
use Alchemy\Phrasea\Search\V3StoryTransformer;
use Alchemy\Phrasea\SearchEngine\SearchEngineInterface;
use Alchemy\Phrasea\SearchEngine\SearchEngineLogger;
use Alchemy\Phrasea\SearchEngine\SearchEngineOptions;
use Alchemy\Phrasea\SearchEngine\SearchEngineResult;
use Alchemy\Phrasea\Utilities\Stopwatch;
use caption_record;
use League\Fractal\Manager as FractalManager;
use League\Fractal\Resource\Item;
use media_Permalink_Adapter;
use media_subdef;
use record_adapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class V3SearchController extends Controller
{
use JsonBodyAware;
use DispatcherAware;
use InstanceIdAware;
/**
* Search for results
*
* @param Request $request
*
* @return Response
*/
public function searchAction(Request $request)
{
$stopwatch = new Stopwatch("controller");
$subdefTransformer = new SubdefTransformer($this->app['acl'], $this->getAuthenticatedUser(), new PermalinkTransformer());
$technicalDataTransformer = new TechnicalDataTransformer();
$recordTransformer = new RecordTransformer($subdefTransformer, $technicalDataTransformer, $this->getResourceIdResolver());
$storyTransformer = new V3StoryTransformer($recordTransformer);
$recordTransformer->setStoryTransformer($storyTransformer);
$compositeTransformer = new V3SearchCompositeResultTransformer($recordTransformer, $storyTransformer);
$searchTransformer = new V3SearchResultTransformer($compositeTransformer);
$transformerResolver = new SearchResultTransformerResolver([
'' => $searchTransformer,
'results' => $compositeTransformer,
'facets' => new CallbackTransformer(),
'suggestions' => new CallbackTransformer(),
'results.stories' => $storyTransformer,
'results.stories.children' => $recordTransformer,
'results.records.stories' => $storyTransformer,
'results.records.stories.thumbnail' => $subdefTransformer,
'results.records.stories.metadata' => new CallbackTransformer(),
'results.stories.children.thumbnail' => $subdefTransformer,
'results.stories.children.technical_informations' => $technicalDataTransformer,
'results.stories.children.subdefs' => $subdefTransformer,
'results.stories.children.metadata' => new CallbackTransformer(),
'results.stories.children.status' => new CallbackTransformer(),
'results.stories.children.caption' => new CallbackTransformer(),
'results.records' => $recordTransformer,
'results.records.thumbnail' => $subdefTransformer,
'results.stories.thumbnail' => $subdefTransformer,
'results.records.technical_informations' => $technicalDataTransformer,
// 'results.stories.technical_informations' => $technicalDataTransformer, // no ti on story
'results.records.subdefs' => $subdefTransformer,
'results.stories.subdefs' => $subdefTransformer,
'results.records.metadata' => new CallbackTransformer(),
'results.stories.metadata' => new CallbackTransformer(),
'results.records.metadatas' => new CallbackTransformer(),
'results.stories.metadatas' => new CallbackTransformer(),
'results.records.caption' => new CallbackTransformer(),
'results.stories.caption' => new CallbackTransformer(),
'results.records.status' => new CallbackTransformer(),
'results.stories.status' => new CallbackTransformer(),
]);
$includeResolver = new IncludeResolver($transformerResolver);
$fractal = new FractalManager();
$fractal->setSerializer(new TraceableArraySerializer($this->app['dispatcher']));
// and push everything back to fractal
$fractal->parseIncludes($this->getIncludes($request));
$stopwatch->lap("boot");
$result = $this->doSearch($request);
$stopwatch->lap("doSearch");
$story_children_limit = null;
// if searching stories
if ($request->get('search_type') == 1) {
$story_children_limit = (int)$request->get('story_children_limit') ?: 10;
}
$searchView = $this->buildSearchView(
$result,
$includeResolver->resolve($fractal),
$this->resolveSubdefUrlTTL($request),
$story_children_limit
);
$stopwatch->lap("buildSearchView");
$ret = $fractal->createData(new Item($searchView, $searchTransformer))->toArray();
$stopwatch->lap("fractal");
return Result::create($request, $ret)->createResponse([$stopwatch]);
}
/**
* Returns requested includes
*
* @param Request $request
* @return string[]
*/
private function getIncludes(Request $request)
{
// a local fractal manager will help to smartly parse the request parameters.
$fractal = new FractalManager();
// first, get includes from request
//
$fractal->parseIncludes($request->get('include', []));
$includes = $fractal->getRequestedIncludes();
if ($request->attributes->get('_extended', false)) {
// if ($request->get('search_type') != SearchEngineOptions::RECORD_STORY) {
if(in_array('results.stories.children', $includes)) {
$includes = array_merge($includes, [
'results.stories.caption',
'results.stories.metadata',
'results.stories.children.subdefs',
'results.stories.children.metadata',
'results.stories.children.caption',
'results.stories.children.status'
]);
}
else {
$includes = array_merge($includes, [
'results.stories.caption'
]);
}
$includes = array_merge($includes, [
'results.records.subdefs',
'results.records.metadata',
'results.records.caption',
'results.records.status'
]);
}
// push back to fractal (it will keep values uniques)
//
$fractal->parseIncludes($includes);
// finally get the result
//
return $fractal->getRequestedIncludes();
}
/**
* @param SearchEngineResult $result
* @param string[] $includes
* @param int $urlTTL
* @param int|null $story_max_records
* @return SearchResultView
*/
private function buildSearchView(SearchEngineResult $result, array $includes, $urlTTL, $story_max_records = null)
{
$references = new RecordReferenceCollection($result->getResults());
$records = new RecordCollection();
$stories = new RecordCollection();
foreach ($references->toRecords($this->getApplicationBox()) as $record) {
if ($record->isStory()) {
$stories[$record->getId()] = $record;
} else {
$records[$record->getId()] = $record;
}
}
$resultView = new SearchResultView($result);
if ($stories->count() > 0) {
$user = $this->getAuthenticatedUser();
$children = [];
$childrenCounts = [];
// todo : refacto to remove over-usage of array_map, array_combine, array_flip etc.
//
foreach ($stories->getDataboxIds() as $databoxId) {
$storyIds = $stories->getDataboxRecordIds($databoxId);
/** @var LegacyRecordRepository $repo */
$repo = $this->findDataboxById($databoxId)->getRecordRepository();
// nb : findChildren() and getChildrenCounts() acts on MULTIPLE story-ids in single sql
//
$childrenCounts[$databoxId] = $repo->getChildrenCounts($storyIds, $user);
// search children only if needed
//
if(in_array('results.stories.children', $includes, true)) {
$selections = $repo->findChildren($storyIds, $user, 0, $story_max_records);
$children[$databoxId] = array_combine($storyIds, $selections);
}
}
/** @var StoryView[] $storyViews */
$storyViews = [];
/** @var RecordView[] $childrenViews */
$childrenViews = [];
foreach ($stories as $index => $story) {
$storyView = new StoryView($story);
// populate children only if needed
//
if(in_array('results.stories.children', $includes, true)) {
$selection = $children[$story->getDataboxId()][$story->getRecordId()];
$childrenView = $this->buildRecordViews($selection);
foreach ($childrenView as $view) {
$childrenViews[spl_object_hash($view)] = $view;
}
$storyView->setChildren($childrenView);
}
$storyView->setData('childrenOffset', 0);
$storyView->setData('childrenLimit', $story_max_records);
$storyView->setData('childrenCount', $childrenCounts[$story->getDataboxId()][$story->getRecordId()]);
$storyViews[$index] = $storyView;
}
if (in_array('results.stories.thumbnail', $includes, true)) {
$subdefViews = $this->buildSubdefsViews($stories, ['thumbnail'], $urlTTL);
foreach ($storyViews as $index => $storyView) {
$storyView->setSubdefs($subdefViews[$index]);
}
}
if (in_array('results.stories.metadata', $includes, true) ||
in_array('results.stories.caption', $includes, true)) {
$captions = $this->app['service.caption']->findByReferenceCollection($stories);
$canSeeBusiness = $this->retrieveSeeBusinessPerDatabox($stories);
$this->buildCaptionViews($storyViews, $captions, $canSeeBusiness);
}
$allChildren = new RecordCollection();
foreach ($childrenViews as $index => $childrenView) {
$allChildren[$index] = $childrenView->getRecord();
}
$names = in_array('results.stories.children.subdefs', $includes, true) ? null : ['thumbnail'];
$subdefViews = $this->buildSubdefsViews($allChildren, $names, $urlTTL);
$technicalDatasets = $this->app['service.technical_data']->fetchRecordsTechnicalData($allChildren);
foreach ($childrenViews as $index => $recordView) {
$recordView->setSubdefs($subdefViews[$index]);
$recordView->setTechnicalDataView(new TechnicalDataView($technicalDatasets[$index]));
}
if (array_intersect($includes, ['results.stories.children.metadata', 'results.stories.children.caption'])) {
$captions = $this->app['service.caption']->findByReferenceCollection($allChildren);
$canSeeBusiness = $this->retrieveSeeBusinessPerDatabox($allChildren);
$this->buildCaptionViews($childrenViews, $captions, $canSeeBusiness);
}
$resultView->setStories($storyViews);
}
if ($records->count() > 0) {
$names = in_array('results.records.subdefs', $includes, true) ? null : ['thumbnail'];
$recordViews = $this->buildRecordViews($records);
$subdefViews = $this->buildSubdefsViews($records, $names, $urlTTL);
$technicalDatasets = $this->app['service.technical_data']->fetchRecordsTechnicalData($records);
foreach ($recordViews as $index => $recordView) {
$recordView->setSubdefs($subdefViews[$index]);
$recordView->setTechnicalDataView(new TechnicalDataView($technicalDatasets[$index]));
}
if (array_intersect($includes, ['results.records.metadata', 'results.records.caption'])) {
$captions = $this->app['service.caption']->findByReferenceCollection($records);
$canSeeBusiness = $this->retrieveSeeBusinessPerDatabox($records);
$this->buildCaptionViews($recordViews, $captions, $canSeeBusiness);
}
$resultView->setRecords($recordViews);
}
return $resultView;
}
/**
* @param Request $request
* @return SearchEngineResult
*/
private function doSearch(Request $request)
{
list($offset, $limit) = V3ResultHelpers::paginationFromRequest($request);
$options = SearchEngineOptions::fromRequest($this->app, $request);
$options->setFirstResult($offset);
$options->setMaxResults($limit);
$this->getSearchEngine()->resetCache();
$search_result = $this->getSearchEngine()->query((string)$request->get('query'), $options);
$this->getUserManipulator()->logQuery($this->getAuthenticatedUser(), $search_result->getQueryText());
// log array of collectionIds (from $options) for each databox
$collectionsReferencesByDatabox = $options->getCollectionsReferencesByDatabox();
foreach ($collectionsReferencesByDatabox as $sbid => $references) {
$databox = $this->findDataboxById($sbid);
$collectionsIds = array_map(function(CollectionReference $ref){return $ref->getCollectionId();}, $references);
$this->getSearchEngineLogger()->log($databox, $search_result->getQueryText(), $search_result->getTotal(), $collectionsIds);
}
$this->getSearchEngine()->clearCache();
return $search_result;
}
/**
* @return SearchEngineInterface
*/
private function getSearchEngine()
{
return $this->app['phraseanet.SE'];
}
/**
* @return UserManipulator
*/
private function getUserManipulator()
{
return $this->app['manipulator.user'];
}
/**
* @return SearchEngineLogger
*/
private function getSearchEngineLogger()
{
return $this->app['phraseanet.SE.logger'];
}
/**
* @param Request $request
* @return int
*/
private function resolveSubdefUrlTTL(Request $request)
{
$urlTTL = $request->query->get('subdef_url_ttl');
if (null !== $urlTTL) {
return (int)$urlTTL;
}
return $this->getConf()->get(['registry', 'general', 'default-subdef-url-ttl']);
}
/**
* @param RecordCollection|record_adapter[] $references
* @return RecordView[]
*/
private function buildRecordViews($references)
{
if (!$references instanceof RecordCollection) {
$references = new RecordCollection($references);
}
$recordViews = [];
foreach ($references as $index => $record) {
$recordViews[$index] = new RecordView($record);
}
return $recordViews;
}
/**
* @param RecordReferenceInterface[]|RecordReferenceCollection|DataboxGroupable $references
* @param array|null $names
* @param int $urlTTL
* @return SubdefView[][]
*/
private function buildSubdefsViews($references, array $names = null, $urlTTL)
{
/** @var MediaSubdefService $MediaSubdefService */
$MediaSubdefService = $this->app['service.media_subdef'];
$subdefGroups = $MediaSubdefService
->findSubdefsByRecordReferenceFromCollection($references, $names);
$fakeSubdefs = [];
foreach ($subdefGroups as $index => $subdefGroup) {
if (!isset($subdefGroup['thumbnail'])) {
$fakeSubdef = new media_subdef($this->app, $references[$index], 'thumbnail', true, []);
$fakeSubdefs[spl_object_hash($fakeSubdef)] = $fakeSubdef;
$subdefGroups[$index]['thumbnail'] = $fakeSubdef;
}
}
$allSubdefs = $this->mergeGroupsIntoOneList($subdefGroups);
$allPermalinks = media_Permalink_Adapter::getMany(
$this->app,
array_filter($allSubdefs, function (media_subdef $subdef) use ($fakeSubdefs) {
return !isset($fakeSubdefs[spl_object_hash($subdef)]);
})
);
/** @var MediaSubDefinitionUrlGenerator $urlGenerator */
$urlGenerator = $this->app['media_accessor.subdef_url_generator'];
$urls = $urlGenerator->generateMany($this->getAuthenticatedUser(), $allSubdefs, $urlTTL);
$subdefViews = [];
/** @var media_subdef $subdef */
foreach ($allSubdefs as $index => $subdef) {
$subdefView = new SubdefView($subdef);
if (isset($allPermalinks[$index])) {
$subdefView->setPermalinkView(new PermalinkView($allPermalinks[$index]));
}
$subdefView->setUrl($urls[$index]);
$subdefView->setUrlTTL($urlTTL);
$subdefViews[spl_object_hash($subdef)] = $subdefView;
}
$reorderedGroups = [];
/** @var media_subdef[] $subdefGroup */
foreach ($subdefGroups as $index => $subdefGroup) {
$reordered = [];
foreach ($subdefGroup as $subdef) {
$reordered[] = $subdefViews[spl_object_hash($subdef)];
}
$reorderedGroups[$index] = $reordered;
}
return $reorderedGroups;
}
/**
* @param array $groups
* @return array|mixed
*/
private function mergeGroupsIntoOneList(array $groups)
{
// Strips keys from the internal array
array_walk($groups, function (array &$group) {
$group = array_values($group);
});
if ($groups) {
return call_user_func_array('array_merge', $groups);
}
return [];
}
/**
* @param RecordReferenceInterface[]|DataboxGroupable $references
* @return array<int, bool>
*/
private function retrieveSeeBusinessPerDatabox($references)
{
if (!$references instanceof DataboxGroupable) {
$references = new RecordReferenceCollection($references);
}
$acl = $this->getAclForUser();
$canSeeBusiness = [];
foreach ($references->getDataboxIds() as $databoxId) {
$canSeeBusiness[$databoxId] = $acl->can_see_business_fields($this->findDataboxById($databoxId));
}
$rights = [];
foreach ($references as $index => $reference) {
$rights[$index] = $canSeeBusiness[$reference->getDataboxId()];
}
return $rights;
}
/**
* @param RecordView[] $recordViews
* @param caption_record[] $captions
* @param bool[] $canSeeBusiness
*/
private function buildCaptionViews($recordViews, $captions, $canSeeBusiness)
{
foreach ($recordViews as $index => $recordView) {
$caption = $captions[$index];
$captionView = new CaptionView($caption);
$captionView->setFields($caption->get_fields(null, isset($canSeeBusiness[$index]) && (bool)$canSeeBusiness[$index]));
$recordView->setCaption($captionView);
}
}
}