Skip to content

Commit

Permalink
feat: file actions redirect, v2 api version (#284)
Browse files Browse the repository at this point in the history
This PR introduces a simple File Actions menu redirect option to open
ExApp UI page with the context of selected files.

Resolves: #136

---------

Signed-off-by: Andrey Borysenko <[email protected]>
Signed-off-by: Alexander Piskun <[email protected]>
Co-authored-by: Alexander Piskun <[email protected]>
  • Loading branch information
andrey18106 and bigcat88 authored May 8, 2024
1 parent 45a289b commit 139e793
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 30 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [2.6.0 - 2024-05-xx]

### Added

- Added File Actions v2 version with redirect to the ExApp UI. #284

### Changed

- Reworked scopes for database/cache requests optimization, drop old ex_app_scopes table. #285

## [2.5.1 - 2024-05-02]

### Added
Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ to join us in shaping a more versatile, stable, and secure app landscape.
*Your insights, suggestions, and contributions are invaluable to us.*
]]></description>
<version>2.5.1</version>
<version>2.6.0</version>
<licence>agpl</licence>
<author mail="[email protected]" homepage="https://github.com/andrey18106">Andrey Borysenko</author>
<author mail="[email protected]" homepage="https://github.com/bigcat88">Alexander Piskun</author>
Expand Down
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
// --- UI ---
// File Actions Menu
['name' => 'OCSUi#registerFileActionMenu', 'url' => '/api/v1/ui/files-actions-menu', 'verb' => 'POST'],
['name' => 'OCSUi#registerFileActionMenuV2', 'url' => '/api/v2/ui/files-actions-menu', 'verb' => 'POST'],
['name' => 'OCSUi#unregisterFileActionMenu', 'url' => '/api/v1/ui/files-actions-menu', 'verb' => 'DELETE'],
['name' => 'OCSUi#getFileActionMenu', 'url' => '/api/v1/ui/files-actions-menu', 'verb' => 'GET'],

Expand Down
31 changes: 28 additions & 3 deletions docs/tech_details/api/fileactionsmenu.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ AppAPI takes responsibility to register FileActionsMenu, ExApps needs only to re
Register
^^^^^^^^

.. note::

With AppAPI 2.6.0 there is a new v2 OCS endpoint with redirect to ExApp UI support:
OCS endpoint: ``POST /apps/app_api/api/v2/ui/files-actions-menu``.
Old v1 is marked as deprecated.

OCS endpoint: ``POST /apps/app_api/api/v1/ui/files-actions-menu``


Params
******

Expand All @@ -35,7 +42,6 @@ Complete list of params (including optional):
.. note:: Urls ``icon`` and ``actionHandler`` are relative to the ExApp root, starting slash is not required.


Optional params
***************

Expand Down Expand Up @@ -90,6 +96,25 @@ The following data is sent to ExApp FileActionsMenu handler from the context of
"instanceId": "string",
}
Redirect to ExApp UI page (top menu)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.. note::
Supported only for Nextcloud 28+.

If you want to open some files in ExApp UI, your FileActionsMenu have to be registered using OCS v2 version (``/apps/app_api/api/v2/ui/files-actions-menu``).

After that, AppAPI will expect in the JSON response of the ExApp ``action_handler``
the ``redirect_handler`` - a relative path on the ExApp Top Menu page,
to which AppAPI will attach a ``fileIds`` query parameter with the selected file ids, for example:

``/index.php/apps/app_api/embedded/ui_example/first_menu/second_page?fileIds=123,124,125``,

where the ``first_menu`` is the name of the Top Menu ExApp UI page,
and the ``second_page`` relative route handled on the frontend routing of the ExApp,
the ``fileIds`` query parameter contains the selected file ids separated by commas.
After that you can get the files info via webdav search request, see `ui_example <https://github.com/cloud-py-api/ui_example>`_.


Request flow
^^^^^^^^^^^^
Expand Down Expand Up @@ -126,5 +151,5 @@ Examples

Here is a list of simple example ExApps based on FileActionsMenu:

* `video_to_gif <https://github.com/cloud-py-api/nc_py_api/tree/main/examples/as_app/to_gif>`_ - ExApp based on FileActionsMenu to convert videos to gif in place
* `upscaler_demo <https://github.com/cloud-py-api/upscaler_example.git>`_ - ExApp based on FileActionsMenu to upscale image in place
* `to_gif <https://github.com/cloud-py-api/nc_py_api/tree/main/examples/as_app/to_gif>`_ - ExApp based on FileActionsMenu to convert videos to gif in place
* `upscaler_example <https://github.com/cloud-py-api/upscaler_example.git>`_ - ExApp based on FileActionsMenu to upscale image in place
21 changes: 20 additions & 1 deletion lib/Controller/OCSUiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public function __construct(

/**
* @throws OCSBadRequestException
*
* @depreacted since AppAPI 2.6.0, use registerFileActionMenuV2 instead
*/
#[AppAPIAuth]
#[PublicPage]
Expand All @@ -46,7 +48,24 @@ public function registerFileActionMenu(string $name, string $displayName, string
string $icon = "", string $mime = "file", int $permissions = 31,
int $order = 0): DataResponse {
$result = $this->filesActionsMenuService->registerFileActionMenu(
$this->request->getHeader('EX-APP-ID'), $name, $displayName, $actionHandler, $icon, $mime, $permissions, $order);
$this->request->getHeader('EX-APP-ID'), $name, $displayName, $actionHandler, $icon, $mime, $permissions, $order, '1.0');
if (!$result) {
throw new OCSBadRequestException("File Action Menu entry could not be registered");
}
return new DataResponse();
}

/**
* @throws OCSBadRequestException
*/
#[AppAPIAuth]
#[PublicPage]
#[NoCSRFRequired]
public function registerFileActionMenuV2(string $name, string $displayName, string $actionHandler,
string $icon = "", string $mime = "file", int $permissions = 31,
int $order = 0): DataResponse {
$result = $this->filesActionsMenuService->registerFileActionMenu(
$this->request->getHeader('EX-APP-ID'), $name, $displayName, $actionHandler, $icon, $mime, $permissions, $order, '2.0');
if (!$result) {
throw new OCSBadRequestException("File Action Menu entry could not be registered");
}
Expand Down
8 changes: 8 additions & 0 deletions lib/Db/UI/FilesActionsMenu.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* @method int getOrder()
* @method string getIcon()
* @method string getActionHandler()
* @method string getVersion()
* @method void setAppid(string $appid)
* @method void setName(string $name)
* @method void setDisplayName(string $displayName)
Expand All @@ -28,6 +29,7 @@
* @method void setOrder(int $order)
* @method void setIcon(string $icon)
* @method void setActionHandler(string $actionHandler)
* @method void setVersion(string $version)
*/
class FilesActionsMenu extends Entity implements JsonSerializable {
protected $appid;
Expand All @@ -38,6 +40,7 @@ class FilesActionsMenu extends Entity implements JsonSerializable {
protected $order;
protected $icon;
protected $actionHandler;
protected $version;

/**
* @param array $params
Expand All @@ -51,6 +54,7 @@ public function __construct(array $params = []) {
$this->addType('order', 'int');
$this->addType('icon', 'string');
$this->addType('actionHandler', 'string');
$this->addType('version', 'string');

if (isset($params['id'])) {
$this->setId($params['id']);
Expand Down Expand Up @@ -79,6 +83,9 @@ public function __construct(array $params = []) {
if (isset($params['action_handler'])) {
$this->setActionHandler($params['action_handler']);
}
if (isset($params['version'])) {
$this->setVersion($params['version']);
}
}

public function jsonSerialize(): array {
Expand All @@ -92,6 +99,7 @@ public function jsonSerialize(): array {
'order' => $this->getOrder(),
'icon' => $this->getIcon(),
'action_handler' => $this->getActionHandler(),
'version' => $this->getVersion(),
];
}
}
37 changes: 37 additions & 0 deletions lib/Migration/Version2206Date20240502145029.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace OCA\AppAPI\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version2206Date20240502145029 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
*
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('ex_ui_files_actions')) {
$table = $schema->getTable('ex_ui_files_actions');

$table->addColumn('version', Types::STRING, [
'notnull' => true,
'length' => 64,
'default' => '1.0',
]);
}

return $schema;
}
}
2 changes: 2 additions & 0 deletions lib/Service/ExAppApiScopeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ class ExAppApiScopeService {
public function __construct(
) {
$aeApiV1Prefix = '/apps/' . Application::APP_ID . '/api/v1';
$aeApiV2Prefix = '/apps/' . Application::APP_ID . '/api/v2';
$this->apiScopes = [
// AppAPI scopes
['api_route' => $aeApiV1Prefix . '/ui/files-actions-menu', 'scope_group' => 1, 'name' => 'BASIC', 'user_check' => 0],
['api_route' => $aeApiV2Prefix . '/ui/files-actions-menu', 'scope_group' => 1, 'name' => 'BASIC', 'user_check' => 0],
['api_route' => $aeApiV1Prefix . '/ui/top-menu', 'scope_group' => 1, 'name' => 'BASIC', 'user_check' => 0],
['api_route' => $aeApiV1Prefix . '/ui/initial-state', 'scope_group' => 1, 'name' => 'BASIC', 'user_check' => 0],
['api_route' => $aeApiV1Prefix . '/ui/script', 'scope_group' => 1, 'name' => 'BASIC', 'user_check' => 0],
Expand Down
4 changes: 3 additions & 1 deletion lib/Service/UI/FilesActionsMenuService.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ public function __construct(
* @param string $mime
* @param int $permissions
* @param int $order
* @param string $version
* @return FilesActionsMenu|null
*/
public function registerFileActionMenu(string $appId, string $name, string $displayName, string $actionHandler,
string $icon, string $mime, int $permissions, int $order): ?FilesActionsMenu {
string $icon, string $mime, int $permissions, int $order, string $version): ?FilesActionsMenu {
try {
$fileActionMenu = $this->mapper->findByAppidName($appId, $name);
} catch (DoesNotExistException|MultipleObjectsReturnedException|Exception) {
Expand All @@ -55,6 +56,7 @@ public function registerFileActionMenu(string $appId, string $name, string $disp
'mime' => $mime,
'permissions' => $permissions,
'order' => $order,
'version' => $version,
]);
if ($fileActionMenu !== null) {
$newFileActionMenu->setId($fileActionMenu->getId());
Expand Down
90 changes: 66 additions & 24 deletions src/filesplugin28.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ function generateAppAPIProxyUrl(appId, route) {
return generateUrl(`/apps/app_api/proxy/${appId}/${route}`)
}

function generateExAppUIPageUrl(appId, route) {
return generateUrl(`/apps/app_api/embedded/${appId}/${route}`)
}

function registerFileAction28(fileAction, inlineSvgIcon) {
const action = new FileAction({
id: fileAction.name,
Expand Down Expand Up @@ -61,32 +65,48 @@ function registerFileAction28(fileAction, inlineSvgIcon) {
},
async exec(node, view, dir) {
const exAppFileActionHandler = generateAppAPIProxyUrl(fileAction.appid, fileAction.action_handler)
return axios.post(exAppFileActionHandler, {
fileId: node.fileid,
name: node.basename,
directory: node.dirname,
etag: node.attributes.etag,
mime: node.mime,
favorite: Boolean(node.attributes.favorite).toString(),
permissions: node.permissions,
fileType: node.type,
size: Number(node.size),
mtime: new Date(node.mtime).getTime() / 1000, // convert ms to s
shareTypes: node.attributes.shareTypes || null,
shareAttributes: node.attributes.shareAttributes || null,
sharePermissions: node.attributes.sharePermissions || null,
shareOwner: node.attributes.ownerDisplayName || null,
shareOwnerId: node.attributes.ownerId || null,
userId: getCurrentUser().uid,
instanceId: state.instanceId,
}).then((response) => {
return true
}).catch((error) => {
console.error('Failed to send FileAction request to ExApp', error)
return false
})
if ('version' in fileAction && fileAction.version === '2.0') {
return axios.post(exAppFileActionHandler, { files: [buildNodeInfo(node)] })
.then((response) => {
if (typeof response.data === 'object' && 'redirect_handler' in response.data) {
const redirectPage = generateExAppUIPageUrl(fileAction.appid, response.data.redirect_handler)
window.location.assign(`${redirectPage}?fileIds=${node.fileid}`)
return true
}
return true
}).catch((error) => {
console.error('Failed to send FileAction request to ExApp', error)
return false
})
}
return axios.post(exAppFileActionHandler, buildNodeInfo(node))
.then(() => {
return true
})
.catch((error) => {
console.error('Failed to send FileAction request to ExApp', error)
return false
})
},
async execBatch(nodes, view, dir) {
if ('version' in fileAction && fileAction.version === '2.0') {
const exAppFileActionHandler = generateAppAPIProxyUrl(fileAction.appid, fileAction.action_handler)
const nodesDataList = nodes.map(buildNodeInfo)
return axios.post(exAppFileActionHandler, { files: nodesDataList })
.then((response) => {
if (typeof response.data === 'object' && 'redirect_handler' in response.data) {
const redirectPage = generateExAppUIPageUrl(fileAction.appid, response.data.redirect_handler)
const fileIds = nodes.map((node) => node.fileid).join(',')
window.location.assign(`${redirectPage}?fileIds=${fileIds}`)
}
return nodes.map(_ => true)
})
.catch((error) => {
console.error('Failed to send FileAction request to ExApp', error)
return nodes.map(_ => false)
})
}
// for version 1.0 behavior is not changed
return Promise.all(nodes.map((node) => {
return this.exec(node, view, dir)
}))
Expand All @@ -95,6 +115,28 @@ function registerFileAction28(fileAction, inlineSvgIcon) {
registerFileAction(action)
}

function buildNodeInfo(node) {
return {
fileId: node.fileid,
name: node.basename,
directory: node.dirname,
etag: node.attributes.etag,
mime: node.mime,
favorite: Boolean(node.attributes.favorite).toString(),
permissions: node.permissions,
fileType: node.type,
size: Number(node.size),
mtime: new Date(node.mtime).getTime() / 1000, // convert ms to s
shareTypes: node.attributes.shareTypes || null,
shareAttributes: node.attributes.shareAttributes || null,
sharePermissions: node.attributes.sharePermissions || null,
shareOwner: node.attributes.ownerDisplayName || null,
shareOwnerId: node.attributes.ownerId || null,
userId: getCurrentUser().uid,
instanceId: state.instanceId,
}
}

document.addEventListener('DOMContentLoaded', () => {
state.fileActions.forEach(fileAction => {
if (fileAction.icon === '') {
Expand Down

0 comments on commit 139e793

Please sign in to comment.