mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-28 16:30:59 +01:00
Add remarkup support for Asana URIs
Summary: Ref T2852. Primarily, this expands API access to Asana. As a user-visible effect, it links Asana tasks in Remarkup. When a user enters an Asana URI, we register an onload behavior to make an Ajax call for the lookup. This respects privacy imposed by the API without creating a significant performance impact. Test Plan: {F47183} Reviewers: btrahan Reviewed By: btrahan CC: chad, aran Maniphest Tasks: T2852 Differential Revision: https://secure.phabricator.com/D6274
This commit is contained in:
parent
e723b7e119
commit
b22e52e40c
13 changed files with 389 additions and 10 deletions
|
@ -1652,6 +1652,20 @@ celerity_register_resource_map(array(
|
|||
),
|
||||
'disk' => '/rsrc/js/application/diffusion/behavior-pull-lastmodified.js',
|
||||
),
|
||||
'javelin-behavior-doorkeeper-tag' =>
|
||||
array(
|
||||
'uri' => '/res/59480572/rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js',
|
||||
'type' => 'js',
|
||||
'requires' =>
|
||||
array(
|
||||
0 => 'javelin-behavior',
|
||||
1 => 'javelin-dom',
|
||||
2 => 'javelin-json',
|
||||
3 => 'javelin-workflow',
|
||||
4 => 'javelin-magical-init',
|
||||
),
|
||||
'disk' => '/rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js',
|
||||
),
|
||||
'javelin-behavior-error-log' =>
|
||||
array(
|
||||
'uri' => '/res/acefdea7/rsrc/js/core/behavior-error-log.js',
|
||||
|
|
|
@ -537,7 +537,10 @@ phutil_register_library_map(array(
|
|||
'DoorkeeperBridgeAsana' => 'applications/doorkeeper/bridge/DoorkeeperBridgeAsana.php',
|
||||
'DoorkeeperDAO' => 'applications/doorkeeper/storage/DoorkeeperDAO.php',
|
||||
'DoorkeeperExternalObject' => 'applications/doorkeeper/storage/DoorkeeperExternalObject.php',
|
||||
'DoorkeeperImportEngine' => 'applications/doorkeeper/engine/DoorkeeperImportEngine.php',
|
||||
'DoorkeeperObjectRef' => 'applications/doorkeeper/engine/DoorkeeperObjectRef.php',
|
||||
'DoorkeeperRemarkupRuleAsana' => 'applications/doorkeeper/remarkup/DoorkeeperRemarkupRuleAsana.php',
|
||||
'DoorkeeperTagsController' => 'applications/doorkeeper/controller/DoorkeeperTagsController.php',
|
||||
'DrydockAllocatorWorker' => 'applications/drydock/worker/DrydockAllocatorWorker.php',
|
||||
'DrydockApacheWebrootInterface' => 'applications/drydock/interface/webroot/DrydockApacheWebrootInterface.php',
|
||||
'DrydockBlueprint' => 'applications/drydock/blueprint/DrydockBlueprint.php',
|
||||
|
@ -745,6 +748,7 @@ phutil_register_library_map(array(
|
|||
'PhabricatorApplicationDifferential' => 'applications/differential/application/PhabricatorApplicationDifferential.php',
|
||||
'PhabricatorApplicationDiffusion' => 'applications/diffusion/application/PhabricatorApplicationDiffusion.php',
|
||||
'PhabricatorApplicationDiviner' => 'applications/diviner/application/PhabricatorApplicationDiviner.php',
|
||||
'PhabricatorApplicationDoorkeeper' => 'applications/doorkeeper/application/PhabricatorApplicationDoorkeeper.php',
|
||||
'PhabricatorApplicationDrydock' => 'applications/drydock/application/PhabricatorApplicationDrydock.php',
|
||||
'PhabricatorApplicationFact' => 'applications/fact/application/PhabricatorApplicationFact.php',
|
||||
'PhabricatorApplicationFeed' => 'applications/feed/application/PhabricatorApplicationFeed.php',
|
||||
|
@ -2417,7 +2421,10 @@ phutil_register_library_map(array(
|
|||
0 => 'DoorkeeperDAO',
|
||||
1 => 'PhabricatorPolicyInterface',
|
||||
),
|
||||
'DoorkeeperImportEngine' => 'Phobject',
|
||||
'DoorkeeperObjectRef' => 'Phobject',
|
||||
'DoorkeeperRemarkupRuleAsana' => 'PhutilRemarkupRule',
|
||||
'DoorkeeperTagsController' => 'PhabricatorController',
|
||||
'DrydockAllocatorWorker' => 'PhabricatorWorker',
|
||||
'DrydockApacheWebrootInterface' => 'DrydockWebrootInterface',
|
||||
'DrydockCommandInterface' => 'DrydockInterface',
|
||||
|
@ -2608,6 +2615,7 @@ phutil_register_library_map(array(
|
|||
'PhabricatorApplicationDifferential' => 'PhabricatorApplication',
|
||||
'PhabricatorApplicationDiffusion' => 'PhabricatorApplication',
|
||||
'PhabricatorApplicationDiviner' => 'PhabricatorApplication',
|
||||
'PhabricatorApplicationDoorkeeper' => 'PhabricatorApplication',
|
||||
'PhabricatorApplicationDrydock' => 'PhabricatorApplication',
|
||||
'PhabricatorApplicationFact' => 'PhabricatorApplication',
|
||||
'PhabricatorApplicationFeed' => 'PhabricatorApplication',
|
||||
|
|
|
@ -15,12 +15,16 @@ final class DiffusionRemarkupRule
|
|||
$min_unqualified = PhabricatorRepository::MINIMUM_UNQUALIFIED_HASH;
|
||||
$min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH;
|
||||
|
||||
// NOTE: The "(?<!/)" negative lookbehind prevents this rule from matching
|
||||
// hashes or hash-like substrings in most URLs. For example, this will not
|
||||
// match: http://www.example.com/article/28903218328/
|
||||
|
||||
return
|
||||
'r[A-Z]+[1-9]\d*'.
|
||||
'|'.
|
||||
'r[A-Z]+[a-f0-9]{'.$min_qualified.',40}'.
|
||||
'|'.
|
||||
'[a-f0-9]{'.$min_unqualified.',40}';
|
||||
'(?<!/)[a-f0-9]{'.$min_unqualified.',40}';
|
||||
}
|
||||
|
||||
protected function loadObjects(array $ids) {
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
final class PhabricatorApplicationDoorkeeper extends PhabricatorApplication {
|
||||
|
||||
public function canUninstall() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getBaseURI() {
|
||||
return '/doorkeeper/';
|
||||
}
|
||||
|
||||
public function shouldAppearOnLaunchView() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getRemarkupRules() {
|
||||
return array(
|
||||
new DoorkeeperRemarkupRuleAsana(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getRoutes() {
|
||||
return array(
|
||||
'/doorkeeper/' => array(
|
||||
'tags/' => 'DoorkeeperTagsController',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -25,6 +25,10 @@ final class DoorkeeperBridgeAsana extends DoorkeeperBridge {
|
|||
->withAccountDomains(array($provider->getProviderDomain()))
|
||||
->execute();
|
||||
|
||||
if (!$accounts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: If the user has several linked Asana accounts, we just pick the
|
||||
// first one arbitrarily. We might want to try using all of them or do
|
||||
// something with more finesse. There's no UI way to link multiple accounts
|
||||
|
@ -47,10 +51,16 @@ final class DoorkeeperBridgeAsana extends DoorkeeperBridge {
|
|||
|
||||
$results = array();
|
||||
foreach (Futures($futures) as $key => $future) {
|
||||
$results[$key] = $future->resolve();
|
||||
try {
|
||||
$results[$key] = $future->resolve();
|
||||
} catch (Exception $ex) {
|
||||
// TODO: For now, ignore this stuff.
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($refs as $ref) {
|
||||
$ref->setAttribute('name', pht('Asana Task %s', $ref->getObjectID()));
|
||||
|
||||
$result = idx($results, $ref->getObjectKey());
|
||||
if (!$result) {
|
||||
continue;
|
||||
|
@ -58,7 +68,8 @@ final class DoorkeeperBridgeAsana extends DoorkeeperBridge {
|
|||
|
||||
$ref->setIsVisible(true);
|
||||
$ref->setAttribute('asana.data', $result);
|
||||
$ref->setAttribute('name', $result['name']);
|
||||
$ref->setAttribute('fullname', pht('Asana: %s', $result['name']));
|
||||
$ref->setAttribute('title', $result['name']);
|
||||
$ref->setAttribute('description', $result['notes']);
|
||||
|
||||
$obj = $ref->getExternalObject();
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
final class DoorkeeperTagsController extends PhabricatorController {
|
||||
|
||||
public function processRequest() {
|
||||
$request = $this->getRequest();
|
||||
$viewer = $request->getUser();
|
||||
|
||||
$tags = $request->getStr('tags');
|
||||
$tags = json_decode($tags, true);
|
||||
if (!is_array($tags)) {
|
||||
$tags = array();
|
||||
}
|
||||
|
||||
$refs = array();
|
||||
$id_map = array();
|
||||
foreach ($tags as $tag_spec) {
|
||||
$tag = $tag_spec['ref'];
|
||||
$ref = id(new DoorkeeperObjectRef())
|
||||
->setApplicationType($tag[0])
|
||||
->setApplicationDomain($tag[1])
|
||||
->setObjectType($tag[2])
|
||||
->setObjectID($tag[3]);
|
||||
|
||||
$key = $ref->getObjectKey();
|
||||
$id_map[$key] = $tag_spec['id'];
|
||||
$refs[$key] = $ref;
|
||||
}
|
||||
|
||||
$refs = id(new DoorkeeperImportEngine())
|
||||
->setViewer($viewer)
|
||||
->setRefs($refs)
|
||||
->execute();
|
||||
|
||||
$results = array();
|
||||
foreach ($refs as $key => $ref) {
|
||||
if (!$ref->getIsVisible()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uri = $ref->getExternalObject()->getObjectURI();
|
||||
if (!$uri) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $id_map[$key];
|
||||
|
||||
$tag = id(new PhabricatorTagView())
|
||||
->setID($id)
|
||||
->setName($ref->getFullName())
|
||||
->setHref($uri)
|
||||
->setType(PhabricatorTagView::TYPE_OBJECT)
|
||||
->setExternal(true)
|
||||
->render();
|
||||
|
||||
$results[] = array(
|
||||
'id' => $id,
|
||||
'markup' => $tag,
|
||||
);
|
||||
}
|
||||
|
||||
return id(new AphrontAjaxResponse())->setContent(
|
||||
array(
|
||||
'tags' => $results,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
final class DoorkeeperImportEngine extends Phobject {
|
||||
|
||||
private $viewer;
|
||||
private $refs;
|
||||
|
||||
public function setViewer(PhabricatorUser $viewer) {
|
||||
$this->viewer = $viewer;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getViewer() {
|
||||
return $this->viewer;
|
||||
}
|
||||
|
||||
public function setRefs(array $refs) {
|
||||
assert_instances_of($refs, 'DoorkeeperObjectRef');
|
||||
$this->refs = $refs;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRefs() {
|
||||
return $this->refs;
|
||||
}
|
||||
|
||||
public function execute() {
|
||||
$refs = $this->getRefs();
|
||||
$viewer = $this->getViewer();
|
||||
|
||||
$keys = mpull($refs, 'getObjectKey');
|
||||
if ($keys) {
|
||||
$xobjs = id(new DoorkeeperExternalObject())->loadAllWhere(
|
||||
'objectKey IN (%Ls)',
|
||||
$keys);
|
||||
$xobjs = mpull($xobjs, null, 'getObjectKey');
|
||||
foreach ($refs as $ref) {
|
||||
$xobj = idx($xobjs, $ref->getObjectKey());
|
||||
if (!$xobj) {
|
||||
$xobj = $ref->newExternalObject()
|
||||
->setImporterPHID($viewer->getPHID());
|
||||
}
|
||||
$ref->attachExternalObject($xobj);
|
||||
}
|
||||
}
|
||||
|
||||
$bridges = id(new PhutilSymbolLoader())
|
||||
->setAncestorClass('DoorkeeperBridge')
|
||||
->loadObjects();
|
||||
|
||||
foreach ($bridges as $key => $bridge) {
|
||||
if (!$bridge->isEnabled()) {
|
||||
unset($bridges[$key]);
|
||||
}
|
||||
$bridge->setViewer($viewer);
|
||||
}
|
||||
|
||||
foreach ($bridges as $bridge) {
|
||||
$bridge_refs = array();
|
||||
foreach ($refs as $key => $ref) {
|
||||
if ($bridge->canPullRef($ref)) {
|
||||
$bridge_refs[$key] = $ref;
|
||||
unset($refs[$key]);
|
||||
}
|
||||
}
|
||||
if ($bridge_refs) {
|
||||
$bridge->pullRefs($bridge_refs);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->getRefs();
|
||||
}
|
||||
|
||||
}
|
|
@ -44,7 +44,7 @@ final class DoorkeeperObjectRef extends Phobject {
|
|||
}
|
||||
|
||||
public function getAttribute($key, $default = null) {
|
||||
return idx($this->attribute, $key, $default);
|
||||
return idx($this->attributes, $key, $default);
|
||||
}
|
||||
|
||||
public function setAttribute($key, $value) {
|
||||
|
@ -91,6 +91,13 @@ final class DoorkeeperObjectRef extends Phobject {
|
|||
return $this->applicationType;
|
||||
}
|
||||
|
||||
public function getFullName() {
|
||||
return coalesce(
|
||||
$this->getAttribute('fullname'),
|
||||
$this->getAttribute('name'),
|
||||
pht('External Object'));
|
||||
}
|
||||
|
||||
public function getObjectKey() {
|
||||
if (!$this->objectKey) {
|
||||
$this->objectKey = PhabricatorHash::digestForIndex(
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
final class DoorkeeperRemarkupRuleAsana
|
||||
extends PhutilRemarkupRule {
|
||||
|
||||
const KEY_TAGS = 'doorkeeper.tags';
|
||||
|
||||
public function apply($text) {
|
||||
return preg_replace_callback(
|
||||
'@https://app\\.asana\\.com/0/(\\d+)/(\\d+)@',
|
||||
array($this, 'markupAsanaLink'),
|
||||
$text);
|
||||
}
|
||||
|
||||
public function markupAsanaLink($matches) {
|
||||
$key = self::KEY_TAGS;
|
||||
$engine = $this->getEngine();
|
||||
$token = $engine->storeText('AsanaDoorkeeper');
|
||||
|
||||
$tags = $engine->getTextMetadata($key, array());
|
||||
|
||||
$tags[] = array(
|
||||
'token' => $token,
|
||||
'href' => $matches[0],
|
||||
'tag' => array(
|
||||
'ref' => array('asana', 'asana.com', 'asana:task', $matches[2]),
|
||||
'extra' => array(
|
||||
'asana.context' => $matches[1],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$engine->setTextMetadata($key, $tags);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function didMarkupText() {
|
||||
$key = self::KEY_TAGS;
|
||||
$engine = $this->getEngine();
|
||||
$tags = $engine->getTextMetadata($key, array());
|
||||
|
||||
if (!$tags) {
|
||||
return;
|
||||
}
|
||||
|
||||
$refs = array();
|
||||
foreach ($tags as $spec) {
|
||||
$tag_id = celerity_generate_unique_node_id();
|
||||
|
||||
$refs[] = array(
|
||||
'id' => $tag_id,
|
||||
) + $spec['tag'];
|
||||
|
||||
$view = id(new PhabricatorTagView())
|
||||
->setID($tag_id)
|
||||
->setName($spec['href'])
|
||||
->setHref($spec['href'])
|
||||
->setType(PhabricatorTagView::TYPE_OBJECT)
|
||||
->setExternal(true);
|
||||
|
||||
$engine->overwriteStoredText($spec['token'], $view);
|
||||
}
|
||||
|
||||
Javelin::initBehavior('doorkeeper-tag', array('tags' => $refs));
|
||||
|
||||
$engine->setTextMetadata($key, array());
|
||||
}
|
||||
|
||||
}
|
|
@ -213,6 +213,8 @@ final class PhabricatorObjectHandleData {
|
|||
return mpull($xusrs, null, 'getPHID');
|
||||
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
public function loadHandles() {
|
||||
|
|
|
@ -449,12 +449,6 @@ final class PhabricatorMarkupEngine {
|
|||
$rules[] = new PhabricatorRemarkupRuleYoutube();
|
||||
}
|
||||
|
||||
$rules[] = new PhutilRemarkupRuleHyperlink();
|
||||
$rules[] = new PhrictionRemarkupRule();
|
||||
|
||||
$rules[] = new PhabricatorRemarkupRuleEmbedFile();
|
||||
$rules[] = new PhabricatorCountdownRemarkupRule();
|
||||
|
||||
$applications = PhabricatorApplication::getAllInstalledApplications();
|
||||
foreach ($applications as $application) {
|
||||
foreach ($application->getRemarkupRules() as $rule) {
|
||||
|
@ -462,6 +456,12 @@ final class PhabricatorMarkupEngine {
|
|||
}
|
||||
}
|
||||
|
||||
$rules[] = new PhutilRemarkupRuleHyperlink();
|
||||
$rules[] = new PhrictionRemarkupRule();
|
||||
|
||||
$rules[] = new PhabricatorRemarkupRuleEmbedFile();
|
||||
$rules[] = new PhabricatorCountdownRemarkupRule();
|
||||
|
||||
if ($options['macros']) {
|
||||
$rules[] = new PhabricatorRemarkupRuleImageMacro();
|
||||
$rules[] = new PhabricatorRemarkupRuleMeme();
|
||||
|
|
|
@ -28,6 +28,17 @@ final class PhabricatorTagView extends AphrontView {
|
|||
private $dotColor;
|
||||
private $barColor;
|
||||
private $closed;
|
||||
private $external;
|
||||
private $id;
|
||||
|
||||
public function setID($id) {
|
||||
$this->id = $id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getID() {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setType($type) {
|
||||
$this->type = $type;
|
||||
|
@ -135,20 +146,24 @@ final class PhabricatorTagView extends AphrontView {
|
|||
return javelin_tag(
|
||||
'a',
|
||||
array(
|
||||
'id' => $this->id,
|
||||
'href' => $this->href,
|
||||
'class' => implode(' ', $classes),
|
||||
'sigil' => 'hovercard',
|
||||
'meta' => array(
|
||||
'hoverPHID' => $this->phid,
|
||||
),
|
||||
'target' => $this->external ? '_blank' : null,
|
||||
),
|
||||
array($bar, $content));
|
||||
} else {
|
||||
return phutil_tag(
|
||||
$this->href ? 'a' : 'span',
|
||||
array(
|
||||
'id' => $this->id,
|
||||
'href' => $this->href,
|
||||
'class' => implode(' ', $classes),
|
||||
'target' => $this->external ? '_blank' : null,
|
||||
),
|
||||
array($bar, $content));
|
||||
}
|
||||
|
@ -180,4 +195,13 @@ final class PhabricatorTagView extends AphrontView {
|
|||
);
|
||||
}
|
||||
|
||||
public function setExternal($external) {
|
||||
$this->external = $external;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExternal() {
|
||||
return $this->external;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* @provides javelin-behavior-doorkeeper-tag
|
||||
* @requires javelin-behavior
|
||||
* javelin-dom
|
||||
* javelin-json
|
||||
* javelin-workflow
|
||||
* javelin-magical-init
|
||||
*/
|
||||
|
||||
JX.behavior('doorkeeper-tag', function(config, statics) {
|
||||
statics.tags = (statics.tags || []).concat(config.tags);
|
||||
statics.cache = statics.cache || {};
|
||||
|
||||
// NOTE: We keep a cache in the browser of external objects that we've already
|
||||
// looked up. This is mostly to keep previews from being flickery messes.
|
||||
|
||||
var load = function() {
|
||||
var tags = statics.tags;
|
||||
statics.tags = [];
|
||||
|
||||
if (!tags.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var have = [];
|
||||
var need = [];
|
||||
var keys = {};
|
||||
|
||||
var draw = function(tags) {
|
||||
for (var ii = 0; ii < tags.length; ii++) {
|
||||
try {
|
||||
JX.DOM.replace(JX.$(tags[ii].id), JX.$H(tags[ii].markup));
|
||||
} catch (ignored) {
|
||||
// The tag may have been wiped out of the body by the time the
|
||||
// response returns, for whatever reason. That's fine, just don't
|
||||
// bother drawing it.
|
||||
}
|
||||
statics.cache[keys[tags[ii].id]] = tags[ii].markup;
|
||||
}
|
||||
};
|
||||
|
||||
for (var ii = 0; ii < tags.length; ii++) {
|
||||
var tag_key = tags[ii].ref.join('@');
|
||||
if (tag_key in statics.cache) {
|
||||
have.push({id: tags[ii].id, markup: statics.cache[tag_key]});
|
||||
} else {
|
||||
need.push(tags[ii]);
|
||||
keys[tags[ii].id] = tag_key;
|
||||
}
|
||||
}
|
||||
|
||||
if (have.length) {
|
||||
draw(have);
|
||||
}
|
||||
|
||||
if (need.length) {
|
||||
new JX.Workflow('/doorkeeper/tags/', {tags: JX.JSON.stringify(need)})
|
||||
.setHandler(function(r) { draw(r.tags); })
|
||||
.start();
|
||||
}
|
||||
};
|
||||
|
||||
JX.onload(load);
|
||||
});
|
||||
|
Loading…
Reference in a new issue