From e291dbeb98d988a72e708d886153238993648e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Wichli=C5=84ski?= Date: Wed, 5 Oct 2022 16:50:35 +0200 Subject: [PATCH] create a copy of a locked file --- .github/workflows/CI/revad-compose.yml | 2 +- cs3api4lab/api/cs3apismanager.py | 51 ++++++++++++- cs3api4lab/handlers.py | 15 +++- cs3api4lab/locks/base.py | 12 ++-- cs3api4lab/tests/test_cs3apismanager.py | 14 ++-- cs3api4lab/utils/share_utils.py | 14 ++-- src/drive.ts | 95 +++++++++++++++++++++---- src/index.ts | 2 +- src/infobox.tsx | 3 +- src/types.ts | 6 ++ 10 files changed, 168 insertions(+), 46 deletions(-) diff --git a/.github/workflows/CI/revad-compose.yml b/.github/workflows/CI/revad-compose.yml index 527055ae..cf7a4019 100644 --- a/.github/workflows/CI/revad-compose.yml +++ b/.github/workflows/CI/revad-compose.yml @@ -1,7 +1,7 @@ version: "3.9" services: revad: - image: cs3org/revad:v1.21.0 # 'latest' tag is temporarily not available + image: cs3org/revad # 'latest' tag is temporarily not available container_name: revad ports: - "19000-19001:19000-19001" diff --git a/cs3api4lab/api/cs3apismanager.py b/cs3api4lab/api/cs3apismanager.py index 0300030a..b3c32f24 100644 --- a/cs3api4lab/api/cs3apismanager.py +++ b/cs3api4lab/api/cs3apismanager.py @@ -1,3 +1,4 @@ +import urllib import nbformat import os import posixpath @@ -349,6 +350,12 @@ def _file_model(self, path, content, format): model = ModelUtils.create_empty_file_model(path) try: file_info = self.file_api.stat_info(path, self.cs3_config.endpoint) + + # additional request until this issue is resolved https://github.com/cs3org/reva/issues/3243 + if self.config.dev_env and "/home/" in file_info['filepath']: + opaque_id = urllib.parse.unquote(file_info['inode']['opaque_id']) + storage_id = urllib.parse.unquote(file_info['inode']['storage_id']) + file_info = self.file_api.stat_info(opaque_id, storage_id) except Exception as e: self.log.info('File % does not exists' % path) @@ -379,9 +386,15 @@ def _file_model(self, path, content, format): def _notebook_model(self, path, content): file_info = self.file_api.stat_info(path, self.cs3_config.endpoint) + # additional request until this issue is resolved https://github.com/cs3org/reva/issues/3243 + if self.config.dev_env and "/home/" in file_info['filepath']: + opaque_id = urllib.parse.unquote(file_info['inode']['opaque_id']) + storage_id = urllib.parse.unquote(file_info['inode']['storage_id']) + file_info = self.file_api.stat_info(opaque_id, storage_id) + model = ModelUtils.update_file_model(ModelUtils.create_empty_file_model(path), file_info) model['type'] = 'notebook' - + model['locked'] = self.lock_api.is_file_locked(file_info) if content: file_content = self._read_file(file_info) nb = nbformat.reads(file_content, as_version=4) @@ -390,6 +403,8 @@ def _notebook_model(self, path, content): model['format'] = 'json' self.validate_notebook_model(model) + model['writable'] = self._is_editor(file_info) + return model @asyncify @@ -495,3 +510,37 @@ def list_checkpoints(self, path): def delete_checkpoint(self, checkpoint_id, path): pass + + @asyncify + def create_clone_file(self, path): + path_normalized = FileUtils.normalize_path(path) + path_normalized = FileUtils.check_and_transform_file_path(path_normalized) + notebook_container = self.cs3_config.home_dir if self.cs3_config.home_dir else self.cs3_config.mount_dir + + file_info = self.file_api.stat_info(path_normalized, self.config.endpoint) + # additional request until this issue is resolved https://github.com/cs3org/reva/issues/3243 + if self.config.dev_env and "/home/" in file_info['filepath']: + opaque_id = urllib.parse.unquote(file_info['inode']['opaque_id']) + storage_id = urllib.parse.unquote(file_info['inode']['storage_id']) + file_info = self.file_api.stat_info(opaque_id, storage_id) + + clone_file = self.lock_api.resolve_file_path(path) + clone_file_exists = self.file_exists(clone_file) + clone_file_created = False + clone_file_path = "" + if not clone_file_exists: + try: + file_content = self._read_file(file_info) + clone_file_path = FileUtils.normalize_path(notebook_container + '/' + clone_file) + clone_file_path = FileUtils.check_and_transform_file_path(clone_file_path) + + self.file_api.write_file(clone_file_path, file_content, self.cs3_config.endpoint, None) + + clone_file_created = True + except Exception: + self.log.info('Could not create a clone file from original %s', path) + + return { + 'conflict_file_path': clone_file_path, + 'conflict_file_created': clone_file_created + } diff --git a/cs3api4lab/handlers.py b/cs3api4lab/handlers.py index c6082f63..c40a0609 100644 --- a/cs3api4lab/handlers.py +++ b/cs3api4lab/handlers.py @@ -105,6 +105,18 @@ def file_api(self): def get(self): yield RequestHandler.async_handle_request(self, self.file_api.get_home_dir, 200) +class LockHandler(APIHandler): + + @property + def contents_manager(self): + return self.settings["contents_manager"] + + @web.authenticated + @gen.coroutine + def post(self): + request = self.get_json_body() + yield RequestHandler.async_handle_request(self, self.contents_manager.create_clone_file, 200, request['file_path']) + class PublicSharesHandler(APIHandler): @property def public_share_api(self): @@ -219,7 +231,8 @@ def setup_handlers(web_app, url_path): (r"/api/cs3/user", UserInfoHandler), (r"/api/cs3/user/claim", UserInfoClaimHandler), (r"/api/cs3/user/query", UserQueryHandler), - (r"/api/cs3/user/home_dir", HomeDirHandler) + (r"/api/cs3/user/home_dir", HomeDirHandler), + (r"/api/cs3/locks/create_clone_file", LockHandler), ] for handler in handlers: diff --git a/cs3api4lab/locks/base.py b/cs3api4lab/locks/base.py index c374eb43..d9b1377e 100644 --- a/cs3api4lab/locks/base.py +++ b/cs3api4lab/locks/base.py @@ -38,19 +38,15 @@ def get_current_user(self): metadata=[('x-access-token', self.auth.authenticate())]) return self.user.user - def resolve_file_path(self, stat): - if self.is_valid_external_lock(stat): - file_name = stat['filepath'].split('/')[-1] - file_dir = '/'.join(stat['filepath'].split('/')[0:-1]) - return self._resolve_directory(file_dir, self.config.endpoint) + self._get_conflict_filename(file_name) - return stat['filepath'] + def resolve_file_path(self, path): + file_name = path.split('/')[-1] + return self._get_conflict_filename(file_name) @abstractmethod def is_valid_external_lock(self, stat): pass - def _resolve_directory(self, dir_path, - endpoint): # right now it's possible to write in somone else's directory without it being shared + def _resolve_directory(self, dir_path, endpoint): # right now it's possible to write in somone else's directory without it being shared stat = self.storage_api.stat(dir_path, endpoint) if stat.status.code == cs3code.CODE_OK: return dir_path diff --git a/cs3api4lab/tests/test_cs3apismanager.py b/cs3api4lab/tests/test_cs3apismanager.py index dbef1ba2..8dd2a93b 100644 --- a/cs3api4lab/tests/test_cs3apismanager.py +++ b/cs3api4lab/tests/test_cs3apismanager.py @@ -32,7 +32,7 @@ def test_get_text_file(self): self.file_api.write_file(file_id, message, self.endpoint) model = self.contents_manager.get(file_id, True, 'file') self.assertEqual(model["name"], "test_get_text_file.txt") - self.assertEqual(model["path"], file_id) + self.assertEqual(model["path"], "/reva/einstein/test_get_text_file.txt") self.assertEqual(model["content"], message) self.assertEqual(model["format"], "text") self.assertEqual(model["mimetype"], "text/plain") @@ -49,7 +49,7 @@ def test_get_text_file_without_type(self): self.file_api.write_file(file_id, message, self.endpoint) model = self.contents_manager.get(file_id, True, None) self.assertEqual(model["name"], "test_get_text_file_no_type.txt") - self.assertEqual(model["path"], file_id) + self.assertEqual(model["path"], "/reva/einstein/test_get_text_file_no_type.txt") self.assertEqual(model["content"], message) self.assertEqual(model["format"], "text") self.assertEqual(model["mimetype"], "text/plain") @@ -116,7 +116,7 @@ def test_get_notebook_file(self): self.file_api.write_file(file_id, buffer, self.endpoint) model = self.contents_manager.get(file_id, True, "notebook") self.assertEqual(model["name"], "test_get_notebook_file.ipynb") - self.assertEqual(model["path"], file_id) + self.assertEqual(model["path"], "/reva/einstein/test_get_notebook_file.ipynb") self.assertIn("### Markdown example", str(model["content"])) self.assertEqual(model["format"], "json") self.assertEqual(model["mimetype"], None) @@ -136,7 +136,7 @@ def test_save_text_model(self): try: save_model = self.contents_manager.save(model, file_id) self.assertEqual(save_model["name"], "test_save_text_model.txt") - self.assertEqual(save_model["path"], file_id) + self.assertEqual(save_model["path"], "/reva/einstein/test_save_text_model.txt") self.assertEqual(save_model["content"], None) self.assertEqual(save_model["format"], None) self.assertEqual(save_model["mimetype"], "text/plain") @@ -152,7 +152,7 @@ def test_save_notebook_model(self): try: save_model = self.contents_manager.save(model, file_id) self.assertEqual(save_model["name"], "test_save_notebook_model.ipynb") - self.assertEqual(save_model["path"], file_id) + self.assertEqual(save_model["path"], "/reva/einstein/test_save_notebook_model.ipynb") self.assertEqual(save_model["content"], None) self.assertEqual(save_model["format"], None) self.assertEqual(save_model["mimetype"], None) @@ -315,7 +315,7 @@ def test_new_file_model(self): self.contents_manager.new(model, file_path) model = self.contents_manager.get(file_path, True, 'file') self.assertEqual(model["name"], "test_new_file_model.txt") - self.assertEqual(model["path"], file_path) + self.assertEqual(model["path"], "/reva/einstein/test_new_file_model.txt") self.assertEqual(model["content"], "Test content") self.assertEqual(model["format"], "text") self.assertEqual(model["mimetype"], "text/plain") @@ -331,7 +331,7 @@ def test_new_notebook_model(self): try: save_model = self.contents_manager.new(model, file_path) self.assertEqual(save_model["name"], "test_new_notebook_model.ipynb") - self.assertEqual(save_model["path"], file_path) + self.assertEqual(save_model["path"], "/reva/einstein/test_new_notebook_model.ipynb") self.assertEqual(save_model["content"], None) self.assertEqual(save_model["format"], None) self.assertEqual(save_model["mimetype"], None) diff --git a/cs3api4lab/utils/share_utils.py b/cs3api4lab/utils/share_utils.py index c257308f..30df2a3e 100644 --- a/cs3api4lab/utils/share_utils.py +++ b/cs3api4lab/utils/share_utils.py @@ -80,16 +80,10 @@ def get_resource_permissions(role): def map_permissions_to_role(permissions): if permissions is None: return None - if permissions.get_path is True and \ - permissions.initiate_file_download is True and \ - permissions.list_container is True and \ - permissions.stat is True and \ - permissions.create_container is True and \ - permissions.delete is True and \ - permissions.initiate_file_upload is True and \ - permissions.restore_file_version is True and \ - permissions.move is True: - return Role.EDITOR + + if permissions.initiate_file_upload is True and \ + permissions.restore_file_version is True: + return Role.EDITOR else: return Role.VIEWER diff --git a/src/drive.ts b/src/drive.ts index 1a680feb..d0831591 100644 --- a/src/drive.ts +++ b/src/drive.ts @@ -5,6 +5,8 @@ import { DocumentRegistry } from '@jupyterlab/docregistry'; import { ISignal, Signal } from '@lumino/signaling'; import { IStateDB } from '@jupyterlab/statedb'; import { IDocumentManager } from '@jupyterlab/docmanager'; +import { Dialog, showDialog } from '@jupyterlab/apputils'; +import { ConflictFileResponse } from './types'; export class CS3Contents implements Contents.IDrive { protected _docRegistry: DocumentRegistry; @@ -55,7 +57,7 @@ export class CS3Contents implements Contents.IDrive { * The name of the drive. */ get name(): string { - return ''; + return 'cs3Files'; } /** @@ -73,7 +75,13 @@ export class CS3Contents implements Contents.IDrive { ): Promise { const activeTab: string = (await this._state.fetch('activeTab')) as string; if (activeTab === 'cs3filebrowser' || activeTab === undefined) { - return await CS3ContainerFiles('filelist', this._state, path, options); + return await CS3ContainerFiles( + 'filelist', + this._state, + path, + options, + this._docManager + ); } else { return Promise.resolve({} as Contents.IModel); } @@ -170,7 +178,13 @@ export class CS3ContentsShareByMe extends CS3Contents { ): Promise { const activeTab: string = (await this._state.fetch('activeTab')) as string; if (activeTab === 'sharesPanel') { - return await CS3ContainerFiles('by_me', this._state, path, options); + return await CS3ContainerFiles( + 'by_me', + this._state, + path, + options, + this._docManager + ); } else { return Promise.resolve({} as Contents.IModel); } @@ -188,7 +202,13 @@ export class CS3ContentsShareWithMe extends CS3Contents { ): Promise { const activeTab: string = (await this._state.fetch('activeTab')) as string; if (activeTab === 'sharesPanel') { - return await CS3ContainerFiles('with_me', this._state, path, options); + return await CS3ContainerFiles( + 'with_me', + this._state, + path, + options, + this._docManager + ); } else { return Promise.resolve({} as Contents.IModel); } @@ -199,7 +219,8 @@ export async function CS3ContainerFiles( readType: string, stateDB: IStateDB, path: string | null = null, - options: Contents.IFetchOptions = {} + options: Contents.IFetchOptions = {}, + docManager: IDocumentManager ): Promise { const share = await stateDB.fetch('share'); const showHidden: boolean = (await stateDB.fetch('showHidden')) as boolean; @@ -211,7 +232,7 @@ export async function CS3ContainerFiles( } if (path !== '') { - return await getFileList(path, options, showHidden, stateDB); + return await getFileList(path, options, showHidden, stateDB, docManager); } switch (shareType) { @@ -221,15 +242,55 @@ export async function CS3ContainerFiles( return await getSharedWithMe(); case 'filelist': default: - return await getFileList(path, options, showHidden, stateDB); + return await getFileList(path, options, showHidden, stateDB, docManager); } } +export function openLockedFileDialog( + path: string, + docManager: IDocumentManager +): void { + showDialog({ + body: 'This file is currently locked by another person', + buttons: [ + Dialog.cancelButton({ + label: 'Stay in preview mode', + className: 'jp-preview-button' + }), + Dialog.okButton({ + label: 'Create a copy', + className: 'jp-create-button' + }) + ] + }).then(async result => { + if (result.button.className === 'jp-create-button') { + await requestAPI('/api/cs3/locks/create_clone_file', { + method: 'POST', + body: JSON.stringify({ + file_path: path + }) + }) + .then(async response => { + const diffResponse = response as ConflictFileResponse; + if (diffResponse.conflict_file_path) { + docManager.openOrReveal(diffResponse.conflict_file_path); + await docManager.closeFile('cs3Files:' + path); + await docManager.closeFile('cs3driveShareWithMe:' + path); + await docManager.closeFile('cs3driveShareByMe:' + path); + } + }) + .catch(error => { + console.log('request failed', error); + }); + } + }); +} async function getFileList( path: string | null, options: Contents.IFetchOptions, showHidden: boolean, - stateDB: IStateDB + stateDB: IStateDB, + docManager: IDocumentManager ): Promise { const { type, format, content } = options; @@ -241,16 +302,20 @@ async function getFileList( if (format && type !== 'notebook') { url += '&format=' + format; } - const result: Contents.IModel = await requestAPI( - '/api/contents/' + path + '' + url, - { method: 'get' } - ); + const result: Contents.IModel & { + locked: boolean; + } = await requestAPI('/api/contents/' + path + '' + url, { method: 'get' }); + + if (path !== null && result.type !== 'directory' && result?.locked) { + openLockedFileDialog(path, docManager); + } // if it is a directory, count hidden files inside if (Array.isArray(result.content) && result.type === 'directory') { - const hiddenFilesNo: number = result.content.filter( - (file: { name: string }) => file.name.startsWith('.') - ).length; + const hiddenFilesNo: number = + result.content.filter((file: { name: string }) => + file.name.startsWith('.') + )?.length || 0; await stateDB.save('hiddenFilesNo', hiddenFilesNo); if (!showHidden) { diff --git a/src/index.ts b/src/index.ts index 5d809e7e..b31713e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,7 +123,7 @@ const factory: JupyterFrontEndPlugin = { const defaultBrowser = createFileBrowser('cs3filebrowser', { auto: true, restore: false, - driveName: '' + driveName: 'cs3Files' }); void restoreBrowser(defaultBrowser, commands, router, tree); diff --git a/src/infobox.tsx b/src/infobox.tsx index 3d06bfc6..30387aa7 100644 --- a/src/infobox.tsx +++ b/src/infobox.tsx @@ -446,8 +446,7 @@ const ShareForm: React.FC = ( const user = userValue[0] as { [key: string]: string }; const formValues = { endpoint: '/', - file_path: - '/' + shareProps.fileInfo.path.replace('cs3drive:', ''), + file_path: shareProps.fileInfo.path.replace('cs3drive:', ''), grantee: user.grantee, idp: user.idp, role: 'viewer', diff --git a/src/types.ts b/src/types.ts index d4bf257b..56f2fc36 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,3 +99,9 @@ export type PendingSharesContentProps = { hideWidget: () => void; showWidget: () => void; }; + +export type ConflictFileResponse = { + conflict_file_path: string; + conflic_file_exists: boolean; + conflict_file_created: boolean; +};