1
0
Fork 0
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:
epriestley 2013-06-24 15:55:08 -07:00
parent e723b7e119
commit b22e52e40c
13 changed files with 389 additions and 10 deletions

View file

@ -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',

View file

@ -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',

View file

@ -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) {

View file

@ -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',
),
);
}
}

View file

@ -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();

View file

@ -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,
));
}
}

View file

@ -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();
}
}

View file

@ -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(

View file

@ -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());
}
}

View file

@ -213,6 +213,8 @@ final class PhabricatorObjectHandleData {
return mpull($xusrs, null, 'getPHID');
}
return array();
}
public function loadHandles() {

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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);
});