diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php index 9e905f8cce..7c2205df46 100644 --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -1,434 +1,453 @@ getRequest(); $viewer = $request->getUser(); $query = $request->getStr('q'); $offset = $request->getInt('offset'); $select_phid = null; $is_browse = ($request->getURIData('action') == 'browse'); $select = $request->getStr('select'); if ($select) { $select = phutil_json_decode($select); $query = idx($select, 'q'); $offset = idx($select, 'offset'); $select_phid = idx($select, 'phid'); } // Default this to the query string to make debugging a little bit easier. $raw_query = nonempty($request->getStr('raw'), $query); // This makes form submission easier in the debug view. $class = nonempty($request->getURIData('class'), $request->getStr('class')); $sources = id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorTypeaheadDatasource') ->execute(); if (isset($sources[$class])) { $source = $sources[$class]; - $source->setParameters($request->getRequestData()); + + $parameters = array(); + + $raw_parameters = $request->getStr('parameters'); + if (strlen($raw_parameters)) { + try { + $parameters = phutil_json_decode($raw_parameters); + } catch (PhutilJSONParserException $ex) { + return $this->newDialog() + ->setTitle(pht('Invalid Parameters')) + ->appendParagraph( + pht( + 'The HTTP parameter named "parameters" for this request is '. + 'not a valid JSON parameter. JSON is required. Exception: %s', + $ex->getMessage())) + ->addCancelButton('/'); + } + } + + $source->setParameters($parameters); $source->setViewer($viewer); // NOTE: Wrapping the source in a Composite datasource ensures we perform // application visibility checks for the viewer, so we do not need to do // those separately. $composite = new PhabricatorTypeaheadRuntimeCompositeDatasource(); $composite->addDatasource($source); $hard_limit = 1000; $limit = 100; $composite ->setViewer($viewer) ->setQuery($query) ->setRawQuery($raw_query) ->setLimit($limit + 1); if ($is_browse) { if (!$composite->isBrowsable()) { return new Aphront404Response(); } if (($offset + $limit) >= $hard_limit) { // Offset-based paging is intrinsically slow; hard-cap how far we're // willing to go with it. return new Aphront404Response(); } $composite ->setOffset($offset) ->setIsBrowse(true); } $results = $composite->loadResults(); if ($is_browse) { // If this is a request for a specific token after the user clicks // "Select", return the token in wire format so it can be added to // the tokenizer. if ($select_phid !== null) { $map = mpull($results, null, 'getPHID'); $token = idx($map, $select_phid); if (!$token) { return new Aphront404Response(); } $payload = array( 'key' => $token->getPHID(), 'token' => $token->getWireFormat(), ); return id(new AphrontAjaxResponse())->setContent($payload); } $format = $request->getStr('format'); switch ($format) { case 'html': case 'dialog': // These are the acceptable response formats. break; default: // Return a dialog if format information is missing or invalid. $format = 'dialog'; break; } $next_link = null; if (count($results) > $limit) { $results = array_slice($results, 0, $limit, $preserve_keys = true); if (($offset + (2 * $limit)) < $hard_limit) { $next_uri = id(new PhutilURI($request->getRequestURI())) ->setQueryParam('offset', $offset + $limit) ->setQueryParam('q', $query) ->setQueryParam('raw', $raw_query) ->setQueryParam('format', 'html'); $next_link = javelin_tag( 'a', array( 'href' => $next_uri, 'class' => 'typeahead-browse-more', 'sigil' => 'typeahead-browse-more', 'mustcapture' => true, ), pht('More Results')); } else { // If the user has paged through more than 1K results, don't // offer to page any further. $next_link = javelin_tag( 'div', array( 'class' => 'typeahead-browse-hard-limit', ), pht('You reach the edge of the abyss.')); } } $exclude = $request->getStrList('exclude'); $exclude = array_fuse($exclude); $select = array( 'offset' => $offset, 'q' => $query, ); $items = array(); foreach ($results as $result) { // Disable already-selected tokens. $disabled = isset($exclude[$result->getPHID()]); $value = $select + array('phid' => $result->getPHID()); $value = json_encode($value); $button = phutil_tag( 'button', array( 'class' => 'small grey', 'name' => 'select', 'value' => $value, 'disabled' => $disabled ? 'disabled' : null, ), pht('Select')); $information = $this->renderBrowseResult($result, $button); $items[] = phutil_tag( 'div', array( 'class' => 'typeahead-browse-item grouped', ), $information); } $markup = array( $items, $next_link, ); if ($format == 'html') { $content = array( 'markup' => hsprintf('%s', $markup), ); return id(new AphrontAjaxResponse())->setContent($content); } $this->requireResource('typeahead-browse-css'); $this->initBehavior('typeahead-browse'); $input_id = celerity_generate_unique_node_id(); $frame_id = celerity_generate_unique_node_id(); $config = array( 'inputID' => $input_id, 'frameID' => $frame_id, 'uri' => (string)$request->getRequestURI(), ); $this->initBehavior('typeahead-search', $config); $search = javelin_tag( 'input', array( 'type' => 'text', 'id' => $input_id, 'class' => 'typeahead-browse-input', 'autocomplete' => 'off', 'placeholder' => $source->getPlaceholderText(), )); $frame = phutil_tag( 'div', array( 'class' => 'typeahead-browse-frame', 'id' => $frame_id, ), $markup); $browser = array( phutil_tag( 'div', array( 'class' => 'typeahead-browse-header', ), $search), $frame, ); $function_help = null; if ($source->getAllDatasourceFunctions()) { $reference_uri = '/typeahead/help/'.get_class($source).'/'; $parameters = $source->getParameters(); if ($parameters) { $reference_uri = (string)id(new PhutilURI($reference_uri)) ->setQueryParam('parameters', phutil_json_encode($parameters)); } $reference_link = phutil_tag( 'a', array( 'href' => $reference_uri, 'target' => '_blank', ), pht('Reference: Advanced Functions')); $function_help = array( id(new PHUIIconView()) ->setIcon('fa-book'), ' ', $reference_link, ); } return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FORM) ->setRenderDialogAsDiv(true) ->setTitle($source->getBrowseTitle()) ->appendChild($browser) ->setResizeX(true) ->setResizeY($frame_id) ->addFooter($function_help) ->addCancelButton('/', pht('Close')); } } else if ($is_browse) { return new Aphront404Response(); } else { $results = array(); } $content = mpull($results, 'getWireFormat'); $content = array_values($content); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent($content); } // If there's a non-Ajax request to this endpoint, show results in a tabular // format to make it easier to debug typeahead output. foreach ($sources as $key => $source) { // See T13119. Exclude proxy datasources from the dropdown since they // fatal if built like this without actually being configured with an // underlying datasource. This is a bit hacky but this is just a // debugging/development UI anyway. if ($source instanceof PhabricatorTypeaheadProxyDatasource) { unset($sources[$key]); continue; } // This can happen with composite or generic sources. if (!$source->getDatasourceApplicationClass()) { continue; } if (!PhabricatorApplication::isClassInstalledForViewer( $source->getDatasourceApplicationClass(), $viewer)) { unset($sources[$key]); } } $options = array_fuse(array_keys($sources)); asort($options); $form = id(new AphrontFormView()) ->setUser($viewer) ->setAction('/typeahead/class/') ->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('Source Class')) ->setName('class') ->setValue($class) ->setOptions($options)) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Query')) ->setName('q') ->setValue($request->getStr('q'))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Raw Query')) ->setName('raw') ->setValue($request->getStr('raw'))) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Query'))); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Token Query')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setForm($form); // Make "\n" delimiters more visible. foreach ($content as $key => $row) { $content[$key][0] = str_replace("\n", '<\n>', $row[0]); } $table = new AphrontTableView($content); $table->setHeaders( array( pht('Name'), pht('URI'), pht('PHID'), pht('Priority'), pht('Display Name'), pht('Display Type'), pht('Image URI'), pht('Priority Type'), pht('Icon'), pht('Closed'), pht('Sprite'), pht('Color'), pht('Type'), pht('Unique'), pht('Auto'), pht('Phase'), )); $result_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Token Results (%s)', $class)) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->appendChild($table); $title = pht('Typeahead Results'); $header = id(new PHUIHeaderView()) ->setHeader($title); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter(array( $form_box, $result_box, )); return $this->newPage() ->setTitle($title) ->appendChild($view); } private function renderBrowseResult( PhabricatorTypeaheadResult $result, $button) { $class = array(); $style = array(); $separator = " \xC2\xB7 "; $class[] = 'phabricator-main-search-typeahead-result'; $name = phutil_tag( 'div', array( 'class' => 'result-name', ), $result->getDisplayName()); $icon = $result->getIcon(); $icon = id(new PHUIIconView())->setIcon($icon); $attributes = $result->getAttributes(); $attributes = phutil_implode_html($separator, $attributes); $attributes = array($icon, ' ', $attributes); $closed = $result->getClosed(); if ($closed) { $class[] = 'result-closed'; $attributes = array($closed, $separator, $attributes); } $attributes = phutil_tag( 'div', array( 'class' => 'result-type', ), $attributes); $image = $result->getImageURI(); if ($image) { $style[] = 'background-image: url('.$image.');'; $class[] = 'has-image'; } return phutil_tag( 'div', array( 'class' => implode(' ', $class), 'style' => implode(' ', $style), ), array( $button, $name, $attributes, )); } } diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php index 2e369a3f67..196ad1b98b 100644 --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php @@ -1,595 +1,607 @@ limit = $limit; return $this; } public function getLimit() { return $this->limit; } public function setOffset($offset) { $this->offset = $offset; return $this; } public function getOffset() { return $this->offset; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setRawQuery($raw_query) { $this->rawQuery = $raw_query; return $this; } public function getPrefixQuery() { return phutil_utf8_strtolower($this->getRawQuery()); } public function getRawQuery() { return $this->rawQuery; } public function setQuery($query) { $this->query = $query; return $this; } public function getQuery() { return $this->query; } public function setParameters(array $params) { $this->parameters = $params; return $this; } public function getParameters() { return $this->parameters; } public function getParameter($name, $default = null) { return idx($this->parameters, $name, $default); } public function setIsBrowse($is_browse) { $this->isBrowse = $is_browse; return $this; } public function getIsBrowse() { return $this->isBrowse; } public function setPhase($phase) { $this->phase = $phase; return $this; } public function getPhase() { return $this->phase; } public function getDatasourceURI() { $uri = new PhutilURI('/typeahead/class/'.get_class($this).'/'); - $uri->setQueryParams($this->parameters); + $uri->setQueryParams($this->newURIParameters()); return (string)$uri; } public function getBrowseURI() { if (!$this->isBrowsable()) { return null; } $uri = new PhutilURI('/typeahead/browse/'.get_class($this).'/'); - $uri->setQueryParams($this->parameters); + $uri->setQueryParams($this->newURIParameters()); return (string)$uri; } + private function newURIParameters() { + if (!$this->parameters) { + return array(); + } + + $map = array( + 'parameters' => phutil_json_encode($this->parameters), + ); + + return $map; + } + abstract public function getPlaceholderText(); public function getBrowseTitle() { return get_class($this); } abstract public function getDatasourceApplicationClass(); abstract public function loadResults(); protected function loadResultsForPhase($phase, $limit) { // By default, sources just load all of their results in every phase and // rely on filtering at a higher level to sequence phases correctly. $this->setLimit($limit); return $this->loadResults(); } protected function didLoadResults(array $results) { return $results; } public static function tokenizeString($string) { $string = phutil_utf8_strtolower($string); $string = trim($string); if (!strlen($string)) { return array(); } // NOTE: Splitting on "(" and ")" is important for milestones. $tokens = preg_split('/[\s\[\]\(\)-]+/u', $string); $tokens = array_unique($tokens); // Make sure we don't return the empty token, as this will boil down to a // JOIN against every token. foreach ($tokens as $key => $value) { if (!strlen($value)) { unset($tokens[$key]); } } return array_values($tokens); } public function getTokens() { return self::tokenizeString($this->getRawQuery()); } protected function executeQuery( PhabricatorCursorPagedPolicyAwareQuery $query) { return $query ->setViewer($this->getViewer()) ->setOffset($this->getOffset()) ->setLimit($this->getLimit()) ->execute(); } /** * Can the user browse through results from this datasource? * * Browsable datasources allow the user to switch from typeahead mode to * a browse mode where they can scroll through all results. * * By default, datasources are browsable, but some datasources can not * generate a meaningful result set or can't filter results on the server. * * @return bool */ public function isBrowsable() { return true; } /** * Filter a list of results, removing items which don't match the query * tokens. * * This is useful for datasources which return a static list of hard-coded * or configured results and can't easily do query filtering in a real * query class. Instead, they can just build the entire result set and use * this method to filter it. * * For datasources backed by database objects, this is often much less * efficient than filtering at the query level. * * @param list List of typeahead results. * @return list Filtered results. */ protected function filterResultsAgainstTokens(array $results) { $tokens = $this->getTokens(); if (!$tokens) { return $results; } $map = array(); foreach ($tokens as $token) { $map[$token] = strlen($token); } foreach ($results as $key => $result) { $rtokens = self::tokenizeString($result->getName()); // For each token in the query, we need to find a match somewhere // in the result name. foreach ($map as $token => $length) { // Look for a match. $match = false; foreach ($rtokens as $rtoken) { if (!strncmp($rtoken, $token, $length)) { // This part of the result name has the query token as a prefix. $match = true; break; } } if (!$match) { // We didn't find a match for this query token, so throw the result // away. Try with the next result. unset($results[$key]); break; } } } return $results; } protected function newFunctionResult() { return id(new PhabricatorTypeaheadResult()) ->setTokenType(PhabricatorTypeaheadTokenView::TYPE_FUNCTION) ->setIcon('fa-asterisk') ->addAttribute(pht('Function')); } public function newInvalidToken($name) { return id(new PhabricatorTypeaheadTokenView()) ->setValue($name) ->setIcon('fa-exclamation-circle') ->setTokenType(PhabricatorTypeaheadTokenView::TYPE_INVALID); } public function renderTokens(array $values) { $phids = array(); $setup = array(); $tokens = array(); foreach ($values as $key => $value) { if (!self::isFunctionToken($value)) { $phids[$key] = $value; } else { $function = $this->parseFunction($value); if ($function) { $setup[$function['name']][$key] = $function; } else { $name = pht('Invalid Function: %s', $value); $tokens[$key] = $this->newInvalidToken($name) ->setKey($value); } } } // Give special non-function tokens which are also not PHIDs (like statuses // and priorities) an opportunity to render. $type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN; $special = array(); foreach ($values as $key => $value) { if (phid_get_type($value) == $type_unknown) { $special[$key] = $value; } } if ($special) { $special_tokens = $this->renderSpecialTokens($special); foreach ($special_tokens as $key => $token) { $tokens[$key] = $token; unset($phids[$key]); } } if ($phids) { $handles = $this->getViewer()->loadHandles($phids); foreach ($phids as $key => $phid) { $handle = $handles[$phid]; $tokens[$key] = PhabricatorTypeaheadTokenView::newFromHandle($handle); } } if ($setup) { foreach ($setup as $function_name => $argv_list) { // Render the function tokens. $function_tokens = $this->renderFunctionTokens( $function_name, ipull($argv_list, 'argv')); // Rekey the function tokens using the original array keys. $function_tokens = array_combine( array_keys($argv_list), $function_tokens); // For any functions which were invalid, set their value to the // original input value before it was parsed. foreach ($function_tokens as $key => $token) { $type = $token->getTokenType(); if ($type == PhabricatorTypeaheadTokenView::TYPE_INVALID) { $token->setKey($values[$key]); } } $tokens += $function_tokens; } } return array_select_keys($tokens, array_keys($values)); } protected function renderSpecialTokens(array $values) { return array(); } /* -( Token Functions )---------------------------------------------------- */ /** * @task functions */ public function getDatasourceFunctions() { return array(); } /** * @task functions */ public function getAllDatasourceFunctions() { return $this->getDatasourceFunctions(); } /** * @task functions */ protected function canEvaluateFunction($function) { return $this->shouldStripFunction($function); } /** * @task functions */ protected function shouldStripFunction($function) { $functions = $this->getDatasourceFunctions(); return isset($functions[$function]); } /** * @task functions */ protected function evaluateFunction($function, array $argv_list) { throw new PhutilMethodNotImplementedException(); } /** * @task functions */ protected function evaluateValues(array $values) { return $values; } /** * @task functions */ public function evaluateTokens(array $tokens) { $results = array(); $evaluate = array(); foreach ($tokens as $token) { if (!self::isFunctionToken($token)) { $results[] = $token; } else { // Put a placeholder in the result list so that we retain token order // when possible. We'll overwrite this below. $results[] = null; $evaluate[last_key($results)] = $token; } } $results = $this->evaluateValues($results); foreach ($evaluate as $result_key => $function) { $function = $this->parseFunction($function); if (!$function) { throw new PhabricatorTypeaheadInvalidTokenException(); } $name = $function['name']; $argv = $function['argv']; $evaluated_tokens = $this->evaluateFunction($name, array($argv)); if (!$evaluated_tokens) { unset($results[$result_key]); } else { $is_first = true; foreach ($evaluated_tokens as $phid) { if ($is_first) { $results[$result_key] = $phid; $is_first = false; } else { $results[] = $phid; } } } } $results = array_values($results); $results = $this->didEvaluateTokens($results); return $results; } /** * @task functions */ protected function didEvaluateTokens(array $results) { return $results; } /** * @task functions */ public static function isFunctionToken($token) { // We're looking for a "(" so that a string like "members(q" is identified // and parsed as a function call. This allows us to start generating // results immediately, before the user fully types out "members(quack)". return (strpos($token, '(') !== false); } /** * @task functions */ protected function parseFunction($token, $allow_partial = false) { $matches = null; if ($allow_partial) { $ok = preg_match('/^([^(]+)\((.*?)\)?\z/', $token, $matches); } else { $ok = preg_match('/^([^(]+)\((.*)\)\z/', $token, $matches); } if (!$ok) { if (!$allow_partial) { throw new PhabricatorTypeaheadInvalidTokenException( pht( 'Unable to parse function and arguments for token "%s".', $token)); } return null; } $function = trim($matches[1]); if (!$this->canEvaluateFunction($function)) { if (!$allow_partial) { throw new PhabricatorTypeaheadInvalidTokenException( pht( 'This datasource ("%s") can not evaluate the function "%s(...)".', get_class($this), $function)); } return null; } // TODO: There is currently no way to quote characters in arguments, so // some characters can't be argument characters. Replace this with a real // parser once we get use cases. $argv = $matches[2]; $argv = trim($argv); if (!strlen($argv)) { $argv = array(); } else { $argv = preg_split('/,/', $matches[2]); foreach ($argv as $key => $arg) { $argv[$key] = trim($arg); } } foreach ($argv as $key => $arg) { if (self::isFunctionToken($arg)) { $subfunction = $this->parseFunction($arg); $results = $this->evaluateFunction( $subfunction['name'], array($subfunction['argv'])); $argv[$key] = head($results); } } return array( 'name' => $function, 'argv' => $argv, ); } /** * @task functions */ public function renderFunctionTokens($function, array $argv_list) { throw new PhutilMethodNotImplementedException(); } /** * @task functions */ public function setFunctionStack(array $function_stack) { $this->functionStack = $function_stack; return $this; } /** * @task functions */ public function getFunctionStack() { return $this->functionStack; } /** * @task functions */ protected function getCurrentFunction() { return nonempty(last($this->functionStack), null); } protected function renderTokensFromResults(array $results, array $values) { $tokens = array(); foreach ($values as $key => $value) { if (empty($results[$value])) { continue; } $tokens[$key] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult( $results[$value]); } return $tokens; } public function getWireTokens(array $values) { // TODO: This is a bit hacky for now: we're sort of generating wire // results, rendering them, then reverting them back to wire results. This // is pretty silly. It would probably be much cleaner to make // renderTokens() call this method instead, then render from the result // structure. $rendered = $this->renderTokens($values); $tokens = array(); foreach ($rendered as $key => $render) { $tokens[$key] = id(new PhabricatorTypeaheadResult()) ->setPHID($render->getKey()) ->setIcon($render->getIcon()) ->setColor($render->getColor()) ->setDisplayName($render->getValue()) ->setTokenType($render->getTokenType()); } return mpull($tokens, 'getWireFormat', 'getPHID'); } }