diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,7 +9,7 @@ 'names' => array( 'conpherence.pkg.css' => 'e68cf1fa', 'conpherence.pkg.js' => '15191c65', - 'core.pkg.css' => '6a8ba174', + 'core.pkg.css' => '97dc0e74', 'core.pkg.js' => '8581cd02', 'differential.pkg.css' => '113e692c', 'differential.pkg.js' => 'f6d809c0', @@ -168,7 +168,7 @@ 'rsrc/css/phui/phui-object-box.css' => '9cff003c', 'rsrc/css/phui/phui-pager.css' => 'edcbc226', 'rsrc/css/phui/phui-pinboard-view.css' => '2495140e', - 'rsrc/css/phui/phui-property-list-view.css' => '79fc3a02', + 'rsrc/css/phui/phui-property-list-view.css' => 'ef864066', 'rsrc/css/phui/phui-remarkup-preview.css' => '54a34863', 'rsrc/css/phui/phui-segment-bar-view.css' => 'b1d1b892', 'rsrc/css/phui/phui-spacing.css' => '042804d6', @@ -392,6 +392,7 @@ 'rsrc/js/application/diffusion/behavior-pull-lastmodified.js' => 'f01586dc', 'rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js' => '1db13e70', 'rsrc/js/application/drydock/drydock-live-operation-status.js' => '901935ef', + 'rsrc/js/application/files/behavior-document-engine.js' => 'f6d6f389', 'rsrc/js/application/files/behavior-icon-composer.js' => '8499b6ab', 'rsrc/js/application/files/behavior-launch-icon-composer.js' => '48086888', 'rsrc/js/application/harbormaster/behavior-harbormaster-log.js' => '191b4909', @@ -606,6 +607,7 @@ 'javelin-behavior-diffusion-jump-to' => '73d09eef', 'javelin-behavior-diffusion-locate-file' => '6d3e1947', 'javelin-behavior-diffusion-pull-lastmodified' => 'f01586dc', + 'javelin-behavior-document-engine' => 'f6d6f389', 'javelin-behavior-doorkeeper-tag' => '1db13e70', 'javelin-behavior-drydock-live-operation-status' => '901935ef', 'javelin-behavior-durable-column' => '2ae077e1', @@ -848,7 +850,7 @@ 'phui-oi-simple-ui-css' => 'a8beebea', 'phui-pager-css' => 'edcbc226', 'phui-pinboard-view-css' => '2495140e', - 'phui-property-list-view-css' => '79fc3a02', + 'phui-property-list-view-css' => 'ef864066', 'phui-remarkup-preview-css' => '54a34863', 'phui-segment-bar-view-css' => 'b1d1b892', 'phui-spacing-css' => '042804d6', @@ -2151,6 +2153,11 @@ 'javelin-util', 'javelin-reactor', ), + 'f6d6f389' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-stratcom', + ), 'f829edb3' => array( 'javelin-view', 'javelin-install', diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -3001,6 +3001,7 @@ 'PhabricatorFileDataController' => 'applications/files/controller/PhabricatorFileDataController.php', 'PhabricatorFileDeleteController' => 'applications/files/controller/PhabricatorFileDeleteController.php', 'PhabricatorFileDeleteTransaction' => 'applications/files/xaction/PhabricatorFileDeleteTransaction.php', + 'PhabricatorFileDocumentController' => 'applications/files/controller/PhabricatorFileDocumentController.php', 'PhabricatorFileDropUploadController' => 'applications/files/controller/PhabricatorFileDropUploadController.php', 'PhabricatorFileEditController' => 'applications/files/controller/PhabricatorFileEditController.php', 'PhabricatorFileEditEngine' => 'applications/files/editor/PhabricatorFileEditEngine.php', @@ -3140,6 +3141,7 @@ 'PhabricatorHelpKeyboardShortcutController' => 'applications/help/controller/PhabricatorHelpKeyboardShortcutController.php', 'PhabricatorHeraldApplication' => 'applications/herald/application/PhabricatorHeraldApplication.php', 'PhabricatorHeraldContentSource' => 'applications/herald/contentsource/PhabricatorHeraldContentSource.php', + 'PhabricatorHexdumpDocumentEngine' => 'applications/files/document/PhabricatorHexdumpDocumentEngine.php', 'PhabricatorHighSecurityRequestExceptionHandler' => 'aphront/handler/PhabricatorHighSecurityRequestExceptionHandler.php', 'PhabricatorHomeApplication' => 'applications/home/application/PhabricatorHomeApplication.php', 'PhabricatorHomeConstants' => 'applications/home/constants/PhabricatorHomeConstants.php', @@ -8590,6 +8592,7 @@ 'PhabricatorFileDataController' => 'PhabricatorFileController', 'PhabricatorFileDeleteController' => 'PhabricatorFileController', 'PhabricatorFileDeleteTransaction' => 'PhabricatorFileTransactionType', + 'PhabricatorFileDocumentController' => 'PhabricatorFileController', 'PhabricatorFileDropUploadController' => 'PhabricatorFileController', 'PhabricatorFileEditController' => 'PhabricatorFileController', 'PhabricatorFileEditEngine' => 'PhabricatorEditEngine', @@ -8747,6 +8750,7 @@ 'PhabricatorHelpKeyboardShortcutController' => 'PhabricatorHelpController', 'PhabricatorHeraldApplication' => 'PhabricatorApplication', 'PhabricatorHeraldContentSource' => 'PhabricatorContentSource', + 'PhabricatorHexdumpDocumentEngine' => 'PhabricatorDocumentEngine', 'PhabricatorHighSecurityRequestExceptionHandler' => 'PhabricatorRequestExceptionHandler', 'PhabricatorHomeApplication' => 'PhabricatorApplication', 'PhabricatorHomeConstants' => 'PhabricatorHomeController', diff --git a/src/applications/files/application/PhabricatorFilesApplication.php b/src/applications/files/application/PhabricatorFilesApplication.php --- a/src/applications/files/application/PhabricatorFilesApplication.php +++ b/src/applications/files/application/PhabricatorFilesApplication.php @@ -89,6 +89,8 @@ 'iconset/(?P[^/]+)/' => array( 'select/' => 'PhabricatorFileIconSetSelectController', ), + 'document/(?P[^/]+)/(?P[^/]+)/' + => 'PhabricatorFileDocumentController', ) + $this->getResourceSubroutes(), ); } diff --git a/src/applications/files/controller/PhabricatorFileDocumentController.php b/src/applications/files/controller/PhabricatorFileDocumentController.php new file mode 100644 --- /dev/null +++ b/src/applications/files/controller/PhabricatorFileDocumentController.php @@ -0,0 +1,113 @@ +getViewer(); + + $file_phid = $request->getURIData('phid'); + + $file = id(new PhabricatorFileQuery()) + ->setViewer($viewer) + ->withPHIDs(array($file_phid)) + ->executeOne(); + if (!$file) { + return $this->newErrorResponse( + pht( + 'This file ("%s") does not exist or could not be loaded.', + $file_phid)); + } + $this->file = $file; + + $ref = id(new PhabricatorDocumentRef()) + ->setFile($file); + $this->ref = $ref; + + $engines = PhabricatorDocumentEngine::getEnginesForRef($viewer, $ref); + $engine_key = $request->getURIData('engineKey'); + if (!isset($engines[$engine_key])) { + return $this->newErrorResponse( + pht( + 'The engine ("%s") is unknown, or unable to render this document.', + $engine_key)); + } + $engine = $engines[$engine_key]; + $this->engine = $engine; + + try { + $content = $engine->newDocument($ref); + } catch (Exception $ex) { + return $this->newErrorResponse($ex->getMessage()); + } + + return $this->newContentResponse($content); + } + + private function newErrorResponse($message) { + $container = phutil_tag( + 'div', + array( + 'class' => 'document-engine-error', + ), + array( + id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle red'), + ' ', + $message, + )); + + return $this->newContentResponse($container); + } + + + private function newContentResponse($content) { + $viewer = $this->getViewer(); + $request = $this->getRequest(); + + $file = $this->file; + $engine = $this->engine; + $ref = $this->ref; + + if ($request->isAjax()) { + return id(new AphrontAjaxResponse()) + ->setContent( + array( + 'markup' => hsprintf('%s', $content), + )); + } + + $crumbs = $this->buildApplicationCrumbs(); + if ($file) { + $crumbs->addTextCrumb($file->getMonogram(), $file->getInfoURI()); + } + + $label = $engine->getViewAsLabel($ref); + if ($label) { + $crumbs->addTextCrumb($label); + } + + $crumbs->setBorder(true); + + $content_frame = id(new PHUIObjectBoxView()) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->appendChild($content); + + $page_frame = id(new PHUITwoColumnView()) + ->setFooter($content_frame); + + return $this->newPage() + ->setCrumbs($crumbs) + ->setTitle( + array( + $ref->getName(), + pht('Standalone'), + )) + ->appendChild($page_frame); + } + +} diff --git a/src/applications/files/controller/PhabricatorFileInfoController.php b/src/applications/files/controller/PhabricatorFileInfoController.php --- a/src/applications/files/controller/PhabricatorFileInfoController.php +++ b/src/applications/files/controller/PhabricatorFileInfoController.php @@ -404,50 +404,70 @@ private function newFileContent(PhabricatorFile $file) { $viewer = $this->getViewer(); - $engines = PhabricatorDocumentEngine::getAllEngines(); $ref = id(new PhabricatorDocumentRef()) ->setFile($file); - foreach ($engines as $key => $engine) { - $engine = id(clone $engine) - ->setViewer($viewer); + $engines = PhabricatorDocumentEngine::getEnginesForRef($viewer, $ref); + $engine = head($engines); - if (!$engine->canRenderDocument($ref)) { - unset($engines[$key]); + $content = $engine->newDocument($ref); + + $icon = $engine->newDocumentIcon($ref); + + $views = array(); + foreach ($engines as $candidate_engine) { + $label = $candidate_engine->getViewAsLabel($ref); + if ($label === null) { continue; } - $engines[$key] = $engine; - } + $view_icon = $candidate_engine->getViewAsIconIcon($ref); - if (!$engines) { - throw new Exception(pht('No engine can render this document.')); + $views[] = array( + 'viewKey' => $candidate_engine->getDocumentEngineKey(), + 'icon' => $view_icon, + 'name' => $label, + 'engineURI' => $candidate_engine->getRenderURI($ref), + ); } - $vectors = array(); - foreach ($engines as $key => $usable_engine) { - $vectors[$key] = $usable_engine->newSortVector($ref); - } - $vectors = msortv($vectors, 'getSelf'); + Javelin::initBehavior('document-engine'); - $engine = $engines[head_key($vectors)]; + $viewport_id = celerity_generate_unique_node_id(); - $content = $engine->newDocument($ref); - if (!$content) { - return null; - } + $viewport = phutil_tag( + 'div', + array( + 'id' => $viewport_id, + ), + $content); - $icon = $engine->newDocumentIcon($ref); + $meta = array( + 'viewportID' => $viewport_id, + 'viewKey' => $engine->getDocumentEngineKey(), + 'views' => $views, + 'standaloneURI' => $engine->getRenderURI($ref), + ); + + $view_button = id(new PHUIButtonView()) + ->setTag('a') + ->setText(pht('View Options')) + ->setIcon('fa-file-image-o') + ->setColor(PHUIButtonView::GREY) + ->setMetadata($meta) + ->setDropdown(true) + ->addSigil('document-engine-view-dropdown'); $header = id(new PHUIHeaderView()) ->setHeaderIcon($icon) - ->setHeader($ref->getName()); + ->setHeader($ref->getName()) + ->addActionLink($view_button); return id(new PHUIObjectBoxView()) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setHeader($header) - ->appendChild($content); + ->appendChild($viewport); } } diff --git a/src/applications/files/document/PhabricatorAudioDocumentEngine.php b/src/applications/files/document/PhabricatorAudioDocumentEngine.php --- a/src/applications/files/document/PhabricatorAudioDocumentEngine.php +++ b/src/applications/files/document/PhabricatorAudioDocumentEngine.php @@ -5,6 +5,10 @@ const ENGINEKEY = 'audio'; + public function getViewAsLabel(PhabricatorDocumentRef $ref) { + return pht('View as Audio'); + } + protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) { return 'fa-file-sound-o'; } diff --git a/src/applications/files/document/PhabricatorDocumentEngine.php b/src/applications/files/document/PhabricatorDocumentEngine.php --- a/src/applications/files/document/PhabricatorDocumentEngine.php +++ b/src/applications/files/document/PhabricatorDocumentEngine.php @@ -59,4 +59,52 @@ return 2000; } + abstract public function getViewAsLabel(PhabricatorDocumentRef $ref); + + public function getViewAsIconIcon(PhabricatorDocumentRef $ref) { + return $this->getDocumentIconIcon($ref); + } + + public function getRenderURI(PhabricatorDocumentRef $ref) { + $file = $ref->getFile(); + if (!$file) { + throw new PhutilMethodNotImplementedException(); + } + + $engine_key = $this->getDocumentEngineKey(); + $file_phid = $file->getPHID(); + + return "/file/document/{$engine_key}/{$file_phid}/"; + } + + final public static function getEnginesForRef( + PhabricatorUser $viewer, + PhabricatorDocumentRef $ref) { + $engines = self::getAllEngines(); + + foreach ($engines as $key => $engine) { + $engine = id(clone $engine) + ->setViewer($viewer); + + if (!$engine->canRenderDocument($ref)) { + unset($engines[$key]); + continue; + } + + $engines[$key] = $engine; + } + + if (!$engines) { + throw new Exception(pht('No content engine can render this document.')); + } + + $vectors = array(); + foreach ($engines as $key => $usable_engine) { + $vectors[$key] = $usable_engine->newSortVector($ref); + } + $vectors = msortv($vectors, 'getSelf'); + + return array_select_keys($engines, array_keys($vectors)); + } + } diff --git a/src/applications/files/document/PhabricatorDocumentRef.php b/src/applications/files/document/PhabricatorDocumentRef.php --- a/src/applications/files/document/PhabricatorDocumentRef.php +++ b/src/applications/files/document/PhabricatorDocumentRef.php @@ -68,6 +68,14 @@ return null; } + public function loadData() { + if ($this->file) { + return $this->file->loadFileData(); + } + + throw new PhutilMethodNotImplementedException(); + } + public function hasAnyMimeType(array $candidate_types) { $mime_full = $this->getMimeType(); $mime_parts = explode(';', $mime_full); diff --git a/src/applications/files/document/PhabricatorHexdumpDocumentEngine.php b/src/applications/files/document/PhabricatorHexdumpDocumentEngine.php new file mode 100644 --- /dev/null +++ b/src/applications/files/document/PhabricatorHexdumpDocumentEngine.php @@ -0,0 +1,83 @@ +loadData(); + + $output = array(); + $offset = 0; + + $lines = str_split($content, 16); + foreach ($lines as $line) { + $output[] = sprintf( + '%08x %- 23s %- 23s %- 16s', + $offset, + $this->renderHex(substr($line, 0, 8)), + $this->renderHex(substr($line, 8)), + $this->renderBytes($line)); + + $offset += 16; + } + + $output = implode("\n", $output); + + $container = phutil_tag( + 'div', + array( + 'class' => 'document-engine-hexdump PhabricatorMonospaced', + ), + $output); + + return $container; + } + + private function renderHex($bytes) { + $length = strlen($bytes); + + $output = array(); + for ($ii = 0; $ii < $length; $ii++) { + $output[] = sprintf('%02x', ord($bytes[$ii])); + } + + return implode(' ', $output); + } + + private function renderBytes($bytes) { + $length = strlen($bytes); + + $output = array(); + for ($ii = 0; $ii < $length; $ii++) { + $chr = $bytes[$ii]; + $ord = ord($chr); + + if ($ord < 0x20 || $ord >= 0x7F) { + $chr = '.'; + } + + $output[] = $chr; + } + + return implode('', $output); + } + +} diff --git a/src/applications/files/document/PhabricatorImageDocumentEngine.php b/src/applications/files/document/PhabricatorImageDocumentEngine.php --- a/src/applications/files/document/PhabricatorImageDocumentEngine.php +++ b/src/applications/files/document/PhabricatorImageDocumentEngine.php @@ -5,6 +5,10 @@ const ENGINEKEY = 'image'; + public function getViewAsLabel(PhabricatorDocumentRef $ref) { + return pht('View as Image'); + } + protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) { return 'fa-file-image-o'; } diff --git a/src/applications/files/document/PhabricatorVideoDocumentEngine.php b/src/applications/files/document/PhabricatorVideoDocumentEngine.php --- a/src/applications/files/document/PhabricatorVideoDocumentEngine.php +++ b/src/applications/files/document/PhabricatorVideoDocumentEngine.php @@ -5,6 +5,10 @@ const ENGINEKEY = 'video'; + public function getViewAsLabel(PhabricatorDocumentRef $ref) { + return pht('View as Video'); + } + protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) { return 'fa-film'; } diff --git a/src/applications/files/document/PhabricatorVoidDocumentEngine.php b/src/applications/files/document/PhabricatorVoidDocumentEngine.php --- a/src/applications/files/document/PhabricatorVoidDocumentEngine.php +++ b/src/applications/files/document/PhabricatorVoidDocumentEngine.php @@ -5,6 +5,10 @@ const ENGINEKEY = 'void'; + public function getViewAsLabel(PhabricatorDocumentRef $ref) { + return null; + } + protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) { return 'fa-file'; } diff --git a/webroot/rsrc/css/phui/phui-property-list-view.css b/webroot/rsrc/css/phui/phui-property-list-view.css --- a/webroot/rsrc/css/phui/phui-property-list-view.css +++ b/webroot/rsrc/css/phui/phui-property-list-view.css @@ -231,3 +231,14 @@ text-align: center; color: {$greytext}; } + +.document-engine-error { + margin: 20px auto; + text-align: center; + color: {$redtext}; +} + +.document-engine-hexdump { + margin: 20px; + white-space: pre; +} diff --git a/webroot/rsrc/js/application/files/behavior-document-engine.js b/webroot/rsrc/js/application/files/behavior-document-engine.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/application/files/behavior-document-engine.js @@ -0,0 +1,67 @@ +/** + * @provides javelin-behavior-document-engine + * @requires javelin-behavior + * javelin-dom + * javelin-stratcom + */ + +JX.behavior('document-engine', function() { + + function onmenu(e) { + var node = e.getNode('document-engine-view-dropdown'); + var data = JX.Stratcom.getData(node); + + if (data.menu) { + return; + } + + e.prevent(); + + var menu = new JX.PHUIXDropdownMenu(node); + var list = new JX.PHUIXActionListView(); + + var view; + for (var ii = 0; ii < data.views.length; ii++) { + var spec = data.views[ii]; + + view = new JX.PHUIXActionView() + .setName(spec.name) + .setIcon(spec.icon) + .setHref(spec.engineURI); + + view.setHandler(JX.bind(null, function(spec, e) { + if (!e.isNormalClick()) { + return; + } + + e.prevent(); + menu.close(); + + onview(data, spec); + }, spec)); + + list.addItem(view); + } + + menu.setContent(list.getNode()); + + data.menu = menu; + menu.open(); + } + + function onview(data, spec) { + var handler = JX.bind(null, onrender, data); + + new JX.Request(spec.engineURI, handler) + .send(); + } + + function onrender(data, r) { + var viewport = JX.$(data.viewportID); + + JX.DOM.setContent(viewport, JX.$H(r.markup)); + } + + JX.Stratcom.listen('click', 'document-engine-view-dropdown', onmenu); + +});