diff --git a/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php b/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php index 8e2d0cf0c9..9f37bdcaab 100644 --- a/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php +++ b/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php @@ -1,327 +1,358 @@ getEngine(); $this->viewer = $engine->getConfig('viewer'); $objects = id(new PhabricatorFileQuery()) ->setViewer($this->viewer) ->withIDs($ids) ->needTransforms( array( PhabricatorFileThumbnailTransform::TRANSFORM_PREVIEW, )) ->execute(); + $objects = mpull($objects, null, 'getID'); - $phids_key = self::KEY_EMBED_FILE_PHIDS; - $phids = $engine->getTextMetadata($phids_key, array()); - foreach (mpull($objects, 'getPHID') as $phid) { - $phids[] = $phid; + + // Identify files embedded in the block with "attachment intent", i.e. + // those files which the user appears to want to attach to the object. + // Files referenced inside quoted blocks are not considered to have this + // attachment intent. + + $metadata_key = self::KEY_RULE_OBJECT.'.'.$this->getObjectNamePrefix(); + $metadata = $engine->getTextMetadata($metadata_key, array()); + + $attach_key = self::KEY_ATTACH_INTENT_FILE_PHIDS; + $attach_phids = $engine->getTextMetadata($attach_key, array()); + + foreach ($metadata as $item) { + + // If this reference was inside a quoted block, don't count it. Quoting + // someone else doesn't establish an intent to attach a file. + $depth = idx($item, 'quote.depth'); + if ($depth > 0) { + continue; + } + + $id = $item['id']; + $file = idx($objects, $id); + + if (!$file) { + continue; + } + + $attach_phids[] = $file->getPHID(); } - $engine->setTextMetadata($phids_key, $phids); + + $attach_phids = array_fuse($attach_phids); + $attach_phids = array_keys($attach_phids); + + $engine->setTextMetadata($attach_key, $attach_phids); + return $objects; } protected function renderObjectEmbed( $object, PhabricatorObjectHandle $handle, $options) { $options = $this->getFileOptions($options) + array( 'name' => $object->getName(), ); $is_viewable_image = $object->isViewableImage(); $is_audio = $object->isAudio(); $is_video = $object->isVideo(); $force_link = ($options['layout'] == 'link'); // If a file is both audio and video, as with "application/ogg" by default, // render it as video but allow the user to specify `media=audio` if they // want to force it to render as audio. if ($is_audio && $is_video) { $media = $options['media']; if ($media == 'audio') { $is_video = false; } else { $is_audio = false; } } $options['viewable'] = ($is_viewable_image || $is_audio || $is_video); if ($is_viewable_image && !$force_link) { return $this->renderImageFile($object, $handle, $options); } else if ($is_video && !$force_link) { return $this->renderVideoFile($object, $handle, $options); } else if ($is_audio && !$force_link) { return $this->renderAudioFile($object, $handle, $options); } else { return $this->renderFileLink($object, $handle, $options); } } private function getFileOptions($option_string) { $options = array( 'size' => null, 'layout' => 'left', 'float' => false, 'width' => null, 'height' => null, 'alt' => null, 'media' => null, 'autoplay' => null, 'loop' => null, ); if ($option_string) { $option_string = trim($option_string, ', '); $parser = new PhutilSimpleOptions(); $options = $parser->parse($option_string) + $options; } return $options; } private function renderImageFile( PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { require_celerity_resource('phui-lightbox-css'); $attrs = array(); $image_class = 'phabricator-remarkup-embed-image'; $use_size = true; if (!$options['size']) { $width = $this->parseDimension($options['width']); $height = $this->parseDimension($options['height']); if ($width || $height) { $use_size = false; $attrs += array( 'src' => $file->getBestURI(), 'width' => $width, 'height' => $height, ); } } if ($use_size) { switch ((string)$options['size']) { case 'full': $attrs += array( 'src' => $file->getBestURI(), 'height' => $file->getImageHeight(), 'width' => $file->getImageWidth(), ); $image_class = 'phabricator-remarkup-embed-image-full'; break; // Displays "full" in normal Remarkup, "wide" in Documents case 'wide': $attrs += array( 'src' => $file->getBestURI(), 'width' => $file->getImageWidth(), ); $image_class = 'phabricator-remarkup-embed-image-wide'; break; case 'thumb': default: $preview_key = PhabricatorFileThumbnailTransform::TRANSFORM_PREVIEW; $xform = PhabricatorFileTransform::getTransformByKey($preview_key); $existing_xform = $file->getTransform($preview_key); if ($existing_xform) { $xform_uri = $existing_xform->getCDNURI('data'); } else { $xform_uri = $file->getURIForTransform($xform); } $attrs['src'] = $xform_uri; $dimensions = $xform->getTransformedDimensions($file); if ($dimensions) { list($x, $y) = $dimensions; $attrs['width'] = $x; $attrs['height'] = $y; } break; } } $alt = null; if (isset($options['alt'])) { $alt = $options['alt']; } if (!strlen($alt)) { $alt = $file->getAltText(); } $attrs['alt'] = $alt; $img = phutil_tag('img', $attrs); $embed = javelin_tag( 'a', array( 'href' => $file->getBestURI(), 'class' => $image_class, 'sigil' => 'lightboxable', 'meta' => array( 'phid' => $file->getPHID(), 'uri' => $file->getBestURI(), 'dUri' => $file->getDownloadURI(), 'alt' => $alt, 'viewable' => true, 'monogram' => $file->getMonogram(), ), ), $img); switch ($options['layout']) { case 'right': case 'center': case 'inline': case 'left': $layout_class = 'phabricator-remarkup-embed-layout-'.$options['layout']; break; default: $layout_class = 'phabricator-remarkup-embed-layout-left'; break; } if ($options['float']) { switch ($options['layout']) { case 'center': case 'inline': break; case 'right': $layout_class .= ' phabricator-remarkup-embed-float-right'; break; case 'left': default: $layout_class .= ' phabricator-remarkup-embed-float-left'; break; } } return phutil_tag( ($options['layout'] == 'inline' ? 'span' : 'div'), array( 'class' => $layout_class, ), $embed); } private function renderAudioFile( PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { return $this->renderMediaFile('audio', $file, $handle, $options); } private function renderVideoFile( PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { return $this->renderMediaFile('video', $file, $handle, $options); } private function renderMediaFile( $tag, PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { $is_video = ($tag == 'video'); if (idx($options, 'autoplay')) { $preload = 'auto'; $autoplay = 'autoplay'; } else { // If we don't preload video, the user can't see the first frame and // has no clue what they're looking at, so always preload. if ($is_video) { $preload = 'auto'; } else { $preload = 'none'; } $autoplay = null; } // Rendering contexts like feed can disable autoplay. $engine = $this->getEngine(); if ($engine->getConfig('autoplay.disable')) { $autoplay = null; } if ($is_video) { // See T13135. Chrome refuses to play videos with type "video/quicktime", // even though it may actually be able to play them. The least awful fix // based on available information is to simply omit the "type" attribute // from `` tags. This causes Chrome to try to play the video // and realize it can, and does not appear to produce any bad behavior in // any other browser. $mime_type = null; } else { $mime_type = $file->getMimeType(); } return $this->newTag( $tag, array( 'controls' => 'controls', 'preload' => $preload, 'autoplay' => $autoplay, 'loop' => idx($options, 'loop') ? 'loop' : null, 'alt' => $options['alt'], 'class' => 'phabricator-media', ), $this->newTag( 'source', array( 'src' => $file->getBestURI(), 'type' => $mime_type, ))); } private function renderFileLink( PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { return id(new PhabricatorFileLinkView()) ->setViewer($this->viewer) ->setFilePHID($file->getPHID()) ->setFileName($this->assertFlatText($options['name'])) ->setFileDownloadURI($file->getDownloadURI()) ->setFileViewURI($file->getBestURI()) ->setFileViewable((bool)$options['viewable']) ->setFileSize(phutil_format_bytes($file->getByteSize())) ->setFileMonogram($file->getMonogram()); } private function parseDimension($string) { $string = trim($string); if (preg_match('/^(?:\d*\\.)?\d+%?$/', $string)) { return $string; } return null; } } diff --git a/src/infrastructure/markup/PhabricatorMarkupEngine.php b/src/infrastructure/markup/PhabricatorMarkupEngine.php index d897314245..cedd0398e3 100644 --- a/src/infrastructure/markup/PhabricatorMarkupEngine.php +++ b/src/infrastructure/markup/PhabricatorMarkupEngine.php @@ -1,743 +1,743 @@ addObject($comment, $field); * } * * Now, call @{method:process} to perform the actual cache/rendering * step. This is a heavyweight call which does batched data access and * transforms the markup into output. * * $engine->process(); * * Finally, do something with the results: * * $results = array(); * foreach ($comments as $comment) { * $results[] = $engine->getOutput($comment, $field); * } * * If you have a single object to render, you can use the convenience method * @{method:renderOneObject}. * * @task markup Markup Pipeline * @task engine Engine Construction */ final class PhabricatorMarkupEngine extends Phobject { private $objects = array(); private $viewer; private $contextObject; private $version = 21; private $engineCaches = array(); private $auxiliaryConfig = array(); private static $engineStack = array(); /* -( Markup Pipeline )---------------------------------------------------- */ /** * Convenience method for pushing a single object through the markup * pipeline. * * @param PhabricatorMarkupInterface The object to render. * @param string The field to render. * @param PhabricatorUser User viewing the markup. * @param object A context object for policy checks * @return string Marked up output. * @task markup */ public static function renderOneObject( PhabricatorMarkupInterface $object, $field, PhabricatorUser $viewer, $context_object = null) { return id(new PhabricatorMarkupEngine()) ->setViewer($viewer) ->setContextObject($context_object) ->addObject($object, $field) ->process() ->getOutput($object, $field); } /** * Queue an object for markup generation when @{method:process} is * called. You can retrieve the output later with @{method:getOutput}. * * @param PhabricatorMarkupInterface The object to render. * @param string The field to render. * @return this * @task markup */ public function addObject(PhabricatorMarkupInterface $object, $field) { $key = $this->getMarkupFieldKey($object, $field); $this->objects[$key] = array( 'object' => $object, 'field' => $field, ); return $this; } /** * Process objects queued with @{method:addObject}. You can then retrieve * the output with @{method:getOutput}. * * @return this * @task markup */ public function process() { self::$engineStack[] = $this; try { $result = $this->execute(); } finally { array_pop(self::$engineStack); } return $result; } public static function isRenderingEmbeddedContent() { // See T13678. This prevents cycles when rendering embedded content that // itself has remarkup fields. return (count(self::$engineStack) > 1); } private function execute() { $keys = array(); foreach ($this->objects as $key => $info) { if (!isset($info['markup'])) { $keys[] = $key; } } if (!$keys) { return $this; } $objects = array_select_keys($this->objects, $keys); // Build all the markup engines. We need an engine for each field whether // we have a cache or not, since we still need to postprocess the cache. $engines = array(); foreach ($objects as $key => $info) { $engines[$key] = $info['object']->newMarkupEngine($info['field']); $engines[$key]->setConfig('viewer', $this->viewer); $engines[$key]->setConfig('contextObject', $this->contextObject); foreach ($this->auxiliaryConfig as $aux_key => $aux_value) { $engines[$key]->setConfig($aux_key, $aux_value); } } // Load or build the preprocessor caches. $blocks = $this->loadPreprocessorCaches($engines, $objects); $blocks = mpull($blocks, 'getCacheData'); $this->engineCaches = $blocks; // Finalize the output. foreach ($objects as $key => $info) { $engine = $engines[$key]; $field = $info['field']; $object = $info['object']; $output = $engine->postprocessText($blocks[$key]); $output = $object->didMarkupText($field, $output, $engine); $this->objects[$key]['output'] = $output; } return $this; } /** * Get the output of markup processing for a field queued with * @{method:addObject}. Before you can call this method, you must call * @{method:process}. * * @param PhabricatorMarkupInterface The object to retrieve. * @param string The field to retrieve. * @return string Processed output. * @task markup */ public function getOutput(PhabricatorMarkupInterface $object, $field) { $key = $this->getMarkupFieldKey($object, $field); $this->requireKeyProcessed($key); return $this->objects[$key]['output']; } /** * Retrieve engine metadata for a given field. * * @param PhabricatorMarkupInterface The object to retrieve. * @param string The field to retrieve. * @param string The engine metadata field to retrieve. * @param wild Optional default value. * @task markup */ public function getEngineMetadata( PhabricatorMarkupInterface $object, $field, $metadata_key, $default = null) { $key = $this->getMarkupFieldKey($object, $field); $this->requireKeyProcessed($key); return idx($this->engineCaches[$key]['metadata'], $metadata_key, $default); } /** * @task markup */ private function requireKeyProcessed($key) { if (empty($this->objects[$key])) { throw new Exception( pht( "Call %s before using results (key = '%s').", 'addObject()', $key)); } if (!isset($this->objects[$key]['output'])) { throw new PhutilInvalidStateException('process'); } } /** * @task markup */ private function getMarkupFieldKey( PhabricatorMarkupInterface $object, $field) { static $custom; if ($custom === null) { $custom = array_merge( self::loadCustomInlineRules(), self::loadCustomBlockRules()); $custom = mpull($custom, 'getRuleVersion', null); ksort($custom); $custom = PhabricatorHash::digestForIndex(serialize($custom)); } return $object->getMarkupFieldKey($field).'@'.$this->version.'@'.$custom; } /** * @task markup */ private function loadPreprocessorCaches(array $engines, array $objects) { $blocks = array(); $use_cache = array(); foreach ($objects as $key => $info) { if ($info['object']->shouldUseMarkupCache($info['field'])) { $use_cache[$key] = true; } } if ($use_cache) { try { $blocks = id(new PhabricatorMarkupCache())->loadAllWhere( 'cacheKey IN (%Ls)', array_keys($use_cache)); $blocks = mpull($blocks, null, 'getCacheKey'); } catch (Exception $ex) { phlog($ex); } } $is_readonly = PhabricatorEnv::isReadOnly(); foreach ($objects as $key => $info) { // False check in case MySQL doesn't support unicode characters // in the string (T1191), resulting in unserialize returning false. if (isset($blocks[$key]) && $blocks[$key]->getCacheData() !== false) { // If we already have a preprocessing cache, we don't need to rebuild // it. continue; } $text = $info['object']->getMarkupText($info['field']); $data = $engines[$key]->preprocessText($text); // NOTE: This is just debugging information to help sort out cache issues. // If one machine is misconfigured and poisoning caches you can use this // field to hunt it down. $metadata = array( 'host' => php_uname('n'), ); $blocks[$key] = id(new PhabricatorMarkupCache()) ->setCacheKey($key) ->setCacheData($data) ->setMetadata($metadata); if (isset($use_cache[$key]) && !$is_readonly) { // This is just filling a cache and always safe, even on a read pathway. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $blocks[$key]->replace(); unset($unguarded); } } return $blocks; } /** * Set the viewing user. Used to implement object permissions. * * @param PhabricatorUser The viewing user. * @return this * @task markup */ public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } /** * Set the context object. Used to implement object permissions. * * @param The object in which context this remarkup is used. * @return this * @task markup */ public function setContextObject($object) { $this->contextObject = $object; return $this; } public function setAuxiliaryConfig($key, $value) { // TODO: This is gross and should be removed. Avoid use. $this->auxiliaryConfig[$key] = $value; return $this; } /* -( Engine Construction )------------------------------------------------ */ /** * @task engine */ public static function newManiphestMarkupEngine() { return self::newMarkupEngine(array( )); } /** * @task engine */ public static function newPhrictionMarkupEngine() { return self::newMarkupEngine(array( 'header.generate-toc' => true, )); } /** * @task engine */ public static function newPhameMarkupEngine() { return self::newMarkupEngine( array( 'macros' => false, 'uri.full' => true, 'uri.same-window' => true, 'uri.base' => PhabricatorEnv::getURI('/'), )); } /** * @task engine */ public static function newFeedMarkupEngine() { return self::newMarkupEngine( array( 'macros' => false, 'youtube' => false, )); } /** * @task engine */ public static function newCalendarMarkupEngine() { return self::newMarkupEngine(array( )); } /** * @task engine */ public static function newDifferentialMarkupEngine(array $options = array()) { return self::newMarkupEngine(array( 'differential.diff' => idx($options, 'differential.diff'), )); } /** * @task engine */ public static function newDiffusionMarkupEngine(array $options = array()) { return self::newMarkupEngine(array( 'header.generate-toc' => true, )); } /** * @task engine */ public static function getEngine($ruleset = 'default') { static $engines = array(); if (isset($engines[$ruleset])) { return $engines[$ruleset]; } $engine = null; switch ($ruleset) { case 'default': $engine = self::newMarkupEngine(array()); break; case 'feed': $engine = self::newMarkupEngine(array()); $engine->setConfig('autoplay.disable', true); break; case 'nolinebreaks': $engine = self::newMarkupEngine(array()); $engine->setConfig('preserve-linebreaks', false); break; case 'diffusion-readme': $engine = self::newMarkupEngine(array()); $engine->setConfig('preserve-linebreaks', false); $engine->setConfig('header.generate-toc', true); break; case 'diviner': $engine = self::newMarkupEngine(array()); $engine->setConfig('preserve-linebreaks', false); // $engine->setConfig('diviner.renderer', new DivinerDefaultRenderer()); $engine->setConfig('header.generate-toc', true); break; case 'extract': // Engine used for reference/edge extraction. Turn off anything which // is slow and doesn't change reference extraction. $engine = self::newMarkupEngine(array()); $engine->setConfig('pygments.enabled', false); break; default: throw new Exception(pht('Unknown engine ruleset: %s!', $ruleset)); } $engines[$ruleset] = $engine; return $engine; } /** * @task engine */ private static function getMarkupEngineDefaultConfiguration() { return array( 'pygments' => PhabricatorEnv::getEnvConfig('pygments.enabled'), 'youtube' => PhabricatorEnv::getEnvConfig( 'remarkup.enable-embedded-youtube'), 'differential.diff' => null, 'header.generate-toc' => false, 'macros' => true, 'uri.allowed-protocols' => PhabricatorEnv::getEnvConfig( 'uri.allowed-protocols'), 'uri.full' => false, 'syntax-highlighter.engine' => PhabricatorEnv::getEnvConfig( 'syntax-highlighter.engine'), 'preserve-linebreaks' => true, ); } /** * @task engine */ public static function newMarkupEngine(array $options) { $options += self::getMarkupEngineDefaultConfiguration(); $engine = new PhutilRemarkupEngine(); $engine->setConfig('preserve-linebreaks', $options['preserve-linebreaks']); $engine->setConfig('pygments.enabled', $options['pygments']); $engine->setConfig( 'uri.allowed-protocols', $options['uri.allowed-protocols']); $engine->setConfig('differential.diff', $options['differential.diff']); $engine->setConfig('header.generate-toc', $options['header.generate-toc']); $engine->setConfig( 'syntax-highlighter.engine', $options['syntax-highlighter.engine']); $style_map = id(new PhabricatorDefaultSyntaxStyle()) ->getRemarkupStyleMap(); $engine->setConfig('phutil.codeblock.style-map', $style_map); $engine->setConfig('uri.full', $options['uri.full']); if (isset($options['uri.base'])) { $engine->setConfig('uri.base', $options['uri.base']); } if (isset($options['uri.same-window'])) { $engine->setConfig('uri.same-window', $options['uri.same-window']); } $rules = array(); $rules[] = new PhutilRemarkupEscapeRemarkupRule(); $rules[] = new PhutilRemarkupEvalRule(); $rules[] = new PhutilRemarkupMonospaceRule(); $rules[] = new PhutilRemarkupDocumentLinkRule(); $rules[] = new PhabricatorNavigationRemarkupRule(); $rules[] = new PhabricatorKeyboardRemarkupRule(); $rules[] = new PhabricatorConfigRemarkupRule(); if ($options['youtube']) { $rules[] = new PhabricatorYoutubeRemarkupRule(); } $rules[] = new PhabricatorIconRemarkupRule(); $rules[] = new PhabricatorEmojiRemarkupRule(); $rules[] = new PhabricatorHandleRemarkupRule(); $applications = PhabricatorApplication::getAllInstalledApplications(); foreach ($applications as $application) { foreach ($application->getRemarkupRules() as $rule) { $rules[] = $rule; } } $rules[] = new PhutilRemarkupHyperlinkRule(); if ($options['macros']) { $rules[] = new PhabricatorImageMacroRemarkupRule(); $rules[] = new PhabricatorMemeRemarkupRule(); } $rules[] = new PhutilRemarkupBoldRule(); $rules[] = new PhutilRemarkupItalicRule(); $rules[] = new PhutilRemarkupDelRule(); $rules[] = new PhutilRemarkupUnderlineRule(); $rules[] = new PhutilRemarkupHighlightRule(); $rules[] = new PhutilRemarkupAnchorRule(); foreach (self::loadCustomInlineRules() as $rule) { $rules[] = clone $rule; } $blocks = array(); $blocks[] = new PhutilRemarkupQuotesBlockRule(); $blocks[] = new PhutilRemarkupReplyBlockRule(); $blocks[] = new PhutilRemarkupLiteralBlockRule(); $blocks[] = new PhutilRemarkupHeaderBlockRule(); $blocks[] = new PhutilRemarkupHorizontalRuleBlockRule(); $blocks[] = new PhutilRemarkupListBlockRule(); $blocks[] = new PhutilRemarkupCodeBlockRule(); $blocks[] = new PhutilRemarkupNoteBlockRule(); $blocks[] = new PhutilRemarkupTableBlockRule(); $blocks[] = new PhutilRemarkupSimpleTableBlockRule(); $blocks[] = new PhutilRemarkupInterpreterBlockRule(); $blocks[] = new PhutilRemarkupDefaultBlockRule(); foreach (self::loadCustomBlockRules() as $rule) { $blocks[] = $rule; } foreach ($blocks as $block) { $block->setMarkupRules($rules); } $engine->setBlockRules($blocks); return $engine; } public static function extractPHIDsFromMentions( PhabricatorUser $viewer, array $content_blocks) { $mentions = array(); $engine = self::newDifferentialMarkupEngine(); $engine->setConfig('viewer', $viewer); foreach ($content_blocks as $content_block) { if ($content_block === null) { continue; } if (!strlen($content_block)) { continue; } $engine->markupText($content_block); $phids = $engine->getTextMetadata( PhabricatorMentionRemarkupRule::KEY_MENTIONED, array()); $mentions += $phids; } return $mentions; } public static function extractFilePHIDsFromEmbeddedFiles( PhabricatorUser $viewer, array $content_blocks) { $files = array(); $engine = self::newDifferentialMarkupEngine(); $engine->setConfig('viewer', $viewer); foreach ($content_blocks as $content_block) { $engine->markupText($content_block); $phids = $engine->getTextMetadata( - PhabricatorEmbedFileRemarkupRule::KEY_EMBED_FILE_PHIDS, + PhabricatorEmbedFileRemarkupRule::KEY_ATTACH_INTENT_FILE_PHIDS, array()); foreach ($phids as $phid) { $files[$phid] = $phid; } } return array_values($files); } public static function summarizeSentence($corpus) { $corpus = trim($corpus); $blocks = preg_split('/\n+/', $corpus, 2); $block = head($blocks); $sentences = preg_split( '/\b([.?!]+)\B/u', $block, 2, PREG_SPLIT_DELIM_CAPTURE); if (count($sentences) > 1) { $result = $sentences[0].$sentences[1]; } else { $result = head($sentences); } return id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(128) ->truncateString($result); } /** * Produce a corpus summary, in a way that shortens the underlying text * without truncating it somewhere awkward. * * TODO: We could do a better job of this. * * @param string Remarkup corpus to summarize. * @return string Summarized corpus. */ public static function summarize($corpus) { // Major goals here are: // - Don't split in the middle of a character (utf-8). // - Don't split in the middle of, e.g., **bold** text, since // we end up with hanging '**' in the summary. // - Try not to pick an image macro, header, embedded file, etc. // - Hopefully don't return too much text. We don't explicitly limit // this right now. $blocks = preg_split("/\n *\n\s*/", $corpus); $best = null; foreach ($blocks as $block) { // This is a test for normal spaces in the block, i.e. a heuristic to // distinguish standard paragraphs from things like image macros. It may // not work well for non-latin text. We prefer to summarize with a // paragraph of normal words over an image macro, if possible. $has_space = preg_match('/\w\s\w/', $block); // This is a test to find embedded images and headers. We prefer to // summarize with a normal paragraph over a header or an embedded object, // if possible. $has_embed = preg_match('/^[{=]/', $block); if ($has_space && !$has_embed) { // This seems like a good summary, so return it. return $block; } if (!$best) { // This is the first block we found; if everything is garbage just // use the first block. $best = $block; } } return $best; } private static function loadCustomInlineRules() { return id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorRemarkupCustomInlineRule') ->execute(); } private static function loadCustomBlockRules() { return id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorRemarkupCustomBlockRule') ->execute(); } public static function digestRemarkupContent($object, $content) { $parts = array(); $parts[] = get_class($object); if ($object instanceof PhabricatorLiskDAO) { $parts[] = $object->getID(); } $parts[] = $content; $message = implode("\n", $parts); return PhabricatorHash::digestWithNamedKey($message, 'remarkup'); } } diff --git a/src/infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php b/src/infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php index feac6cffa9..ac3a308d06 100644 --- a/src/infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php +++ b/src/infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php @@ -1,170 +1,178 @@ addInt($this->getPriority()) ->addString(get_class($this)); } abstract public function markupText($text, $children); /** * This will get an array of unparsed lines and return the number of lines * from the first array value that it can parse. * * @param array $lines * @param int $cursor * * @return int */ abstract public function getMatchingLineCount(array $lines, $cursor); protected function didMarkupText() { return; } + public function willMarkupChildBlocks() { + return; + } + + public function didMarkupChildBlocks() { + return; + } + final public function setEngine(PhutilRemarkupEngine $engine) { $this->engine = $engine; $this->updateRules(); return $this; } final protected function getEngine() { return $this->engine; } public function setMarkupRules(array $rules) { assert_instances_of($rules, 'PhutilRemarkupRule'); $this->rules = $rules; $this->updateRules(); return $this; } private function updateRules() { $engine = $this->getEngine(); if ($engine) { $this->rules = msort($this->rules, 'getPriority'); foreach ($this->rules as $rule) { $rule->setEngine($engine); } } return $this; } final public function getMarkupRules() { return $this->rules; } final public function postprocess() { $this->didMarkupText(); } final protected function applyRules($text) { foreach ($this->getMarkupRules() as $rule) { $text = $rule->apply($text); } return $text; } public function supportsChildBlocks() { return false; } public function extractChildText($text) { throw new PhutilMethodNotImplementedException(); } protected function renderRemarkupTable(array $out_rows) { assert_instances_of($out_rows, 'array'); if ($this->getEngine()->isTextMode()) { $lengths = array(); foreach ($out_rows as $r => $row) { foreach ($row['content'] as $c => $cell) { $text = $this->getEngine()->restoreText($cell['content']); $lengths[$c][$r] = phutil_utf8_strlen($text); } } $max_lengths = array_map('max', $lengths); $out = array(); foreach ($out_rows as $r => $row) { $headings = false; foreach ($row['content'] as $c => $cell) { $length = $max_lengths[$c] - $lengths[$c][$r]; $out[] = '| '.$cell['content'].str_repeat(' ', $length).' '; if ($cell['type'] == 'th') { $headings = true; } } $out[] = "|\n"; if ($headings) { foreach ($row['content'] as $c => $cell) { $char = ($cell['type'] == 'th' ? '-' : ' '); $out[] = '| '.str_repeat($char, $max_lengths[$c]).' '; } $out[] = "|\n"; } } return rtrim(implode('', $out), "\n"); } if ($this->getEngine()->isHTMLMailMode()) { $table_attributes = array( 'style' => 'border-collapse: separate; border-spacing: 1px; background: #d3d3d3; margin: 12px 0;', ); $cell_attributes = array( 'style' => 'background: #ffffff; padding: 3px 6px;', ); } else { $table_attributes = array( 'class' => 'remarkup-table', ); $cell_attributes = array(); } $out = array(); $out[] = "\n"; foreach ($out_rows as $row) { $cells = array(); foreach ($row['content'] as $cell) { $cells[] = phutil_tag( $cell['type'], $cell_attributes, $cell['content']); } $out[] = phutil_tag($row['type'], array(), $cells); $out[] = "\n"; } $table = phutil_tag('table', $table_attributes, $out); return phutil_tag_div('remarkup-table-wrap', $table); } } diff --git a/src/infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php b/src/infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php index 9f2bd7a297..90c9a2c33a 100644 --- a/src/infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php +++ b/src/infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php @@ -1,110 +1,126 @@ getEngine(); + + $depth = $engine->getQuoteDepth(); + $depth = $depth + 1; + $engine->setQuoteDepth($depth); + } + + public function didMarkupChildBlocks() { + $engine = $this->getEngine(); + + $depth = $engine->getQuoteDepth(); + $depth = $depth - 1; + $engine->setQuoteDepth($depth); + } + final protected function normalizeQuotedBody($text) { $text = phutil_split_lines($text, true); foreach ($text as $key => $line) { $text[$key] = substr($line, 1); } // If every line in the block is empty or begins with at least one leading // space, strip the initial space off each line. When we quote text, we // normally add "> " (with a space) to the beginning of each line, which // can disrupt some other rules. If the block appears to have this space // in front of each line, remove it. $strip_space = true; foreach ($text as $key => $line) { $len = strlen($line); if (!$len) { // We'll still strip spaces if there are some completely empty // lines, they may have just had trailing whitespace trimmed. continue; } // If this line is part of a nested quote block, just ignore it when // realigning this quote block. It's either an author attribution // line with ">>!", or we'll deal with it in a subrule when processing // the nested quote block. if ($line[0] == '>') { continue; } if ($line[0] == ' ' || $line[0] == "\n") { continue; } // The first character of this line is something other than a space, so // we can't strip spaces. $strip_space = false; break; } if ($strip_space) { foreach ($text as $key => $line) { $len = strlen($line); if (!$len) { continue; } if ($line[0] !== ' ') { continue; } $text[$key] = substr($line, 1); } } // Strip leading empty lines. foreach ($text as $key => $line) { if (!strlen(trim($line))) { unset($text[$key]); } else { break; } } return implode('', $text); } final protected function getQuotedText($text) { $text = rtrim($text, "\n"); $no_whitespace = array( // For readability, we render nested quotes as ">> quack", // not "> > quack". '>' => true, // If the line is empty except for a newline, do not add an // unnecessary dangling space. "\n" => true, ); $text = phutil_split_lines($text, true); foreach ($text as $key => $line) { $c = null; if (isset($line[0])) { $c = $line[0]; } else { $c = null; } if (isset($no_whitespace[$c])) { $text[$key] = '>'.$line; } else { $text[$key] = '> '.$line; } } $text = implode('', $text); return $text; } } diff --git a/src/infrastructure/markup/remarkup/PhutilRemarkupEngine.php b/src/infrastructure/markup/remarkup/PhutilRemarkupEngine.php index a0a379aaf9..bedbf0bab7 100644 --- a/src/infrastructure/markup/remarkup/PhutilRemarkupEngine.php +++ b/src/infrastructure/markup/remarkup/PhutilRemarkupEngine.php @@ -1,357 +1,371 @@ config[$key] = $value; return $this; } public function getConfig($key, $default = null) { return idx($this->config, $key, $default); } public function setMode($mode) { $this->mode = $mode; return $this; } public function isTextMode() { return $this->mode & self::MODE_TEXT; } public function isAnchorMode() { return $this->getState('toc'); } public function isHTMLMailMode() { return $this->mode & self::MODE_HTML_MAIL; } + public function getQuoteDepth() { + return $this->getConfig('runtime.quote.depth', 0); + } + + public function setQuoteDepth($depth) { + return $this->setConfig('runtime.quote.depth', $depth); + } + public function setBlockRules(array $rules) { assert_instances_of($rules, 'PhutilRemarkupBlockRule'); $rules = msortv($rules, 'getPriorityVector'); $this->blockRules = $rules; foreach ($this->blockRules as $rule) { $rule->setEngine($this); } $post_rules = array(); foreach ($this->blockRules as $block_rule) { foreach ($block_rule->getMarkupRules() as $rule) { $key = $rule->getPostprocessKey(); if ($key !== null) { $post_rules[$key] = $rule; } } } $this->postprocessRules = $post_rules; return $this; } public function getTextMetadata($key, $default = null) { if (isset($this->metadata[$key])) { return $this->metadata[$key]; } return idx($this->metadata, $key, $default); } public function setTextMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function storeText($text) { if ($this->isTextMode()) { $text = phutil_safe_html($text); } return $this->storage->store($text); } public function overwriteStoredText($token, $new_text) { if ($this->isTextMode()) { $new_text = phutil_safe_html($new_text); } $this->storage->overwrite($token, $new_text); return $this; } public function markupText($text) { return $this->postprocessText($this->preprocessText($text)); } public function pushState($state) { if (empty($this->states[$state])) { $this->states[$state] = 0; } $this->states[$state]++; return $this; } public function popState($state) { if (empty($this->states[$state])) { throw new Exception(pht("State '%s' pushed more than popped!", $state)); } $this->states[$state]--; if (!$this->states[$state]) { unset($this->states[$state]); } return $this; } public function getState($state) { return !empty($this->states[$state]); } public function preprocessText($text) { $this->metadata = array(); $this->storage = new PhutilRemarkupBlockStorage(); $blocks = $this->splitTextIntoBlocks($text); $output = array(); foreach ($blocks as $block) { $output[] = $this->markupBlock($block); } $output = $this->flattenOutput($output); $map = $this->storage->getMap(); $this->storage = null; $metadata = $this->metadata; return array( 'output' => $output, 'storage' => $map, 'metadata' => $metadata, ); } private function splitTextIntoBlocks($text, $depth = 0) { // Apply basic block and paragraph normalization to the text. NOTE: We don't // strip trailing whitespace because it is semantic in some contexts, // notably inlined diffs that the author intends to show as a code block. $text = phutil_split_lines($text, true); $block_rules = $this->blockRules; $blocks = array(); $cursor = 0; $can_merge = array(); foreach ($block_rules as $key => $block_rule) { if ($block_rule instanceof PhutilRemarkupDefaultBlockRule) { $can_merge[$key] = true; } } $last_block = null; $last_block_key = -1; // See T13487. For very large inputs, block separation can dominate // runtime. This is written somewhat clumsily to attempt to handle // very large inputs as gracefully as is practical. while (isset($text[$cursor])) { $starting_cursor = $cursor; foreach ($block_rules as $block_key => $block_rule) { $num_lines = $block_rule->getMatchingLineCount($text, $cursor); if ($num_lines) { $current_block = array( 'start' => $cursor, 'num_lines' => $num_lines, 'rule' => $block_rule, 'empty' => self::isEmptyBlock($text, $cursor, $num_lines), 'children' => array(), 'merge' => isset($can_merge[$block_key]), ); $should_merge = self::shouldMergeParagraphBlocks( $text, $last_block, $current_block); if ($should_merge) { $last_block['num_lines'] = ($last_block['num_lines'] + $current_block['num_lines']); $last_block['empty'] = ($last_block['empty'] && $current_block['empty']); $blocks[$last_block_key] = $last_block; } else { $blocks[] = $current_block; $last_block = $current_block; $last_block_key++; } $cursor += $num_lines; break; } } if ($starting_cursor === $cursor) { throw new Exception(pht('Block in text did not match any block rule.')); } } // See T13487. It's common for blocks to be small, and this loop seems to // measure as faster if we manually concatenate blocks than if we // "array_slice()" and "implode()" blocks. This is a bit muddy. foreach ($blocks as $key => $block) { $min = $block['start']; $max = $min + $block['num_lines']; $lines = ''; for ($ii = $min; $ii < $max; $ii++) { $lines .= $text[$ii]; } $blocks[$key]['text'] = $lines; } // Stop splitting child blocks apart if we get too deep. This arrests // any blocks which have looping child rules, and stops the stack from // exploding if someone writes a hilarious comment with 5,000 levels of // quoted text. if ($depth < self::MAX_CHILD_DEPTH) { foreach ($blocks as $key => $block) { $rule = $block['rule']; if (!$rule->supportsChildBlocks()) { continue; } list($parent_text, $child_text) = $rule->extractChildText( $block['text']); $blocks[$key]['text'] = $parent_text; $blocks[$key]['children'] = $this->splitTextIntoBlocks( $child_text, $depth + 1); } } return $blocks; } private function markupBlock(array $block) { + $rule = $block['rule']; + + $rule->willMarkupChildBlocks(); + $children = array(); foreach ($block['children'] as $child) { $children[] = $this->markupBlock($child); } + $rule->didMarkupChildBlocks(); + if ($children) { $children = $this->flattenOutput($children); } else { $children = null; } - return $block['rule']->markupText($block['text'], $children); + return $rule->markupText($block['text'], $children); } private function flattenOutput(array $output) { if ($this->isTextMode()) { $output = implode("\n\n", $output)."\n"; } else { $output = phutil_implode_html("\n\n", $output); } return $output; } private static function shouldMergeParagraphBlocks( $text, $last_block, $current_block) { // If we're at the beginning of the input, we can't merge. if ($last_block === null) { return false; } // If the previous block wasn't a default block, we can't merge. if (!$last_block['merge']) { return false; } // If the current block isn't a default block, we can't merge. if (!$current_block['merge']) { return false; } // If the last block was empty, we definitely want to merge. if ($last_block['empty']) { return true; } // If this block is empty, we definitely want to merge. if ($current_block['empty']) { return true; } // Check if the last line of the previous block or the first line of this // block have any non-whitespace text. If they both do, we're going to // merge. // If either of them are a blank line or a line with only whitespace, we // do not merge: this means we've found a paragraph break. $tail = $text[$current_block['start'] - 1]; $head = $text[$current_block['start']]; if (strlen(trim($tail)) && strlen(trim($head))) { return true; } return false; } private static function isEmptyBlock($text, $start, $num_lines) { for ($cursor = $start; $cursor < $start + $num_lines; $cursor++) { if (strlen(trim($text[$cursor]))) { return false; } } return true; } public function postprocessText(array $dict) { $this->metadata = idx($dict, 'metadata', array()); $this->storage = new PhutilRemarkupBlockStorage(); $this->storage->setMap(idx($dict, 'storage', array())); foreach ($this->blockRules as $block_rule) { $block_rule->postprocess(); } foreach ($this->postprocessRules as $rule) { $rule->didMarkupText(); } return $this->restoreText(idx($dict, 'output')); } public function restoreText($text) { return $this->storage->restore($text, $this->isTextMode()); } } diff --git a/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php b/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php index 5ab033d6b1..b0399527b0 100644 --- a/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php +++ b/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php @@ -1,430 +1,432 @@ getObjectNamePrefix(); return preg_match('/^\w/', $prefix); } protected function getObjectIDPattern() { return '[1-9]\d*'; } protected function shouldMarkupObject(array $params) { return true; } protected function getObjectNameText( $object, PhabricatorObjectHandle $handle, $id) { return $this->getObjectNamePrefix().$id; } protected function loadHandles(array $objects) { $phids = mpull($objects, 'getPHID'); $viewer = $this->getEngine()->getConfig('viewer'); $handles = $viewer->loadHandles($phids); $handles = iterator_to_array($handles); $result = array(); foreach ($objects as $id => $object) { $result[$id] = $handles[$object->getPHID()]; } return $result; } protected function getObjectHref( $object, PhabricatorObjectHandle $handle, $id) { $uri = $handle->getURI(); if ($this->getEngine()->getConfig('uri.full')) { $uri = PhabricatorEnv::getURI($uri); } return $uri; } protected function renderObjectRefForAnyMedia( $object, PhabricatorObjectHandle $handle, $anchor, $id) { $href = $this->getObjectHref($object, $handle, $id); $text = $this->getObjectNameText($object, $handle, $id); if ($anchor) { $href = $href.'#'.$anchor; $text = $text.'#'.$anchor; } if ($this->getEngine()->isTextMode()) { return $text.' <'.PhabricatorEnv::getProductionURI($href).'>'; } else if ($this->getEngine()->isHTMLMailMode()) { $href = PhabricatorEnv::getProductionURI($href); return $this->renderObjectTagForMail($text, $href, $handle); } return $this->renderObjectRef($object, $handle, $anchor, $id); } protected function renderObjectRef( $object, PhabricatorObjectHandle $handle, $anchor, $id) { $href = $this->getObjectHref($object, $handle, $id); $text = $this->getObjectNameText($object, $handle, $id); $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; if ($anchor) { $href = $href.'#'.$anchor; $text = $text.'#'.$anchor; } $attr = array( 'phid' => $handle->getPHID(), 'closed' => ($handle->getStatus() == $status_closed), ); return $this->renderHovertag($text, $href, $attr); } protected function renderObjectEmbedForAnyMedia( $object, PhabricatorObjectHandle $handle, $options) { $name = $handle->getFullName(); $href = $handle->getURI(); if ($this->getEngine()->isTextMode()) { return $name.' <'.PhabricatorEnv::getProductionURI($href).'>'; } else if ($this->getEngine()->isHTMLMailMode()) { $href = PhabricatorEnv::getProductionURI($href); return $this->renderObjectTagForMail($name, $href, $handle); } // See T13678. If we're already rendering embedded content, render a // default reference instead to avoid cycles. if (PhabricatorMarkupEngine::isRenderingEmbeddedContent()) { return $this->renderDefaultObjectEmbed($object, $handle); } return $this->renderObjectEmbed($object, $handle, $options); } protected function renderObjectEmbed( $object, PhabricatorObjectHandle $handle, $options) { return $this->renderDefaultObjectEmbed($object, $handle); } final protected function renderDefaultObjectEmbed( $object, PhabricatorObjectHandle $handle) { $name = $handle->getFullName(); $href = $handle->getURI(); $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; $attr = array( 'phid' => $handle->getPHID(), 'closed' => ($handle->getStatus() == $status_closed), ); return $this->renderHovertag($name, $href, $attr); } protected function renderObjectTagForMail( $text, $href, PhabricatorObjectHandle $handle) { $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; $strikethrough = $handle->getStatus() == $status_closed ? 'text-decoration: line-through;' : 'text-decoration: none;'; return phutil_tag( 'a', array( 'href' => $href, 'style' => 'background-color: #e7e7e7; border-color: #e7e7e7; border-radius: 3px; padding: 0 4px; font-weight: bold; color: black;' .$strikethrough, ), $text); } protected function renderHovertag($name, $href, array $attr = array()) { return id(new PHUITagView()) ->setName($name) ->setHref($href) ->setType(PHUITagView::TYPE_OBJECT) ->setPHID(idx($attr, 'phid')) ->setClosed(idx($attr, 'closed')) ->render(); } public function apply($text) { $text = preg_replace_callback( $this->getObjectEmbedPattern(), array($this, 'markupObjectEmbed'), $text); $text = preg_replace_callback( $this->getObjectReferencePattern(), array($this, 'markupObjectReference'), $text); return $text; } private function getObjectEmbedPattern() { if ($this->embedPattern === null) { $prefix = $this->getObjectNamePrefix(); $prefix = preg_quote($prefix); $id = $this->getObjectIDPattern(); $this->embedPattern = '(\B{'.$prefix.'('.$id.')([,\s](?:[^}\\\\]|\\\\.)*)?}\B)u'; } return $this->embedPattern; } private function getObjectReferencePattern() { if ($this->referencePattern === null) { $prefix = $this->getObjectNamePrefix(); $prefix = preg_quote($prefix); $id = $this->getObjectIDPattern(); // If the prefix starts with a word character (like "D"), we want to // require a word boundary so that we don't match "XD1" as "D1". If the // prefix does not start with a word character, we want to require no word // boundary for the same reasons. Test if the prefix starts with a word // character. if ($this->getObjectNamePrefixBeginsWithWordCharacter()) { $boundary = '\\b'; } else { $boundary = '\\B'; } // The "(?referencePattern = '((?referencePattern; } /** * Extract matched object references from a block of text. * * This is intended to make it easy to write unit tests for object remarkup * rules. Production code is not normally expected to call this method. * * @param string Text to match rules against. * @return wild Matches, suitable for writing unit tests against. */ public function extractReferences($text) { $embed_matches = null; preg_match_all( $this->getObjectEmbedPattern(), $text, $embed_matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $ref_matches = null; preg_match_all( $this->getObjectReferencePattern(), $text, $ref_matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $results = array(); $sets = array( 'embed' => $embed_matches, 'ref' => $ref_matches, ); foreach ($sets as $type => $matches) { $formatted = array(); foreach ($matches as $match) { $format = array( 'offset' => $match[1][1], 'id' => $match[1][0], ); if (isset($match[2][0])) { $format['tail'] = $match[2][0]; } $formatted[] = $format; } $results[$type] = $formatted; } return $results; } public function markupObjectEmbed(array $matches) { if (!$this->isFlatText($matches[0])) { return $matches[0]; } // If we're rendering a table of contents, just render the raw input. // This could perhaps be handled more gracefully but it seems unusual to // put something like "{P123}" in a header and it's not obvious what users // expect? See T8845. $engine = $this->getEngine(); if ($engine->getState('toc')) { return $matches[0]; } return $this->markupObject(array( 'type' => 'embed', 'id' => $matches[1], 'options' => idx($matches, 2), 'original' => $matches[0], + 'quote.depth' => $engine->getQuoteDepth(), )); } public function markupObjectReference(array $matches) { if (!$this->isFlatText($matches[0])) { return $matches[0]; } // If we're rendering a table of contents, just render the monogram. $engine = $this->getEngine(); if ($engine->getState('toc')) { return $matches[0]; } return $this->markupObject(array( 'type' => 'ref', 'id' => $matches[1], 'anchor' => idx($matches, 2), 'original' => $matches[0], + 'quote.depth' => $engine->getQuoteDepth(), )); } private function markupObject(array $params) { if (!$this->shouldMarkupObject($params)) { return $params['original']; } $regex = trim( PhabricatorEnv::getEnvConfig('remarkup.ignored-object-names')); if ($regex && preg_match($regex, $params['original'])) { return $params['original']; } $engine = $this->getEngine(); $token = $engine->storeText('x'); $metadata_key = self::KEY_RULE_OBJECT.'.'.$this->getObjectNamePrefix(); $metadata = $engine->getTextMetadata($metadata_key, array()); $metadata[] = array( 'token' => $token, ) + $params; $engine->setTextMetadata($metadata_key, $metadata); return $token; } public function didMarkupText() { $engine = $this->getEngine(); $metadata_key = self::KEY_RULE_OBJECT.'.'.$this->getObjectNamePrefix(); $metadata = $engine->getTextMetadata($metadata_key, array()); if (!$metadata) { return; } $ids = ipull($metadata, 'id'); $objects = $this->loadObjects($ids); // For objects that are invalid or which the user can't see, just render // the original text. // TODO: We should probably distinguish between these cases and render a // "you can't see this" state for nonvisible objects. foreach ($metadata as $key => $spec) { if (empty($objects[$spec['id']])) { $engine->overwriteStoredText( $spec['token'], $spec['original']); unset($metadata[$key]); } } $phids = $engine->getTextMetadata(self::KEY_MENTIONED_OBJECTS, array()); foreach ($objects as $object) { $phids[$object->getPHID()] = $object->getPHID(); } $engine->setTextMetadata(self::KEY_MENTIONED_OBJECTS, $phids); $handles = $this->loadHandles($objects); foreach ($metadata as $key => $spec) { $handle = $handles[$spec['id']]; $object = $objects[$spec['id']]; switch ($spec['type']) { case 'ref': $view = $this->renderObjectRefForAnyMedia( $object, $handle, $spec['anchor'], $spec['id']); break; case 'embed': $spec['options'] = $this->assertFlatText($spec['options']); $view = $this->renderObjectEmbedForAnyMedia( $object, $handle, $spec['options']); break; } $engine->overwriteStoredText($spec['token'], $view); } $engine->setTextMetadata($metadata_key, array()); } }