diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a8c768..c10785fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/appinfo/info.xml b/appinfo/info.xml index 236f15ad..d17f5921 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -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.* ]]> - 2.5.1 + 2.6.0 agpl Andrey Borysenko Alexander Piskun diff --git a/appinfo/routes.php b/appinfo/routes.php index bac06c48..9e08a6e2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -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'], diff --git a/docs/tech_details/api/fileactionsmenu.rst b/docs/tech_details/api/fileactionsmenu.rst index a3a70a51..dbc4a55c 100644 --- a/docs/tech_details/api/fileactionsmenu.rst +++ b/docs/tech_details/api/fileactionsmenu.rst @@ -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 ****** @@ -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 *************** @@ -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 `_. + Request flow ^^^^^^^^^^^^ @@ -126,5 +151,5 @@ Examples Here is a list of simple example ExApps based on FileActionsMenu: -* `video_to_gif `_ - ExApp based on FileActionsMenu to convert videos to gif in place -* `upscaler_demo `_ - ExApp based on FileActionsMenu to upscale image in place +* `to_gif `_ - ExApp based on FileActionsMenu to convert videos to gif in place +* `upscaler_example `_ - ExApp based on FileActionsMenu to upscale image in place diff --git a/lib/Controller/OCSUiController.php b/lib/Controller/OCSUiController.php index 56a52c5b..ea564355 100644 --- a/lib/Controller/OCSUiController.php +++ b/lib/Controller/OCSUiController.php @@ -38,6 +38,8 @@ public function __construct( /** * @throws OCSBadRequestException + * + * @depreacted since AppAPI 2.6.0, use registerFileActionMenuV2 instead */ #[AppAPIAuth] #[PublicPage] @@ -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"); } diff --git a/lib/Db/UI/FilesActionsMenu.php b/lib/Db/UI/FilesActionsMenu.php index 17d058aa..124c4a12 100644 --- a/lib/Db/UI/FilesActionsMenu.php +++ b/lib/Db/UI/FilesActionsMenu.php @@ -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) @@ -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; @@ -38,6 +40,7 @@ class FilesActionsMenu extends Entity implements JsonSerializable { protected $order; protected $icon; protected $actionHandler; + protected $version; /** * @param array $params @@ -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']); @@ -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 { @@ -92,6 +99,7 @@ public function jsonSerialize(): array { 'order' => $this->getOrder(), 'icon' => $this->getIcon(), 'action_handler' => $this->getActionHandler(), + 'version' => $this->getVersion(), ]; } } diff --git a/lib/Migration/Version2206Date20240502145029.php b/lib/Migration/Version2206Date20240502145029.php new file mode 100644 index 00000000..fe8b948a --- /dev/null +++ b/lib/Migration/Version2206Date20240502145029.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/lib/Service/ExAppApiScopeService.php b/lib/Service/ExAppApiScopeService.php index 905e3b3d..302a5b59 100644 --- a/lib/Service/ExAppApiScopeService.php +++ b/lib/Service/ExAppApiScopeService.php @@ -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], diff --git a/lib/Service/UI/FilesActionsMenuService.php b/lib/Service/UI/FilesActionsMenuService.php index 5e6eacdb..693f1f2a 100644 --- a/lib/Service/UI/FilesActionsMenuService.php +++ b/lib/Service/UI/FilesActionsMenuService.php @@ -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) { @@ -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()); diff --git a/src/filesplugin28.js b/src/filesplugin28.js index 7832f656..1f606322 100644 --- a/src/filesplugin28.js +++ b/src/filesplugin28.js @@ -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, @@ -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) })) @@ -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 === '') {