diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index b8394459ba..b7b512156f 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -1,1101 +1,1101 @@ 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 getActiveQuery() { if (!$this->activeQuery) { throw new Exception(pht('There is no active query yet.')); } return $this->activeQuery; } 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(); } if ($query_action === 'customize') { return $this->processCustomizeRequest(); } $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 (!phutil_nonempty_string($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 "Bulk Edit" and "Export Data" work correctly. $engine->saveQuery($saved_query); } $this->activeQuery = $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') ->setColor(PHUIButtonView::GREY) ->setHref('/search/edit/key/'.$saved_query->getQueryKey().'/') ->setText(pht('Save Query')) ->setIcon('fa-bookmark'); $submit->addButton($save_button); } $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); $query->setReturnPartialResultsOnOverheat(true); $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 ("%s").', 'PhabricatorApplicationSearchResultView', get_class($engine), phutil_describe_type($list))); } 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(); } catch (PhabricatorInvalidQueryCursorException $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() ->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(); $sheet_title = $named_query->getQueryName(); } else { $filename = $engine->getResultTypeDescription(); $sheet_title = $engine->getResultTypeDescription(); } $filename = phutil_utf8_strtolower($filename); $filename = PhabricatorFile::normalizeFileName($filename); $all_formats = PhabricatorExportFormat::getAllExportFormats(); $available_options = array(); $unavailable_options = array(); $formats = array(); $unavailable_formats = array(); foreach ($all_formats as $key => $format) { if ($format->isExportFormatEnabled()) { $available_options[$key] = $format->getExportFormatName(); $formats[$key] = $format; } else { $unavailable_options[$key] = pht( '%s (Not Available)', $format->getExportFormatName()); $unavailable_formats[$key] = $format; } } $format_options = $available_options + $unavailable_options; // Try to default to the format the user used last time. If you just // exported to Excel, you probably want to export to Excel again. $format_key = $this->readExportFormatPreference(); if (!isset($formats[$format_key])) { $format_key = head_key($format_options); } // Check if this is a large result set or not. If we're exporting a // large amount of data, we'll build the actual export file in the daemons. $threshold = 1000; $query = $engine->buildQueryFromSavedQuery($saved_query); $pager = $engine->newPagerForSavedQuery($saved_query); $pager->setPageSize($threshold + 1); $objects = $engine->executeQuery($query, $pager); $object_count = count($objects); $is_large_export = ($object_count > $threshold); $errors = array(); $e_format = null; if ($request->isFormPost()) { $format_key = $request->getStr('format'); if (isset($unavailable_formats[$format_key])) { $unavailable = $unavailable_formats[$format_key]; $instructions = $unavailable->getInstallInstructions(); $markup = id(new PHUIRemarkupView($viewer, $instructions)) ->setRemarkupOption( PHUIRemarkupView::OPTION_PRESERVE_LINEBREAKS, false); return $this->newDialog() ->setTitle(pht('Export Format Not Available')) ->appendChild($markup) ->addCancelButton($cancel_uri, pht('Done')); } $format = idx($formats, $format_key); if (!$format) { $e_format = pht('Invalid'); $errors[] = pht('Choose a valid export format.'); } if (!$errors) { $this->writeExportFormatPreference($format_key); $export_engine = id(new PhabricatorExportEngine()) ->setViewer($viewer) ->setSearchEngine($engine) ->setSavedQuery($saved_query) ->setTitle($sheet_title) ->setFilename($filename) ->setExportFormat($format); if ($is_large_export) { $job = $export_engine->newBulkJob($request); return id(new AphrontRedirectResponse()) ->setURI($job->getMonitorURI()); } else { $file = $export_engine->exportFile(); return $file->newDownloadResponse(); } } } $export_form = id(new AphrontFormView()) ->setViewer($viewer) ->appendControl( id(new AphrontFormSelectControl()) ->setName('format') ->setLabel(pht('Format')) ->setError($e_format) ->setValue($format_key) ->setOptions($format_options)); if ($is_large_export) { $submit_button = pht('Continue'); } else { $submit_button = pht('Download Data'); } return $this->newDialog() ->setTitle(pht('Export Results')) ->setErrors($errors) ->appendForm($export_form) ->addCancelButton($cancel_uri) ->addSubmitButton($submit_button); } 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() ->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())) { + if (phutil_nonempty_string($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) ->setReturnPartialResultsOnOverheat(true) ->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, )); } public static function newOverheatedError($has_results) { $overheated_link = phutil_tag( 'a', array( 'href' => 'https://phurl.io/u/overheated', 'target' => '_blank', ), pht('Learn More')); if ($has_results) { $message = pht( 'This query took too long, so only some results are shown. %s', $overheated_link); } else { $message = pht( 'This query took too long. %s', $overheated_link); } return $message; } private function newOverheatedView(array $results) { $message = self::newOverheatedError((bool)$results); 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->getActiveQuery()->getQueryKey(); $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)) ->replaceQueryParam('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() ->replaceQueryParam('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; } private function readExportFormatPreference() { $viewer = $this->getViewer(); $export_key = PhabricatorExportFormatSetting::SETTINGKEY; $value = $viewer->getUserSetting($export_key); if (is_string($value)) { return $value; } return ''; } private function writeExportFormatPreference($value) { $viewer = $this->getViewer(); $request = $this->getRequest(); if (!$viewer->isLoggedIn()) { return; } $export_key = PhabricatorExportFormatSetting::SETTINGKEY; $preferences = PhabricatorUserPreferences::loadUserPreferences($viewer); $editor = id(new PhabricatorUserPreferencesEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $xactions = array(); $xactions[] = $preferences->newTransaction($export_key, $value); $editor->applyTransactions($preferences, $xactions); } private function processCustomizeRequest() { $viewer = $this->getViewer(); $engine = $this->getSearchEngine(); $request = $this->getRequest(); $object_phid = $request->getStr('search.objectPHID'); $context_phid = $request->getStr('search.contextPHID'); // For now, the object can only be a dashboard panel, so just use a panel // query explicitly. $object = id(new PhabricatorDashboardPanelQuery()) ->setViewer($viewer) ->withPHIDs(array($object_phid)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$object) { return new Aphront404Response(); } $object_name = pht('%s %s', $object->getMonogram(), $object->getName()); // Likewise, the context object can only be a dashboard. if (strlen($context_phid)) { $context = id(new PhabricatorDashboardQuery()) ->setViewer($viewer) ->withPHIDs(array($context_phid)) ->executeOne(); if (!$context) { return new Aphront404Response(); } } else { $context = $object; } $done_uri = $context->getURI(); if ($request->isFormPost()) { $saved_query = $engine->buildSavedQueryFromRequest($request); $engine->saveQuery($saved_query); $query_key = $saved_query->getQueryKey(); } else { $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(); } $form = id(new AphrontFormView()) ->setViewer($viewer) ->addHiddenInput('search.objectPHID', $object_phid) ->addHiddenInput('search.contextPHID', $context_phid) ->setAction($request->getPath()); $engine->buildSearchForm($form, $saved_query); $errors = $engine->getErrors(); if ($request->isFormPost()) { if (!$errors) { $xactions = array(); // Since this workflow is currently used only by dashboard panels, // we can hard-code how the edit works. $xactions[] = $object->getApplicationTransactionTemplate() ->setTransactionType( PhabricatorDashboardQueryPanelQueryTransaction::TRANSACTIONTYPE) ->setNewValue($query_key); $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $editor->applyTransactions($object, $xactions); return id(new AphrontRedirectResponse())->setURI($done_uri); } } return $this->newDialog() ->setTitle(pht('Customize Query: %s', $object_name)) ->setErrors($errors) ->setWidth(AphrontDialogView::WIDTH_FULL) ->appendForm($form) ->addCancelButton($done_uri) ->addSubmitButton(pht('Save Changes')); } } diff --git a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php index 95f1ffafa3..a81e055a56 100644 --- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php +++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php @@ -1,1626 +1,1626 @@ 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) { if ($query->getID()) { throw new Exception( pht( 'Query (with ID "%s") has already been saved. Queries are '. 'immutable once saved.', $query->getID())); } $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])) { + if (phutil_nonempty_string($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/'); } public function getCustomizeURI($query_key, $object_phid, $context_phid) { $params = array( 'search.objectPHID' => $object_phid, 'search.contextPHID' => $context_phid, ); $uri = $this->getURI('query/'.$query_key.'/customize/'); $uri = new PhutilURI($uri, $params); return phutil_string_cast($uri); } /** * 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()); if (!is_array($constraints)) { throw new Exception( pht( 'Parameter "constraints" must be a map of constraints, got "%s".', phutil_describe_type($constraints))); } $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() { $object = $this->newResultObject(); $builtin_fields = array( id(new PhabricatorIDExportField()) ->setKey('id') ->setLabel(pht('ID')), ); if ($object->getConfigOption(LiskDAO::CONFIG_AUX_PHID)) { $builtin_fields[] = 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; } $extensions = $this->newExportExtensions(); foreach ($extensions as $extension) { $extension_fields = $extension->newExportFields(); foreach ($extension_fields as $extension_field) { $key = $extension_field->getKey(); if (isset($fields[$key])) { throw new Exception( pht( 'Export engine extension ("%s") defines an export field with '. 'a key ("%s") that collides with another field. Each field '. 'must have a unique key.', get_class($extension_field), $key)); } $fields[$key] = $extension_field; } } return $fields; } final public function newExport(array $objects) { $object = $this->newResultObject(); $has_phid = $object->getConfigOption(LiskDAO::CONFIG_AUX_PHID); $objects = array_values($objects); $n = count($objects); $maps = array(); foreach ($objects as $object) { $map = array( 'id' => $object->getID(), ); if ($has_phid) { $map['phid'] = $object->getPHID(); } $maps[] = $map; } $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]; } $extensions = $this->newExportExtensions(); foreach ($extensions as $extension) { $extension_data = $extension->newExportData($objects); $extension_data = array_values($extension_data); if (count($export_data) !== count($objects)) { throw new Exception( pht( 'Export engine extension ("%s") exported the wrong number of '. 'objects, expected %s but got %s.', get_class($extension), phutil_count($objects), phutil_count($export_data))); } for ($ii = 0; $ii < $n; $ii++) { $maps[$ii] += $extension_data[$ii]; } } return $maps; } protected function newExportFields() { return array(); } protected function newExportData(array $objects) { throw new PhutilMethodNotImplementedException(); } private function newExportExtensions() { $object = $this->newResultObject(); $viewer = $this->requireViewer(); $extensions = PhabricatorExportEngineExtension::getAllExtensions(); $supported = array(); foreach ($extensions as $extension) { $extension = clone $extension; $extension->setViewer($viewer); if ($extension->supportsObject($object)) { $supported[] = $extension; } } return $supported; } } diff --git a/src/view/form/control/AphrontFormTokenizerControl.php b/src/view/form/control/AphrontFormTokenizerControl.php index fe80c86f81..7af8fcae32 100644 --- a/src/view/form/control/AphrontFormTokenizerControl.php +++ b/src/view/form/control/AphrontFormTokenizerControl.php @@ -1,154 +1,154 @@ datasource = $datasource; return $this; } public function setDisableBehavior($disable) { $this->disableBehavior = $disable; return $this; } protected function getCustomControlClass() { return 'aphront-form-control-tokenizer'; } public function setLimit($limit) { $this->limit = $limit; return $this; } public function setPlaceholder($placeholder) { $this->placeholder = $placeholder; return $this; } public function setInitialValue(array $initial_value) { $this->initialValue = $initial_value; return $this; } public function getInitialValue() { return $this->initialValue; } public function willRender() { // Load the handles now so we'll get a bulk load later on when we actually // render them. $this->loadHandles(); } protected function renderInput() { $name = $this->getName(); $handles = $this->loadHandles(); $handles = iterator_to_array($handles); if ($this->getID()) { $id = $this->getID(); } else { $id = celerity_generate_unique_node_id(); } $datasource = $this->datasource; if (!$datasource) { throw new Exception( pht('You must set a datasource to use a TokenizerControl.')); } $datasource->setViewer($this->getUser()); $placeholder = null; - if (!strlen($this->placeholder)) { + if (!phutil_nonempty_string($this->placeholder)) { $placeholder = $datasource->getPlaceholderText(); } $values = nonempty($this->getValue(), array()); $tokens = $datasource->renderTokens($values); foreach ($tokens as $token) { $token->setInputName($this->getName()); } $template = id(new AphrontTokenizerTemplateView()) ->setName($name) ->setID($id) ->setValue($tokens); $initial_value = $this->getInitialValue(); if ($initial_value !== null) { $template->setInitialValue($initial_value); } $username = null; if ($this->hasViewer()) { $username = $this->getViewer()->getUsername(); } $datasource_uri = $datasource->getDatasourceURI(); $browse_uri = $datasource->getBrowseURI(); if ($browse_uri) { $template->setBrowseURI($browse_uri); } if (!$this->disableBehavior) { Javelin::initBehavior('aphront-basic-tokenizer', array( 'id' => $id, 'src' => $datasource_uri, 'value' => mpull($tokens, 'getValue', 'getKey'), 'icons' => mpull($tokens, 'getIcon', 'getKey'), 'types' => mpull($tokens, 'getTokenType', 'getKey'), 'colors' => mpull($tokens, 'getColor', 'getKey'), 'availabilityColors' => mpull( $tokens, 'getAvailabilityColor', 'getKey'), 'limit' => $this->limit, 'username' => $username, 'placeholder' => $placeholder, 'browseURI' => $browse_uri, 'disabled' => $this->getDisabled(), )); } return $template->render(); } private function loadHandles() { if ($this->handles === null) { $viewer = $this->getUser(); if (!$viewer) { throw new Exception( pht( 'Call %s before rendering tokenizers. '. 'Use %s on %s to do this easily.', 'setUser()', 'appendControl()', 'AphrontFormView')); } $values = nonempty($this->getValue(), array()); $phids = array(); foreach ($values as $value) { if (!PhabricatorTypeaheadDatasource::isFunctionToken($value)) { $phids[] = $value; } } $this->handles = $viewer->loadHandles($phids); } return $this->handles; } } diff --git a/src/view/phui/PHUIInfoView.php b/src/view/phui/PHUIInfoView.php index cefeedbe5f..58898d40d2 100644 --- a/src/view/phui/PHUIInfoView.php +++ b/src/view/phui/PHUIInfoView.php @@ -1,203 +1,209 @@ title = $title; return $this; } public function setSeverity($severity) { $this->severity = $severity; return $this; } private function getSeverity() { $severity = $this->severity ? $this->severity : self::SEVERITY_ERROR; return $severity; } public function setErrors(array $errors) { $this->errors = $errors; return $this; } public function setID($id) { $this->id = $id; return $this; } public function setIsHidden($bool) { $this->isHidden = $bool; return $this; } public function setFlush($flush) { $this->flush = $flush; return $this; } public function setIcon($icon) { if ($icon instanceof PHUIIconView) { $this->icon = $icon; } else { $icon = id(new PHUIIconView()) ->setIcon($icon); $this->icon = $icon; } return $this; } private function getIcon() { if ($this->icon) { return $this->icon; } switch ($this->getSeverity()) { case self::SEVERITY_ERROR: $icon = 'fa-exclamation-circle'; break; case self::SEVERITY_WARNING: $icon = 'fa-exclamation-triangle'; break; case self::SEVERITY_NOTICE: $icon = 'fa-info-circle'; break; case self::SEVERITY_PLAIN: case self::SEVERITY_NODATA: return null; case self::SEVERITY_SUCCESS: $icon = 'fa-check-circle'; break; case self::SEVERITY_MFA: $icon = 'fa-lock'; break; } $icon = id(new PHUIIconView()) ->setIcon($icon) ->addClass('phui-info-icon'); return $icon; } public function addButton(PHUIButtonView $button) { $this->buttons[] = $button; return $this; } protected function getTagAttributes() { $classes = array(); $classes[] = 'phui-info-view'; $classes[] = 'phui-info-severity-'.$this->getSeverity(); $classes[] = 'grouped'; if ($this->flush) { $classes[] = 'phui-info-view-flush'; } if ($this->getIcon()) { $classes[] = 'phui-info-has-icon'; } return array( 'id' => $this->id, 'class' => implode(' ', $classes), 'style' => $this->isHidden ? 'display: none;' : null, ); } protected function getTagContent() { require_celerity_resource('phui-info-view-css'); $errors = $this->errors; if (count($errors) > 1) { $list = array(); foreach ($errors as $error) { $list[] = phutil_tag( 'li', array(), $error); } $list = phutil_tag( 'ul', array( 'class' => 'phui-info-view-list', ), $list); } else if (count($errors) == 1) { $list = head($this->errors); } else { $list = null; } $title = $this->title; - if ($title || strlen($title)) { + if ($title || phutil_nonempty_string($title)) { $title = phutil_tag( 'h1', array( 'class' => 'phui-info-view-head', ), $title); } else { $title = null; } $children = $this->renderChildren(); if ($list) { $children[] = $list; } $body = null; if (!empty($children)) { $body = phutil_tag( 'div', array( 'class' => 'phui-info-view-body', ), $children); } $buttons = null; if (!empty($this->buttons)) { $buttons = phutil_tag( 'div', array( 'class' => 'phui-info-view-actions', ), $this->buttons); } $icon = null; if ($this->getIcon()) { $icon = phutil_tag( 'div', array( 'class' => 'phui-info-view-icon', ), $this->getIcon()); } return array( $icon, $buttons, $title, $body, ); } }