diff --git a/src/applications/diffusion/query/DiffusionPullLogSearchEngine.php b/src/applications/diffusion/query/DiffusionPullLogSearchEngine.php index 8d6102d4eb..fc7cee52ce 100644 --- a/src/applications/diffusion/query/DiffusionPullLogSearchEngine.php +++ b/src/applications/diffusion/query/DiffusionPullLogSearchEngine.php @@ -1,166 +1,158 @@ newQuery(); if ($map['repositoryPHIDs']) { $query->withRepositoryPHIDs($map['repositoryPHIDs']); } if ($map['pullerPHIDs']) { $query->withPullerPHIDs($map['pullerPHIDs']); } return $query; } protected function buildCustomSearchFields() { return array( id(new PhabricatorSearchDatasourceField()) ->setDatasource(new DiffusionRepositoryDatasource()) ->setKey('repositoryPHIDs') ->setAliases(array('repository', 'repositories', 'repositoryPHID')) ->setLabel(pht('Repositories')) ->setDescription( pht('Search for pull logs for specific repositories.')), id(new PhabricatorUsersSearchField()) ->setKey('pullerPHIDs') ->setAliases(array('puller', 'pullers', 'pullerPHID')) ->setLabel(pht('Pullers')) ->setDescription( pht('Search for pull logs by specific users.')), ); } protected function newExportFields() { return array( - id(new PhabricatorIDExportField()) - ->setKey('id') - ->setLabel(pht('ID')), - id(new PhabricatorPHIDExportField()) - ->setKey('phid') - ->setLabel(pht('PHID')), id(new PhabricatorPHIDExportField()) ->setKey('repositoryPHID') ->setLabel(pht('Repository PHID')), id(new PhabricatorStringExportField()) ->setKey('repository') ->setLabel(pht('Repository')), id(new PhabricatorPHIDExportField()) ->setKey('pullerPHID') ->setLabel(pht('Puller PHID')), id(new PhabricatorStringExportField()) ->setKey('puller') ->setLabel(pht('Puller')), id(new PhabricatorStringExportField()) ->setKey('protocol') ->setLabel(pht('Protocol')), id(new PhabricatorStringExportField()) ->setKey('result') ->setLabel(pht('Result')), id(new PhabricatorIntExportField()) ->setKey('code') ->setLabel(pht('Code')), id(new PhabricatorEpochExportField()) ->setKey('date') ->setLabel(pht('Date')), ); } - public function newExport(array $events) { + protected function newExportData(array $events) { $viewer = $this->requireViewer(); $phids = array(); foreach ($events as $event) { if ($event->getPullerPHID()) { $phids[] = $event->getPullerPHID(); } } $handles = $viewer->loadHandles($phids); $export = array(); foreach ($events as $event) { $repository = $event->getRepository(); if ($repository) { $repository_phid = $repository->getPHID(); $repository_name = $repository->getDisplayName(); } else { $repository_phid = null; $repository_name = null; } $puller_phid = $event->getPullerPHID(); if ($puller_phid) { $puller_name = $handles[$puller_phid]->getName(); } else { $puller_name = null; } $export[] = array( - 'id' => $event->getID(), - 'phid' => $event->getPHID(), 'repositoryPHID' => $repository_phid, 'repository' => $repository_name, 'pullerPHID' => $puller_phid, 'puller' => $puller_name, 'protocol' => $event->getRemoteProtocol(), 'result' => $event->getResultType(), 'code' => $event->getResultCode(), 'date' => $event->getEpoch(), ); } return $export; } protected function getURI($path) { return '/diffusion/pulllog/'.$path; } protected function getBuiltinQueryNames() { return array( 'all' => pht('All Pull Logs'), ); } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); switch ($query_key) { case 'all': return $query; } return parent::buildSavedQueryFromBuiltin($query_key); } protected function renderResultList( array $logs, PhabricatorSavedQuery $query, array $handles) { $table = id(new DiffusionPullLogListView()) ->setViewer($this->requireViewer()) ->setLogs($logs); return id(new PhabricatorApplicationSearchResultView()) ->setTable($table); } } diff --git a/src/applications/people/query/PhabricatorPeopleSearchEngine.php b/src/applications/people/query/PhabricatorPeopleSearchEngine.php index db2256a8b8..e0e1b5070e 100644 --- a/src/applications/people/query/PhabricatorPeopleSearchEngine.php +++ b/src/applications/people/query/PhabricatorPeopleSearchEngine.php @@ -1,360 +1,352 @@ needPrimaryEmail(true) ->needProfileImage(true); } protected function buildCustomSearchFields() { $fields = array( id(new PhabricatorSearchStringListField()) ->setLabel(pht('Usernames')) ->setKey('usernames') ->setAliases(array('username')) ->setDescription(pht('Find users by exact username.')), id(new PhabricatorSearchTextField()) ->setLabel(pht('Name Contains')) ->setKey('nameLike') ->setDescription( pht('Find users whose usernames contain a substring.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Administrators')) ->setKey('isAdmin') ->setOptions( pht('(Show All)'), pht('Show Only Administrators'), pht('Hide Administrators')) ->setDescription( pht( 'Pass true to find only administrators, or false to omit '. 'administrators.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Disabled')) ->setKey('isDisabled') ->setOptions( pht('(Show All)'), pht('Show Only Disabled Users'), pht('Hide Disabled Users')) ->setDescription( pht( 'Pass true to find only disabled users, or false to omit '. 'disabled users.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Bots')) ->setKey('isBot') ->setAliases(array('isSystemAgent')) ->setOptions( pht('(Show All)'), pht('Show Only Bots'), pht('Hide Bots')) ->setDescription( pht( 'Pass true to find only bots, or false to omit bots.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Mailing Lists')) ->setKey('isMailingList') ->setOptions( pht('(Show All)'), pht('Show Only Mailing Lists'), pht('Hide Mailing Lists')) ->setDescription( pht( 'Pass true to find only mailing lists, or false to omit '. 'mailing lists.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Needs Approval')) ->setKey('needsApproval') ->setOptions( pht('(Show All)'), pht('Show Only Unapproved Users'), pht('Hide Unapproved Users')) ->setDescription( pht( 'Pass true to find only users awaiting administrative approval, '. 'or false to omit these users.')), ); $viewer = $this->requireViewer(); if ($viewer->getIsAdmin()) { $fields[] = id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Has MFA')) ->setKey('mfa') ->setOptions( pht('(Show All)'), pht('Show Only Users With MFA'), pht('Hide Users With MFA')) ->setDescription( pht( 'Pass true to find only users who are enrolled in MFA, or false '. 'to omit these users.')); } $fields[] = id(new PhabricatorSearchDateField()) ->setKey('createdStart') ->setLabel(pht('Joined After')) ->setDescription( pht('Find user accounts created after a given time.')); $fields[] = id(new PhabricatorSearchDateField()) ->setKey('createdEnd') ->setLabel(pht('Joined Before')) ->setDescription( pht('Find user accounts created before a given time.')); return $fields; } protected function getDefaultFieldOrder() { return array( '...', 'createdStart', 'createdEnd', ); } protected function buildQueryFromParameters(array $map) { $query = $this->newQuery(); $viewer = $this->requireViewer(); // If the viewer can't browse the user directory, restrict the query to // just the user's own profile. This is a little bit silly, but serves to // restrict users from creating a dashboard panel which essentially just // contains a user directory anyway. $can_browse = PhabricatorPolicyFilter::hasCapability( $viewer, $this->getApplication(), PeopleBrowseUserDirectoryCapability::CAPABILITY); if (!$can_browse) { $query->withPHIDs(array($viewer->getPHID())); } if ($map['usernames']) { $query->withUsernames($map['usernames']); } if ($map['nameLike']) { $query->withNameLike($map['nameLike']); } if ($map['isAdmin'] !== null) { $query->withIsAdmin($map['isAdmin']); } if ($map['isDisabled'] !== null) { $query->withIsDisabled($map['isDisabled']); } if ($map['isMailingList'] !== null) { $query->withIsMailingList($map['isMailingList']); } if ($map['isBot'] !== null) { $query->withIsSystemAgent($map['isBot']); } if ($map['needsApproval'] !== null) { $query->withIsApproved(!$map['needsApproval']); } if (idx($map, 'mfa') !== null) { $viewer = $this->requireViewer(); if (!$viewer->getIsAdmin()) { throw new PhabricatorSearchConstraintException( pht( 'The "Has MFA" query constraint may only be used by '. 'administrators, to prevent attackers from using it to target '. 'weak accounts.')); } $query->withIsEnrolledInMultiFactor($map['mfa']); } if ($map['createdStart']) { $query->withDateCreatedAfter($map['createdStart']); } if ($map['createdEnd']) { $query->withDateCreatedBefore($map['createdEnd']); } return $query; } protected function getURI($path) { return '/people/'.$path; } protected function getBuiltinQueryNames() { $names = array( 'active' => pht('Active'), 'all' => pht('All'), ); $viewer = $this->requireViewer(); if ($viewer->getIsAdmin()) { $names['approval'] = pht('Approval Queue'); } return $names; } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); switch ($query_key) { case 'all': return $query; case 'active': return $query ->setParameter('isDisabled', false); case 'approval': return $query ->setParameter('needsApproval', true) ->setParameter('isDisabled', false); } return parent::buildSavedQueryFromBuiltin($query_key); } protected function renderResultList( array $users, PhabricatorSavedQuery $query, array $handles) { assert_instances_of($users, 'PhabricatorUser'); $request = $this->getRequest(); $viewer = $this->requireViewer(); $list = new PHUIObjectItemListView(); $is_approval = ($query->getQueryKey() == 'approval'); foreach ($users as $user) { $primary_email = $user->loadPrimaryEmail(); if ($primary_email && $primary_email->getIsVerified()) { $email = pht('Verified'); } else { $email = pht('Unverified'); } $item = new PHUIObjectItemView(); $item->setHeader($user->getFullName()) ->setHref('/p/'.$user->getUsername().'/') ->addAttribute(phabricator_datetime($user->getDateCreated(), $viewer)) ->addAttribute($email) ->setImageURI($user->getProfileImageURI()); if ($is_approval && $primary_email) { $item->addAttribute($primary_email->getAddress()); } if ($user->getIsDisabled()) { $item->addIcon('fa-ban', pht('Disabled')); $item->setDisabled(true); } if (!$is_approval) { if (!$user->getIsApproved()) { $item->addIcon('fa-clock-o', pht('Needs Approval')); } } if ($user->getIsAdmin()) { $item->addIcon('fa-star', pht('Admin')); } if ($user->getIsSystemAgent()) { $item->addIcon('fa-desktop', pht('Bot')); } if ($user->getIsMailingList()) { $item->addIcon('fa-envelope-o', pht('Mailing List')); } if ($viewer->getIsAdmin()) { if ($user->getIsEnrolledInMultiFactor()) { $item->addIcon('fa-lock', pht('Has MFA')); } } if ($viewer->getIsAdmin()) { $user_id = $user->getID(); if ($is_approval) { $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-ban') ->setName(pht('Disable')) ->setWorkflow(true) ->setHref($this->getApplicationURI('disapprove/'.$user_id.'/'))); $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-thumbs-o-up') ->setName(pht('Approve')) ->setWorkflow(true) ->setHref($this->getApplicationURI('approve/'.$user_id.'/'))); } } $list->addItem($item); } $result = new PhabricatorApplicationSearchResultView(); $result->setObjectList($list); $result->setNoDataString(pht('No accounts found.')); return $result; } protected function newExportFields() { return array( - id(new PhabricatorIDExportField()) - ->setKey('id') - ->setLabel(pht('ID')), - id(new PhabricatorPHIDExportField()) - ->setKey('phid') - ->setLabel(pht('PHID')), id(new PhabricatorStringExportField()) ->setKey('username') ->setLabel(pht('Username')), id(new PhabricatorStringExportField()) ->setKey('realName') ->setLabel(pht('Real Name')), id(new PhabricatorEpochExportField()) ->setKey('created') ->setLabel(pht('Date Created')), ); } - public function newExport(array $users) { + protected function newExportData(array $users) { $viewer = $this->requireViewer(); $export = array(); foreach ($users as $user) { $export[] = array( - 'id' => $user->getID(), - 'phid' => $user->getPHID(), 'username' => $user->getUsername(), 'realName' => $user->getRealName(), 'created' => $user->getDateCreated(), ); } return $export; } } diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index d3b05d1e3e..037a626f26 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -1,917 +1,906 @@ preface = $preface; return $this; } public function getPreface() { return $this->preface; } public function setQueryKey($query_key) { $this->queryKey = $query_key; return $this; } protected function getQueryKey() { return $this->queryKey; } public function setNavigation(AphrontSideNavFilterView $navigation) { $this->navigation = $navigation; return $this; } protected function getNavigation() { return $this->navigation; } public function setSearchEngine( PhabricatorApplicationSearchEngine $search_engine) { $this->searchEngine = $search_engine; return $this; } protected function getSearchEngine() { return $this->searchEngine; } protected function validateDelegatingController() { $parent = $this->getDelegatingController(); if (!$parent) { throw new Exception( pht('You must delegate to this controller, not invoke it directly.')); } $engine = $this->getSearchEngine(); if (!$engine) { throw new PhutilInvalidStateException('setEngine'); } $engine->setViewer($this->getRequest()->getUser()); $parent = $this->getDelegatingController(); } public function processRequest() { $this->validateDelegatingController(); $query_action = $this->getRequest()->getURIData('queryAction'); if ($query_action == 'export') { return $this->processExportRequest(); } $key = $this->getQueryKey(); if ($key == 'edit') { return $this->processEditRequest(); } else { return $this->processSearchRequest(); } } private function processSearchRequest() { $parent = $this->getDelegatingController(); $request = $this->getRequest(); $user = $request->getUser(); $engine = $this->getSearchEngine(); $nav = $this->getNavigation(); if (!$nav) { $nav = $this->buildNavigation(); } if ($request->isFormPost()) { $saved_query = $engine->buildSavedQueryFromRequest($request); $engine->saveQuery($saved_query); return id(new AphrontRedirectResponse())->setURI( $engine->getQueryResultsPageURI($saved_query->getQueryKey()).'#R'); } $named_query = null; $run_query = true; $query_key = $this->queryKey; if ($this->queryKey == 'advanced') { $run_query = false; $query_key = $request->getStr('query'); } else if (!strlen($this->queryKey)) { $found_query_data = false; if ($request->isHTTPGet() || $request->isQuicksand()) { // If this is a GET request and it has some query data, don't // do anything unless it's only before= or after=. We'll build and // execute a query from it below. This allows external tools to build // URIs like "/query/?users=a,b". $pt_data = $request->getPassthroughRequestData(); $exempt = array( 'before' => true, 'after' => true, 'nux' => true, 'overheated' => true, ); foreach ($pt_data as $pt_key => $pt_value) { if (isset($exempt[$pt_key])) { continue; } $found_query_data = true; break; } } if (!$found_query_data) { // Otherwise, there's no query data so just run the user's default // query for this application. $query_key = $engine->getDefaultQueryKey(); } } if ($engine->isBuiltinQuery($query_key)) { $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); } else if ($query_key) { $saved_query = id(new PhabricatorSavedQueryQuery()) ->setViewer($user) ->withQueryKeys(array($query_key)) ->executeOne(); if (!$saved_query) { return new Aphront404Response(); } $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); } else { $saved_query = $engine->buildSavedQueryFromRequest($request); // Save the query to generate a query key, so "Save Custom Query..." and // other features like Maniphest's "Export..." work correctly. $engine->saveQuery($saved_query); } $nav->selectFilter( 'query/'.$saved_query->getQueryKey(), 'query/advanced'); $form = id(new AphrontFormView()) ->setUser($user) ->setAction($request->getPath()); $engine->buildSearchForm($form, $saved_query); $errors = $engine->getErrors(); if ($errors) { $run_query = false; } $submit = id(new AphrontFormSubmitControl()) ->setValue(pht('Search')); if ($run_query && !$named_query && $user->isLoggedIn()) { $save_button = id(new PHUIButtonView()) ->setTag('a') ->setHref('/search/edit/key/'.$saved_query->getQueryKey().'/') ->setText(pht('Save Query')) ->setIcon('fa-floppy-o'); $submit->addButton($save_button); } // TODO: A "Create Dashboard Panel" action goes here somewhere once // we sort out T5307. $form->appendChild($submit); $body = array(); if ($this->getPreface()) { $body[] = $this->getPreface(); } if ($named_query) { $title = $named_query->getQueryName(); } else { $title = pht('Advanced Search'); } $header = id(new PHUIHeaderView()) ->setHeader($title) ->setProfileHeader(true); $box = id(new PHUIObjectBoxView()) ->setHeader($header) ->addClass('application-search-results'); if ($run_query || $named_query) { $box->setShowHide( pht('Edit Query'), pht('Hide Query'), $form, $this->getApplicationURI('query/advanced/?query='.$query_key), (!$named_query ? true : false)); } else { $box->setForm($form); } $body[] = $box; $more_crumbs = null; if ($run_query) { $exec_errors = array(); $box->setAnchor( id(new PhabricatorAnchorView()) ->setAnchorName('R')); try { $engine->setRequest($request); $query = $engine->buildQueryFromSavedQuery($saved_query); $pager = $engine->newPagerForSavedQuery($saved_query); $pager->readFromRequest($request); $objects = $engine->executeQuery($query, $pager); $force_nux = $request->getBool('nux'); if (!$objects || $force_nux) { $nux_view = $this->renderNewUserView($engine, $force_nux); } else { $nux_view = null; } $is_overflowing = $pager->willShowPagingControls() && $engine->getResultBucket($saved_query); $force_overheated = $request->getBool('overheated'); $is_overheated = $query->getIsOverheated() || $force_overheated; if ($nux_view) { $box->appendChild($nux_view); } else { $list = $engine->renderResults($objects, $saved_query); if (!($list instanceof PhabricatorApplicationSearchResultView)) { throw new Exception( pht( 'SearchEngines must render a "%s" object, but this engine '. '(of class "%s") rendered something else.', 'PhabricatorApplicationSearchResultView', get_class($engine))); } if ($list->getObjectList()) { $box->setObjectList($list->getObjectList()); } if ($list->getTable()) { $box->setTable($list->getTable()); } if ($list->getInfoView()) { $box->setInfoView($list->getInfoView()); } if ($is_overflowing) { $box->appendChild($this->newOverflowingView()); } if ($list->getContent()) { $box->appendChild($list->getContent()); } if ($is_overheated) { $box->appendChild($this->newOverheatedView($objects)); } $result_header = $list->getHeader(); if ($result_header) { $box->setHeader($result_header); $header = $result_header; } $actions = $list->getActions(); if ($actions) { foreach ($actions as $action) { $header->addActionLink($action); } } $use_actions = $engine->newUseResultsActions($saved_query); // TODO: Eventually, modularize all this stuff. $builtin_use_actions = $this->newBuiltinUseActions(); if ($builtin_use_actions) { foreach ($builtin_use_actions as $builtin_use_action) { $use_actions[] = $builtin_use_action; } } if ($use_actions) { $use_dropdown = $this->newUseResultsDropdown( $saved_query, $use_actions); $header->addActionLink($use_dropdown); } $more_crumbs = $list->getCrumbs(); if ($pager->willShowPagingControls()) { $pager_box = id(new PHUIBoxView()) ->setColor(PHUIBoxView::GREY) ->addClass('application-search-pager') ->appendChild($pager); $body[] = $pager_box; } } } catch (PhabricatorTypeaheadInvalidTokenException $ex) { $exec_errors[] = pht( 'This query specifies an invalid parameter. Review the '. 'query parameters and correct errors.'); } catch (PhutilSearchQueryCompilerSyntaxException $ex) { $exec_errors[] = $ex->getMessage(); } catch (PhabricatorSearchConstraintException $ex) { $exec_errors[] = $ex->getMessage(); } // The engine may have encountered additional errors during rendering; // merge them in and show everything. foreach ($engine->getErrors() as $error) { $exec_errors[] = $error; } $errors = $exec_errors; } if ($errors) { $box->setFormErrors($errors, pht('Query Errors')); } $crumbs = $parent ->buildApplicationCrumbs() ->setBorder(true); if ($more_crumbs) { $query_uri = $engine->getQueryResultsPageURI($saved_query->getQueryKey()); $crumbs->addTextCrumb($title, $query_uri); foreach ($more_crumbs as $crumb) { $crumbs->addCrumb($crumb); } } else { $crumbs->addTextCrumb($title); } require_celerity_resource('application-search-view-css'); return $this->newPage() ->setApplicationMenu($this->buildApplicationMenu()) ->setTitle(pht('Query: %s', $title)) ->setCrumbs($crumbs) ->setNavigation($nav) ->addClass('application-search-view') ->appendChild($body); } private function processExportRequest() { $viewer = $this->getViewer(); $engine = $this->getSearchEngine(); $request = $this->getRequest(); if (!$this->canExport()) { return new Aphront404Response(); } $query_key = $this->getQueryKey(); if ($engine->isBuiltinQuery($query_key)) { $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); } else if ($query_key) { $saved_query = id(new PhabricatorSavedQueryQuery()) ->setViewer($viewer) ->withQueryKeys(array($query_key)) ->executeOne(); } else { $saved_query = null; } if (!$saved_query) { return new Aphront404Response(); } $cancel_uri = $engine->getQueryResultsPageURI($query_key); $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); if ($named_query) { $filename = $named_query->getQueryName(); } else { $filename = $engine->getResultTypeDescription(); } $filename = phutil_utf8_strtolower($filename); $filename = PhabricatorFile::normalizeFileName($filename); $formats = PhabricatorExportFormat::getAllEnabledExportFormats(); $format_options = mpull($formats, 'getExportFormatName'); $errors = array(); $e_format = null; if ($request->isFormPost()) { $format_key = $request->getStr('format'); $format = idx($formats, $format_key); if (!$format) { $e_format = pht('Invalid'); $errors[] = pht('Choose a valid export format.'); } if (!$errors) { $query = $engine->buildQueryFromSavedQuery($saved_query); // NOTE: We aren't reading the pager from the request. Exports always // affect the entire result set. $pager = $engine->newPagerForSavedQuery($saved_query); $pager->setPageSize(0x7FFFFFFF); $objects = $engine->executeQuery($query, $pager); $extension = $format->getFileExtension(); $mime_type = $format->getMIMEContentType(); $filename = $filename.'.'.$extension; $format = clone $format; $format->setViewer($viewer); $export_data = $engine->newExport($objects); - - if (count($export_data) !== count($objects)) { - throw new Exception( - pht( - 'Search engine exported the wrong number of objects, expected '. - '%s but got %s.', - phutil_count($objects), - phutil_count($export_data))); - } - $objects = array_values($objects); - $export_data = array_values($export_data); $field_list = $engine->newExportFieldList(); $field_list = mpull($field_list, null, 'getKey'); for ($ii = 0; $ii < count($objects); $ii++) { $format->addObject($objects[$ii], $field_list, $export_data[$ii]); } $export_result = $format->newFileData(); $file = PhabricatorFile::newFromFileData( $export_result, array( 'name' => $filename, 'authorPHID' => $viewer->getPHID(), 'ttl.relative' => phutil_units('15 minutes in seconds'), 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE, 'mime-type' => $mime_type, )); return $this->newDialog() ->setTitle(pht('Download Results')) ->appendParagraph( pht('Click the download button to download the exported data.')) ->addCancelButton($cancel_uri, pht('Done')) ->setSubmitURI($file->getDownloadURI()) ->setDisableWorkflowOnSubmit(true) ->addSubmitButton(pht('Download Data')); } } $export_form = id(new AphrontFormView()) ->setViewer($viewer) ->appendControl( id(new AphrontFormSelectControl()) ->setName('format') ->setLabel(pht('Format')) ->setError($e_format) ->setOptions($format_options)); return $this->newDialog() ->setTitle(pht('Export Results')) ->setErrors($errors) ->appendForm($export_form) ->addCancelButton($cancel_uri) ->addSubmitButton(pht('Continue')); } private function processEditRequest() { $parent = $this->getDelegatingController(); $request = $this->getRequest(); $viewer = $request->getUser(); $engine = $this->getSearchEngine(); $nav = $this->getNavigation(); if (!$nav) { $nav = $this->buildNavigation(); } $named_queries = $engine->loadAllNamedQueries(); $can_global = $viewer->getIsAdmin(); $groups = array( 'personal' => array( 'name' => pht('Personal Saved Queries'), 'items' => array(), 'edit' => true, ), 'global' => array( 'name' => pht('Global Saved Queries'), 'items' => array(), 'edit' => $can_global, ), ); foreach ($named_queries as $named_query) { if ($named_query->isGlobal()) { $group = 'global'; } else { $group = 'personal'; } $groups[$group]['items'][] = $named_query; } $default_key = $engine->getDefaultQueryKey(); $lists = array(); foreach ($groups as $group) { $lists[] = $this->newQueryListView( $group['name'], $group['items'], $default_key, $group['edit']); } $crumbs = $parent ->buildApplicationCrumbs() ->addTextCrumb(pht('Saved Queries'), $engine->getQueryManagementURI()) ->setBorder(true); $nav->selectFilter('query/edit'); $header = id(new PHUIHeaderView()) ->setHeader(pht('Saved Queries')) ->setProfileHeader(true); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter($lists); return $this->newPage() ->setApplicationMenu($this->buildApplicationMenu()) ->setTitle(pht('Saved Queries')) ->setCrumbs($crumbs) ->setNavigation($nav) ->appendChild($view); } private function newQueryListView( $list_name, array $named_queries, $default_key, $can_edit) { $engine = $this->getSearchEngine(); $viewer = $this->getViewer(); $list = id(new PHUIObjectItemListView()) ->setViewer($viewer); if ($can_edit) { $list_id = celerity_generate_unique_node_id(); $list->setID($list_id); Javelin::initBehavior( 'search-reorder-queries', array( 'listID' => $list_id, 'orderURI' => '/search/order/'.get_class($engine).'/', )); } foreach ($named_queries as $named_query) { $class = get_class($engine); $key = $named_query->getQueryKey(); $item = id(new PHUIObjectItemView()) ->setHeader($named_query->getQueryName()) ->setHref($engine->getQueryResultsPageURI($key)); if ($named_query->getIsDisabled()) { if ($can_edit) { $item->setDisabled(true); } else { // If an item is disabled and you don't have permission to edit it, // just skip it. continue; } } if ($can_edit) { if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) { $icon = 'fa-plus'; $disable_name = pht('Enable'); } else { $icon = 'fa-times'; if ($named_query->getIsBuiltin()) { $disable_name = pht('Disable'); } else { $disable_name = pht('Delete'); } } if ($named_query->getID()) { $disable_href = '/search/delete/id/'.$named_query->getID().'/'; } else { $disable_href = '/search/delete/key/'.$key.'/'.$class.'/'; } $item->addAction( id(new PHUIListItemView()) ->setIcon($icon) ->setHref($disable_href) ->setRenderNameAsTooltip(true) ->setName($disable_name) ->setWorkflow(true)); } $default_disabled = $named_query->getIsDisabled(); $default_icon = 'fa-thumb-tack'; if ($default_key === $key) { $default_color = 'green'; } else { $default_color = null; } $item->addAction( id(new PHUIListItemView()) ->setIcon("{$default_icon} {$default_color}") ->setHref('/search/default/'.$key.'/'.$class.'/') ->setRenderNameAsTooltip(true) ->setName(pht('Make Default')) ->setWorkflow(true) ->setDisabled($default_disabled)); if ($can_edit) { if ($named_query->getIsBuiltin()) { $edit_icon = 'fa-lock lightgreytext'; $edit_disabled = true; $edit_name = pht('Builtin'); $edit_href = null; } else { $edit_icon = 'fa-pencil'; $edit_disabled = false; $edit_name = pht('Edit'); $edit_href = '/search/edit/id/'.$named_query->getID().'/'; } $item->addAction( id(new PHUIListItemView()) ->setIcon($edit_icon) ->setHref($edit_href) ->setRenderNameAsTooltip(true) ->setName($edit_name) ->setDisabled($edit_disabled)); } $item->setGrippable($can_edit); $item->addSigil('named-query'); $item->setMetadata( array( 'queryKey' => $named_query->getQueryKey(), )); $list->addItem($item); } $list->setNoDataString(pht('No saved queries.')); return id(new PHUIObjectBoxView()) ->setHeaderText($list_name) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList($list); } public function buildApplicationMenu() { $menu = $this->getDelegatingController() ->buildApplicationMenu(); if ($menu instanceof PHUIApplicationMenuView) { $menu->setSearchEngine($this->getSearchEngine()); } return $menu; } private function buildNavigation() { $viewer = $this->getViewer(); $engine = $this->getSearchEngine(); $nav = id(new AphrontSideNavFilterView()) ->setUser($viewer) ->setBaseURI(new PhutilURI($this->getApplicationURI())); $engine->addNavigationItems($nav->getMenu()); return $nav; } private function renderNewUserView( PhabricatorApplicationSearchEngine $engine, $force_nux) { // Don't render NUX if the user has clicked away from the default page. if (strlen($this->getQueryKey())) { return null; } // Don't put NUX in panels because it would be weird. if ($engine->isPanelContext()) { return null; } // Try to render the view itself first, since this should be very cheap // (just returning some text). $nux_view = $engine->renderNewUserView(); if (!$nux_view) { return null; } $query = $engine->newQuery(); if (!$query) { return null; } // Try to load any object at all. If we can, the application has seen some // use so we just render the normal view. if (!$force_nux) { $object = $query ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setLimit(1) ->execute(); if ($object) { return null; } } return $nux_view; } private function newUseResultsDropdown( PhabricatorSavedQuery $query, array $dropdown_items) { $viewer = $this->getViewer(); $action_list = id(new PhabricatorActionListView()) ->setViewer($viewer); foreach ($dropdown_items as $dropdown_item) { $action_list->addAction($dropdown_item); } return id(new PHUIButtonView()) ->setTag('a') ->setHref('#') ->setText(pht('Use Results')) ->setIcon('fa-bars') ->setDropdownMenu($action_list) ->addClass('dropdown'); } private function newOverflowingView() { $message = pht( 'The query matched more than one page of results. Results are '. 'paginated before bucketing, so later pages may contain additional '. 'results in any bucket.'); return id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setFlush(true) ->setTitle(pht('Buckets Overflowing')) ->setErrors( array( $message, )); } private function newOverheatedView(array $results) { if ($results) { $message = pht( 'Most objects matching your query are not visible to you, so '. 'filtering results is taking a long time. Only some results are '. 'shown. Refine your query to find results more quickly.'); } else { $message = pht( 'Most objects matching your query are not visible to you, so '. 'filtering results is taking a long time. Refine your query to '. 'find results more quickly.'); } return id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setFlush(true) ->setTitle(pht('Query Overheated')) ->setErrors( array( $message, )); } private function newBuiltinUseActions() { $actions = array(); $request = $this->getRequest(); $viewer = $request->getUser(); $is_dev = PhabricatorEnv::getEnvConfig('phabricator.developer-mode'); $engine = $this->getSearchEngine(); $engine_class = get_class($engine); $query_key = $this->getQueryKey(); if (!$query_key) { $query_key = $engine->getDefaultQueryKey(); } $can_use = $engine->canUseInPanelContext(); $is_installed = PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorDashboardApplication', $viewer); if ($can_use && $is_installed) { $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-dashboard') ->setName(pht('Add to Dashboard')) ->setWorkflow(true) ->setHref("/dashboard/panel/install/{$engine_class}/{$query_key}/"); } if ($this->canExport()) { $export_uri = $engine->getExportURI($query_key); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-download') ->setName(pht('Export Data')) ->setWorkflow(true) ->setHref($export_uri); } if ($is_dev) { $engine = $this->getSearchEngine(); $nux_uri = $engine->getQueryBaseURI(); $nux_uri = id(new PhutilURI($nux_uri)) ->setQueryParam('nux', true); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-user-plus') ->setName(pht('DEV: New User State')) ->setHref($nux_uri); } if ($is_dev) { $overheated_uri = $this->getRequest()->getRequestURI() ->setQueryParam('overheated', true); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-fire') ->setName(pht('DEV: Overheated State')) ->setHref($overheated_uri); } return $actions; } private function canExport() { $engine = $this->getSearchEngine(); if (!$engine->canExport()) { return false; } // Don't allow logged-out users to perform exports. There's no technical // or policy reason they can't, but we don't normally give them access // to write files or jobs. For now, just err on the side of caution. $viewer = $this->getViewer(); if (!$viewer->getPHID()) { return false; } return true; } } diff --git a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php index 33efd890bb..e77c06a62b 100644 --- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php +++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php @@ -1,1465 +1,1528 @@ controller = $controller; return $this; } public function getController() { return $this->controller; } public function buildResponse() { $controller = $this->getController(); $request = $controller->getRequest(); $search = id(new PhabricatorApplicationSearchController()) ->setQueryKey($request->getURIData('queryKey')) ->setSearchEngine($this); return $controller->delegateToController($search); } public function newResultObject() { // We may be able to get this automatically if newQuery() is implemented. $query = $this->newQuery(); if ($query) { $object = $query->newResultObject(); if ($object) { return $object; } } return null; } public function newQuery() { return null; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } protected function requireViewer() { if (!$this->viewer) { throw new PhutilInvalidStateException('setViewer'); } return $this->viewer; } public function setContext($context) { $this->context = $context; return $this; } public function isPanelContext() { return ($this->context == self::CONTEXT_PANEL); } public function setNavigationItems(array $navigation_items) { assert_instances_of($navigation_items, 'PHUIListItemView'); $this->navigationItems = $navigation_items; return $this; } public function getNavigationItems() { return $this->navigationItems; } public function canUseInPanelContext() { return true; } public function saveQuery(PhabricatorSavedQuery $query) { $query->setEngineClassName(get_class($this)); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { $query->save(); } catch (AphrontDuplicateKeyQueryException $ex) { // Ignore, this is just a repeated search. } unset($unguarded); } /** * Create a saved query object from the request. * * @param AphrontRequest The search request. * @return PhabricatorSavedQuery */ public function buildSavedQueryFromRequest(AphrontRequest $request) { $fields = $this->buildSearchFields(); $viewer = $this->requireViewer(); $saved = new PhabricatorSavedQuery(); foreach ($fields as $field) { $field->setViewer($viewer); $value = $field->readValueFromRequest($request); $saved->setParameter($field->getKey(), $value); } return $saved; } /** * Executes the saved query. * * @param PhabricatorSavedQuery The saved query to operate on. * @return PhabricatorQuery The result of the query. */ public function buildQueryFromSavedQuery(PhabricatorSavedQuery $original) { $saved = clone $original; $this->willUseSavedQuery($saved); $fields = $this->buildSearchFields(); $viewer = $this->requireViewer(); $map = array(); foreach ($fields as $field) { $field->setViewer($viewer); $field->readValueFromSavedQuery($saved); $value = $field->getValueForQuery($field->getValue()); $map[$field->getKey()] = $value; } $original->attachParameterMap($map); $query = $this->buildQueryFromParameters($map); $object = $this->newResultObject(); if (!$object) { return $query; } $extensions = $this->getEngineExtensions(); foreach ($extensions as $extension) { $extension->applyConstraintsToQuery($object, $query, $saved, $map); } $order = $saved->getParameter('order'); $builtin = $query->getBuiltinOrderAliasMap(); if (strlen($order) && isset($builtin[$order])) { $query->setOrder($order); } else { // If the order is invalid or not available, we choose the first // builtin order. This isn't always the default order for the query, // but is the first value in the "Order" dropdown, and makes the query // behavior more consistent with the UI. In queries where the two // orders differ, this order is the preferred order for humans. $query->setOrder(head_key($builtin)); } return $query; } /** * Hook for subclasses to adjust saved queries prior to use. * * If an application changes how queries are saved, it can implement this * hook to keep old queries working the way users expect, by reading, * adjusting, and overwriting parameters. * * @param PhabricatorSavedQuery Saved query which will be executed. * @return void */ protected function willUseSavedQuery(PhabricatorSavedQuery $saved) { return; } protected function buildQueryFromParameters(array $parameters) { throw new PhutilMethodNotImplementedException(); } /** * Builds the search form using the request. * * @param AphrontFormView Form to populate. * @param PhabricatorSavedQuery The query from which to build the form. * @return void */ public function buildSearchForm( AphrontFormView $form, PhabricatorSavedQuery $saved) { $saved = clone $saved; $this->willUseSavedQuery($saved); $fields = $this->buildSearchFields(); $fields = $this->adjustFieldsForDisplay($fields); $viewer = $this->requireViewer(); foreach ($fields as $field) { $field->setViewer($viewer); $field->readValueFromSavedQuery($saved); } foreach ($fields as $field) { foreach ($field->getErrors() as $error) { $this->addError(last($error)); } } foreach ($fields as $field) { $field->appendToForm($form); } } protected function buildSearchFields() { $fields = array(); foreach ($this->buildCustomSearchFields() as $field) { $fields[] = $field; } $object = $this->newResultObject(); if ($object) { $extensions = $this->getEngineExtensions(); foreach ($extensions as $extension) { $extension_fields = $extension->getSearchFields($object); foreach ($extension_fields as $extension_field) { $fields[] = $extension_field; } } } $query = $this->newQuery(); if ($query && $this->shouldShowOrderField()) { $orders = $query->getBuiltinOrders(); $orders = ipull($orders, 'name'); $fields[] = id(new PhabricatorSearchOrderField()) ->setLabel(pht('Order By')) ->setKey('order') ->setOrderAliases($query->getBuiltinOrderAliasMap()) ->setOptions($orders); } $buckets = $this->newResultBuckets(); if ($query && $buckets) { $bucket_options = array( self::BUCKET_NONE => pht('No Bucketing'), ) + mpull($buckets, 'getResultBucketName'); $fields[] = id(new PhabricatorSearchSelectField()) ->setLabel(pht('Bucket')) ->setKey('bucket') ->setOptions($bucket_options); } $field_map = array(); foreach ($fields as $field) { $key = $field->getKey(); if (isset($field_map[$key])) { throw new Exception( pht( 'Two fields in this SearchEngine use the same key ("%s"), but '. 'each field must use a unique key.', $key)); } $field_map[$key] = $field; } return $field_map; } protected function shouldShowOrderField() { return true; } private function adjustFieldsForDisplay(array $field_map) { $order = $this->getDefaultFieldOrder(); $head_keys = array(); $tail_keys = array(); $seen_tail = false; foreach ($order as $order_key) { if ($order_key === '...') { $seen_tail = true; continue; } if (!$seen_tail) { $head_keys[] = $order_key; } else { $tail_keys[] = $order_key; } } $head = array_select_keys($field_map, $head_keys); $body = array_diff_key($field_map, array_fuse($tail_keys)); $tail = array_select_keys($field_map, $tail_keys); $result = $head + $body + $tail; // Force the fulltext "query" field to the top unconditionally. $result = array_select_keys($result, array('query')) + $result; foreach ($this->getHiddenFields() as $hidden_key) { unset($result[$hidden_key]); } return $result; } protected function buildCustomSearchFields() { throw new PhutilMethodNotImplementedException(); } /** * Define the default display order for fields by returning a list of * field keys. * * You can use the special key `...` to mean "all unspecified fields go * here". This lets you easily put important fields at the top of the form, * standard fields in the middle of the form, and less important fields at * the bottom. * * For example, you might return a list like this: * * return array( * 'authorPHIDs', * 'reviewerPHIDs', * '...', * 'createdAfter', * 'createdBefore', * ); * * Any unspecified fields (including custom fields and fields added * automatically by infrastructure) will be put in the middle. * * @return list Default ordering for field keys. */ protected function getDefaultFieldOrder() { return array(); } /** * Return a list of field keys which should be hidden from the viewer. * * @return list Fields to hide. */ protected function getHiddenFields() { return array(); } public function getErrors() { return $this->errors; } public function addError($error) { $this->errors[] = $error; return $this; } /** * Return an application URI corresponding to the results page of a query. * Normally, this is something like `/application/query/QUERYKEY/`. * * @param string The query key to build a URI for. * @return string URI where the query can be executed. * @task uri */ public function getQueryResultsPageURI($query_key) { return $this->getURI('query/'.$query_key.'/'); } /** * Return an application URI for query management. This is used when, e.g., * a query deletion operation is cancelled. * * @return string URI where queries can be managed. * @task uri */ public function getQueryManagementURI() { return $this->getURI('query/edit/'); } public function getQueryBaseURI() { return $this->getURI(''); } public function getExportURI($query_key) { return $this->getURI('query/'.$query_key.'/export/'); } /** * Return the URI to a path within the application. Used to construct default * URIs for management and results. * * @return string URI to path. * @task uri */ abstract protected function getURI($path); /** * Return a human readable description of the type of objects this query * searches for. * * For example, "Tasks" or "Commits". * * @return string Human-readable description of what this engine is used to * find. */ abstract public function getResultTypeDescription(); public function newSavedQuery() { return id(new PhabricatorSavedQuery()) ->setEngineClassName(get_class($this)); } public function addNavigationItems(PHUIListView $menu) { $viewer = $this->requireViewer(); $menu->newLabel(pht('Queries')); $named_queries = $this->loadEnabledNamedQueries(); foreach ($named_queries as $query) { $key = $query->getQueryKey(); $uri = $this->getQueryResultsPageURI($key); $menu->newLink($query->getQueryName(), $uri, 'query/'.$key); } if ($viewer->isLoggedIn()) { $manage_uri = $this->getQueryManagementURI(); $menu->newLink(pht('Edit Queries...'), $manage_uri, 'query/edit'); } $menu->newLabel(pht('Search')); $advanced_uri = $this->getQueryResultsPageURI('advanced'); $menu->newLink(pht('Advanced Search'), $advanced_uri, 'query/advanced'); foreach ($this->navigationItems as $extra_item) { $menu->addMenuItem($extra_item); } return $this; } public function loadAllNamedQueries() { $viewer = $this->requireViewer(); $builtin = $this->getBuiltinQueries(); if ($this->namedQueries === null) { $named_queries = id(new PhabricatorNamedQueryQuery()) ->setViewer($viewer) ->withEngineClassNames(array(get_class($this))) ->withUserPHIDs( array( $viewer->getPHID(), PhabricatorNamedQuery::SCOPE_GLOBAL, )) ->execute(); $named_queries = mpull($named_queries, null, 'getQueryKey'); $builtin = mpull($builtin, null, 'getQueryKey'); foreach ($named_queries as $key => $named_query) { if ($named_query->getIsBuiltin()) { if (isset($builtin[$key])) { $named_queries[$key]->setQueryName($builtin[$key]->getQueryName()); unset($builtin[$key]); } else { unset($named_queries[$key]); } } unset($builtin[$key]); } $named_queries = msortv($named_queries, 'getNamedQuerySortVector'); $this->namedQueries = $named_queries; } return $this->namedQueries + $builtin; } public function loadEnabledNamedQueries() { $named_queries = $this->loadAllNamedQueries(); foreach ($named_queries as $key => $named_query) { if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) { unset($named_queries[$key]); } } return $named_queries; } public function getDefaultQueryKey() { $viewer = $this->requireViewer(); $configs = id(new PhabricatorNamedQueryConfigQuery()) ->setViewer($viewer) ->withEngineClassNames(array(get_class($this))) ->withScopePHIDs( array( $viewer->getPHID(), PhabricatorNamedQueryConfig::SCOPE_GLOBAL, )) ->execute(); $configs = msortv($configs, 'getStrengthSortVector'); $key_pinned = PhabricatorNamedQueryConfig::PROPERTY_PINNED; $map = $this->loadEnabledNamedQueries(); foreach ($configs as $config) { $pinned = $config->getConfigProperty($key_pinned); if (!isset($map[$pinned])) { continue; } return $pinned; } return head_key($map); } protected function setQueryProjects( PhabricatorCursorPagedPolicyAwareQuery $query, PhabricatorSavedQuery $saved) { $datasource = id(new PhabricatorProjectLogicalDatasource()) ->setViewer($this->requireViewer()); $projects = $saved->getParameter('projects', array()); $constraints = $datasource->evaluateTokens($projects); if ($constraints) { $query->withEdgeLogicConstraints( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, $constraints); } return $this; } /* -( Applications )------------------------------------------------------- */ protected function getApplicationURI($path = '') { return $this->getApplication()->getApplicationURI($path); } protected function getApplication() { if (!$this->application) { $class = $this->getApplicationClassName(); $this->application = id(new PhabricatorApplicationQuery()) ->setViewer($this->requireViewer()) ->withClasses(array($class)) ->withInstalled(true) ->executeOne(); if (!$this->application) { throw new Exception( pht( 'Application "%s" is not installed!', $class)); } } return $this->application; } abstract public function getApplicationClassName(); /* -( Constructing Engines )----------------------------------------------- */ /** * Load all available application search engines. * * @return list All available engines. * @task construct */ public static function getAllEngines() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->execute(); } /** * Get an engine by class name, if it exists. * * @return PhabricatorApplicationSearchEngine|null Engine, or null if it does * not exist. * @task construct */ public static function getEngineByClassName($class_name) { return idx(self::getAllEngines(), $class_name); } /* -( Builtin Queries )---------------------------------------------------- */ /** * @task builtin */ public function getBuiltinQueries() { $names = $this->getBuiltinQueryNames(); $queries = array(); $sequence = 0; foreach ($names as $key => $name) { $queries[$key] = id(new PhabricatorNamedQuery()) ->setUserPHID(PhabricatorNamedQuery::SCOPE_GLOBAL) ->setEngineClassName(get_class($this)) ->setQueryName($name) ->setQueryKey($key) ->setSequence((1 << 24) + $sequence++) ->setIsBuiltin(true); } return $queries; } /** * @task builtin */ public function getBuiltinQuery($query_key) { if (!$this->isBuiltinQuery($query_key)) { throw new Exception(pht("'%s' is not a builtin!", $query_key)); } return idx($this->getBuiltinQueries(), $query_key); } /** * @task builtin */ protected function getBuiltinQueryNames() { return array(); } /** * @task builtin */ public function isBuiltinQuery($query_key) { $builtins = $this->getBuiltinQueries(); return isset($builtins[$query_key]); } /** * @task builtin */ public function buildSavedQueryFromBuiltin($query_key) { throw new Exception(pht("Builtin '%s' is not supported!", $query_key)); } /* -( Reading Utilities )--------------------------------------------------- */ /** * Read a list of user PHIDs from a request in a flexible way. This method * supports either of these forms: * * users[]=alincoln&users[]=htaft * users=alincoln,htaft * * Additionally, users can be specified either by PHID or by name. * * The main goal of this flexibility is to allow external programs to generate * links to pages (like "alincoln's open revisions") without needing to make * API calls. * * @param AphrontRequest Request to read user PHIDs from. * @param string Key to read in the request. * @param list Other permitted PHID types. * @return list List of user PHIDs and selector functions. * @task read */ protected function readUsersFromRequest( AphrontRequest $request, $key, array $allow_types = array()) { $list = $this->readListFromRequest($request, $key); $phids = array(); $names = array(); $allow_types = array_fuse($allow_types); $user_type = PhabricatorPeopleUserPHIDType::TYPECONST; foreach ($list as $item) { $type = phid_get_type($item); if ($type == $user_type) { $phids[] = $item; } else if (isset($allow_types[$type])) { $phids[] = $item; } else { if (PhabricatorTypeaheadDatasource::isFunctionToken($item)) { // If this is a function, pass it through unchanged; we'll evaluate // it later. $phids[] = $item; } else { $names[] = $item; } } } if ($names) { $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->requireViewer()) ->withUsernames($names) ->execute(); foreach ($users as $user) { $phids[] = $user->getPHID(); } $phids = array_unique($phids); } return $phids; } /** * Read a list of subscribers from a request in a flexible way. * * @param AphrontRequest Request to read PHIDs from. * @param string Key to read in the request. * @return list List of object PHIDs. * @task read */ protected function readSubscribersFromRequest( AphrontRequest $request, $key) { return $this->readUsersFromRequest( $request, $key, array( PhabricatorProjectProjectPHIDType::TYPECONST, )); } /** * Read a list of generic PHIDs from a request in a flexible way. Like * @{method:readUsersFromRequest}, this method supports either array or * comma-delimited forms. Objects can be specified either by PHID or by * object name. * * @param AphrontRequest Request to read PHIDs from. * @param string Key to read in the request. * @param list Optional, list of permitted PHID types. * @return list List of object PHIDs. * * @task read */ protected function readPHIDsFromRequest( AphrontRequest $request, $key, array $allow_types = array()) { $list = $this->readListFromRequest($request, $key); $objects = id(new PhabricatorObjectQuery()) ->setViewer($this->requireViewer()) ->withNames($list) ->execute(); $list = mpull($objects, 'getPHID'); if (!$list) { return array(); } // If only certain PHID types are allowed, filter out all the others. if ($allow_types) { $allow_types = array_fuse($allow_types); foreach ($list as $key => $phid) { if (empty($allow_types[phid_get_type($phid)])) { unset($list[$key]); } } } return $list; } /** * Read a list of items from the request, in either array format or string * format: * * list[]=item1&list[]=item2 * list=item1,item2 * * This provides flexibility when constructing URIs, especially from external * sources. * * @param AphrontRequest Request to read strings from. * @param string Key to read in the request. * @return list List of values. */ protected function readListFromRequest( AphrontRequest $request, $key) { $list = $request->getArr($key, null); if ($list === null) { $list = $request->getStrList($key); } if (!$list) { return array(); } return $list; } protected function readBoolFromRequest( AphrontRequest $request, $key) { if (!strlen($request->getStr($key))) { return null; } return $request->getBool($key); } protected function getBoolFromQuery(PhabricatorSavedQuery $query, $key) { $value = $query->getParameter($key); if ($value === null) { return $value; } return $value ? 'true' : 'false'; } /* -( Dates )-------------------------------------------------------------- */ /** * @task dates */ protected function parseDateTime($date_time) { if (!strlen($date_time)) { return null; } return PhabricatorTime::parseLocalTime($date_time, $this->requireViewer()); } /** * @task dates */ protected function buildDateRange( AphrontFormView $form, PhabricatorSavedQuery $saved_query, $start_key, $start_name, $end_key, $end_name) { $start_str = $saved_query->getParameter($start_key); $start = null; if (strlen($start_str)) { $start = $this->parseDateTime($start_str); if (!$start) { $this->addError( pht( '"%s" date can not be parsed.', $start_name)); } } $end_str = $saved_query->getParameter($end_key); $end = null; if (strlen($end_str)) { $end = $this->parseDateTime($end_str); if (!$end) { $this->addError( pht( '"%s" date can not be parsed.', $end_name)); } } if ($start && $end && ($start >= $end)) { $this->addError( pht( '"%s" must be a date before "%s".', $start_name, $end_name)); } $form ->appendChild( id(new PHUIFormFreeformDateControl()) ->setName($start_key) ->setLabel($start_name) ->setValue($start_str)) ->appendChild( id(new AphrontFormTextControl()) ->setName($end_key) ->setLabel($end_name) ->setValue($end_str)); } /* -( Paging and Executing Queries )--------------------------------------- */ protected function newResultBuckets() { return array(); } public function getResultBucket(PhabricatorSavedQuery $saved) { $key = $saved->getParameter('bucket'); if ($key == self::BUCKET_NONE) { return null; } $buckets = $this->newResultBuckets(); return idx($buckets, $key); } public function getPageSize(PhabricatorSavedQuery $saved) { $bucket = $this->getResultBucket($saved); $limit = (int)$saved->getParameter('limit'); if ($limit > 0) { if ($bucket) { $bucket->setPageSize($limit); } return $limit; } if ($bucket) { return $bucket->getPageSize(); } return 100; } public function shouldUseOffsetPaging() { return false; } public function newPagerForSavedQuery(PhabricatorSavedQuery $saved) { if ($this->shouldUseOffsetPaging()) { $pager = new PHUIPagerView(); } else { $pager = new AphrontCursorPagerView(); } $page_size = $this->getPageSize($saved); if (is_finite($page_size)) { $pager->setPageSize($page_size); } else { // Consider an INF pagesize to mean a large finite pagesize. // TODO: It would be nice to handle this more gracefully, but math // with INF seems to vary across PHP versions, systems, and runtimes. $pager->setPageSize(0xFFFF); } return $pager; } public function executeQuery( PhabricatorPolicyAwareQuery $query, AphrontView $pager) { $query->setViewer($this->requireViewer()); if ($this->shouldUseOffsetPaging()) { $objects = $query->executeWithOffsetPager($pager); } else { $objects = $query->executeWithCursorPager($pager); } $this->didExecuteQuery($query); return $objects; } protected function didExecuteQuery(PhabricatorPolicyAwareQuery $query) { return; } /* -( Rendering )---------------------------------------------------------- */ public function setRequest(AphrontRequest $request) { $this->request = $request; return $this; } public function getRequest() { return $this->request; } public function renderResults( array $objects, PhabricatorSavedQuery $query) { $phids = $this->getRequiredHandlePHIDsForResultList($objects, $query); if ($phids) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireViewer()) ->witHPHIDs($phids) ->execute(); } else { $handles = array(); } return $this->renderResultList($objects, $query, $handles); } protected function getRequiredHandlePHIDsForResultList( array $objects, PhabricatorSavedQuery $query) { return array(); } abstract protected function renderResultList( array $objects, PhabricatorSavedQuery $query, array $handles); /* -( Application Search )------------------------------------------------- */ public function getSearchFieldsForConduit() { $standard_fields = $this->buildSearchFields(); $fields = array(); foreach ($standard_fields as $field_key => $field) { $conduit_key = $field->getConduitKey(); if (isset($fields[$conduit_key])) { $other = $fields[$conduit_key]; $other_key = $other->getKey(); throw new Exception( pht( 'SearchFields "%s" (of class "%s") and "%s" (of class "%s") both '. 'define the same Conduit key ("%s"). Keys must be unique.', $field_key, get_class($field), $other_key, get_class($other), $conduit_key)); } $fields[$conduit_key] = $field; } // These are handled separately for Conduit, so don't show them as // supported. unset($fields['order']); unset($fields['limit']); $viewer = $this->requireViewer(); foreach ($fields as $key => $field) { $field->setViewer($viewer); } return $fields; } public function buildConduitResponse( ConduitAPIRequest $request, ConduitAPIMethod $method) { $viewer = $this->requireViewer(); $query_key = $request->getValue('queryKey'); if (!strlen($query_key)) { $saved_query = new PhabricatorSavedQuery(); } else if ($this->isBuiltinQuery($query_key)) { $saved_query = $this->buildSavedQueryFromBuiltin($query_key); } else { $saved_query = id(new PhabricatorSavedQueryQuery()) ->setViewer($viewer) ->withQueryKeys(array($query_key)) ->executeOne(); if (!$saved_query) { throw new Exception( pht( 'Query key "%s" does not correspond to a valid query.', $query_key)); } } $constraints = $request->getValue('constraints', array()); $fields = $this->getSearchFieldsForConduit(); foreach ($fields as $key => $field) { if (!$field->getConduitParameterType()) { unset($fields[$key]); } } $valid_constraints = array(); foreach ($fields as $field) { foreach ($field->getValidConstraintKeys() as $key) { $valid_constraints[$key] = true; } } foreach ($constraints as $key => $constraint) { if (empty($valid_constraints[$key])) { throw new Exception( pht( 'Constraint "%s" is not a valid constraint for this query.', $key)); } } foreach ($fields as $field) { if (!$field->getValueExistsInConduitRequest($constraints)) { continue; } $value = $field->readValueFromConduitRequest( $constraints, $request->getIsStrictlyTyped()); $saved_query->setParameter($field->getKey(), $value); } // NOTE: Currently, when running an ad-hoc query we never persist it into // a saved query. We might want to add an option to do this in the future // (for example, to enable a CLI-to-Web workflow where user can view more // details about results by following a link), but have no use cases for // it today. If we do identify a use case, we could save the query here. $query = $this->buildQueryFromSavedQuery($saved_query); $pager = $this->newPagerForSavedQuery($saved_query); $attachments = $this->getConduitSearchAttachments(); // TODO: Validate this better. $attachment_specs = $request->getValue('attachments', array()); $attachments = array_select_keys( $attachments, array_keys($attachment_specs)); foreach ($attachments as $key => $attachment) { $attachment->setViewer($viewer); } foreach ($attachments as $key => $attachment) { $attachment->willLoadAttachmentData($query, $attachment_specs[$key]); } $this->setQueryOrderForConduit($query, $request); $this->setPagerLimitForConduit($pager, $request); $this->setPagerOffsetsForConduit($pager, $request); $objects = $this->executeQuery($query, $pager); $data = array(); if ($objects) { $field_extensions = $this->getConduitFieldExtensions(); $extension_data = array(); foreach ($field_extensions as $key => $extension) { $extension_data[$key] = $extension->loadExtensionConduitData($objects); } $attachment_data = array(); foreach ($attachments as $key => $attachment) { $attachment_data[$key] = $attachment->loadAttachmentData( $objects, $attachment_specs[$key]); } foreach ($objects as $object) { $field_map = $this->getObjectWireFieldsForConduit( $object, $field_extensions, $extension_data); $attachment_map = array(); foreach ($attachments as $key => $attachment) { $attachment_map[$key] = $attachment->getAttachmentForObject( $object, $attachment_data[$key], $attachment_specs[$key]); } // If this is empty, we still want to emit a JSON object, not a // JSON list. if (!$attachment_map) { $attachment_map = (object)$attachment_map; } $id = (int)$object->getID(); $phid = $object->getPHID(); $data[] = array( 'id' => $id, 'type' => phid_get_type($phid), 'phid' => $phid, 'fields' => $field_map, 'attachments' => $attachment_map, ); } } return array( 'data' => $data, 'maps' => $method->getQueryMaps($query), 'query' => array( // This may be `null` if we have not saved the query. 'queryKey' => $saved_query->getQueryKey(), ), 'cursor' => array( 'limit' => $pager->getPageSize(), 'after' => $pager->getNextPageID(), 'before' => $pager->getPrevPageID(), 'order' => $request->getValue('order'), ), ); } public function getAllConduitFieldSpecifications() { $extensions = $this->getConduitFieldExtensions(); $object = $this->newQuery()->newResultObject(); $map = array(); foreach ($extensions as $extension) { $specifications = $extension->getFieldSpecificationsForConduit($object); foreach ($specifications as $specification) { $key = $specification->getKey(); if (isset($map[$key])) { throw new Exception( pht( 'Two field specifications share the same key ("%s"). Each '. 'specification must have a unique key.', $key)); } $map[$key] = $specification; } } return $map; } private function getEngineExtensions() { $extensions = PhabricatorSearchEngineExtension::getAllEnabledExtensions(); foreach ($extensions as $key => $extension) { $extension ->setViewer($this->requireViewer()) ->setSearchEngine($this); } $object = $this->newResultObject(); foreach ($extensions as $key => $extension) { if (!$extension->supportsObject($object)) { unset($extensions[$key]); } } return $extensions; } private function getConduitFieldExtensions() { $extensions = $this->getEngineExtensions(); $object = $this->newResultObject(); foreach ($extensions as $key => $extension) { if (!$extension->getFieldSpecificationsForConduit($object)) { unset($extensions[$key]); } } return $extensions; } private function setQueryOrderForConduit($query, ConduitAPIRequest $request) { $order = $request->getValue('order'); if ($order === null) { return; } if (is_scalar($order)) { $query->setOrder($order); } else { $query->setOrderVector($order); } } private function setPagerLimitForConduit($pager, ConduitAPIRequest $request) { $limit = $request->getValue('limit'); // If there's no limit specified and the query uses a weird huge page // size, just leave it at the default gigantic page size. Otherwise, // make sure it's between 1 and 100, inclusive. if ($limit === null) { if ($pager->getPageSize() >= 0xFFFF) { return; } else { $limit = 100; } } if ($limit > 100) { throw new Exception( pht( 'Maximum page size for Conduit API method calls is 100, but '. 'this call specified %s.', $limit)); } if ($limit < 1) { throw new Exception( pht( 'Minimum page size for API searches is 1, but this call '. 'specified %s.', $limit)); } $pager->setPageSize($limit); } private function setPagerOffsetsForConduit( $pager, ConduitAPIRequest $request) { $before_id = $request->getValue('before'); if ($before_id !== null) { $pager->setBeforeID($before_id); } $after_id = $request->getValue('after'); if ($after_id !== null) { $pager->setAfterID($after_id); } } protected function getObjectWireFieldsForConduit( $object, array $field_extensions, array $extension_data) { $fields = array(); foreach ($field_extensions as $key => $extension) { $data = idx($extension_data, $key, array()); $fields += $extension->getFieldValuesForConduit($object, $data); } return $fields; } public function getConduitSearchAttachments() { $extensions = $this->getEngineExtensions(); $object = $this->newResultObject(); $attachments = array(); foreach ($extensions as $extension) { $extension_attachments = $extension->getSearchAttachments($object); foreach ($extension_attachments as $attachment) { $attachment_key = $attachment->getAttachmentKey(); if (isset($attachments[$attachment_key])) { $other = $attachments[$attachment_key]; throw new Exception( pht( 'Two search engine attachments (of classes "%s" and "%s") '. 'specify the same attachment key ("%s"); keys must be unique.', get_class($attachment), get_class($other), $attachment_key)); } $attachments[$attachment_key] = $attachment; } } return $attachments; } final public function renderNewUserView() { $body = $this->getNewUserBody(); if (!$body) { return null; } return $body; } protected function getNewUserHeader() { return null; } protected function getNewUserBody() { return null; } public function newUseResultsActions(PhabricatorSavedQuery $saved) { return array(); } /* -( Export )------------------------------------------------------------- */ public function canExport() { $fields = $this->newExportFields(); return (bool)$fields; } final public function newExportFieldList() { - return $this->newExportFields(); + $builtin_fields = array( + id(new PhabricatorIDExportField()) + ->setKey('id') + ->setLabel(pht('ID')), + id(new PhabricatorPHIDExportField()) + ->setKey('phid') + ->setLabel(pht('PHID')), + ); + + $fields = mpull($builtin_fields, null, 'getKey'); + + $export_fields = $this->newExportFields(); + foreach ($export_fields as $export_field) { + $key = $export_field->getKey(); + + if (isset($fields[$key])) { + throw new Exception( + pht( + 'Search engine ("%s") defines an export field with a key ("%s") '. + 'that collides with another field. Each field must have a '. + 'unique key.', + get_class($this), + $key)); + } + + $fields[$key] = $export_field; + } + + return $fields; + } + + final public function newExport(array $objects) { + $objects = array_values($objects); + $n = count($objects); + + $maps = array(); + foreach ($objects as $object) { + $maps[] = array( + 'id' => $object->getID(), + 'phid' => $object->getPHID(), + ); + } + + $export_data = $this->newExportData($objects); + $export_data = array_values($export_data); + if (count($export_data) !== count($objects)) { + throw new Exception( + pht( + 'Search engine ("%s") exported the wrong number of objects, '. + 'expected %s but got %s.', + get_class($this), + phutil_count($objects), + phutil_count($export_data))); + } + + for ($ii = 0; $ii < $n; $ii++) { + $maps[$ii] += $export_data[$ii]; + } + + return $maps; } protected function newExportFields() { return array(); } + protected function newExportData(array $objects) { + throw new PhutilMethodNotImplementedException(); + } + }