mirror of
https://we.phorge.it/source/phorge.git
synced 2025-03-28 04:00:17 +01:00
Summary: Ref T13682. This is a marginal case, but if you quote someone else's text and it has a file reference embedded in it, that isn't good enough to generate "attachment intent". Since you need both a reference and an explicit attachment, this should never actually affect any user-visible behavior today, but makes the ruleset more thorough. Test Plan: Dragged and dropped a file, referencing it in either a quoted or unquoted block. Saw it attach only for a quoted block. Maniphest Tasks: T13682 Differential Revision: https://secure.phabricator.com/D21833
432 lines
12 KiB
PHP
432 lines
12 KiB
PHP
<?php
|
|
|
|
abstract class PhabricatorObjectRemarkupRule extends PhutilRemarkupRule {
|
|
|
|
private $referencePattern;
|
|
private $embedPattern;
|
|
|
|
const KEY_RULE_OBJECT = 'rule.object';
|
|
const KEY_MENTIONED_OBJECTS = 'rule.object.mentioned';
|
|
|
|
abstract protected function getObjectNamePrefix();
|
|
abstract protected function loadObjects(array $ids);
|
|
|
|
public function getPriority() {
|
|
return 450.0;
|
|
}
|
|
|
|
protected function getObjectNamePrefixBeginsWithWordCharacter() {
|
|
$prefix = $this->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 "(?<![#@-])" prevents us from linking "#abcdef" or similar, and
|
|
// "ABC-T1" (see T5714), and from matching "@T1" as a task (it is a user)
|
|
// (see T9479).
|
|
|
|
// The "\b" allows us to link "(abcdef)" or similar without linking things
|
|
// in the middle of words.
|
|
|
|
$this->referencePattern =
|
|
'((?<![#@-])'.$boundary.$prefix.'('.$id.')(?:#([-\w\d]+))?(?!\w))u';
|
|
}
|
|
|
|
return $this->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());
|
|
}
|
|
|
|
}
|