diff --git a/frontend/src/components/dir-view-mode/dir-column-view.js b/frontend/src/components/dir-view-mode/dir-column-view.js index 2e89c6aef7..4d7f04ad35 100644 --- a/frontend/src/components/dir-view-mode/dir-column-view.js +++ b/frontend/src/components/dir-view-mode/dir-column-view.js @@ -57,6 +57,7 @@ const propTypes = { onItemRename: PropTypes.func.isRequired, onItemMove: PropTypes.func.isRequired, onItemCopy: PropTypes.func.isRequired, + onItemConvert: PropTypes.func.isRequired, onDirentClick: PropTypes.func.isRequired, isAllItemSelected: PropTypes.bool.isRequired, onAllItemSelected: PropTypes.func.isRequired, @@ -217,6 +218,7 @@ class DirColumnView extends React.Component { onItemRename={this.props.onItemRename} onItemMove={this.props.onItemMove} onItemCopy={this.props.onItemCopy} + onItemConvert={this.props.onItemConvert} onDirentClick={this.props.onDirentClick} updateDirent={this.props.updateDirent} isAllItemSelected={this.props.isAllItemSelected} diff --git a/frontend/src/components/dir-view-mode/dir-grid-view.js b/frontend/src/components/dir-view-mode/dir-grid-view.js index 7c5ccfc761..8dfb88ced6 100644 --- a/frontend/src/components/dir-view-mode/dir-grid-view.js +++ b/frontend/src/components/dir-view-mode/dir-grid-view.js @@ -19,6 +19,7 @@ const propTypes = { onItemDelete: PropTypes.func.isRequired, onItemMove: PropTypes.func.isRequired, onItemCopy: PropTypes.func.isRequired, + onItemConvert: PropTypes.func.isRequired, onRenameNode: PropTypes.func.isRequired, isGroupOwnedRepo: PropTypes.bool.isRequired, userPerm: PropTypes.string, @@ -73,6 +74,7 @@ class DirGridView extends React.Component { onItemDelete={this.props.onItemDelete} onItemMove={this.props.onItemMove} onItemCopy={this.props.onItemCopy} + onItemConvert={this.props.onItemConvert} isDirentListLoading={this.props.isDirentListLoading} updateDirent={this.props.updateDirent} onRenameNode={this.props.onRenameNode} diff --git a/frontend/src/components/dir-view-mode/dir-list-view.js b/frontend/src/components/dir-view-mode/dir-list-view.js index afd1512385..5f96440f72 100644 --- a/frontend/src/components/dir-view-mode/dir-list-view.js +++ b/frontend/src/components/dir-view-mode/dir-list-view.js @@ -36,6 +36,7 @@ const propTypes = { selectedDirentList: PropTypes.array.isRequired, onItemsMove: PropTypes.func.isRequired, onItemsCopy: PropTypes.func.isRequired, + onItemConvert: PropTypes.func.isRequired, onItemsDelete: PropTypes.func.isRequired, onFileTagChanged: PropTypes.func, showDirentDetail: PropTypes.func.isRequired, @@ -96,6 +97,7 @@ class DirListView extends React.Component { selectedDirentList={this.props.selectedDirentList} onItemsMove={this.props.onItemsMove} onItemsCopy={this.props.onItemsCopy} + onItemConvert={this.props.onItemConvert} onItemsDelete={this.props.onItemsDelete} onAddFile={this.props.onAddFile} onAddFolder={this.props.onAddFolder} diff --git a/frontend/src/components/dirent-grid-view/dirent-grid-view.js b/frontend/src/components/dirent-grid-view/dirent-grid-view.js index 6b3d8ac265..5c5567a142 100644 --- a/frontend/src/components/dirent-grid-view/dirent-grid-view.js +++ b/frontend/src/components/dirent-grid-view/dirent-grid-view.js @@ -33,6 +33,7 @@ const propTypes = { onAddFile: PropTypes.func, onItemDelete: PropTypes.func, onItemCopy: PropTypes.func.isRequired, + onItemConvert: PropTypes.func.isRequired, onItemMove: PropTypes.func.isRequired, onRenameNode: PropTypes.func.isRequired, onItemClick: PropTypes.func.isRequired, @@ -115,6 +116,11 @@ class DirentGridView extends React.Component { this.props.onItemDelete(currentObject); }; + onItemConvert = (currentObject, e, dstType) => { + e.nativeEvent.stopImmediatePropagation(); //for document event + this.props.onItemConvert(currentObject, dstType); + } + onMenuItemClick = (operation, currentObject, event) => { hideMenu(); switch(operation) { @@ -136,6 +142,12 @@ class DirentGridView extends React.Component { case 'Copy': this.onItemCopyToggle(); break; + case 'Convert to Markdown': + this.onItemConvert(currentObject, event, 'markdown'); + break; + case 'Convert to sdoc': + this.onItemConvert(currentObject, event, 'sdoc'); + break; case 'Tags': this.onEditFileTagToggle(); break; diff --git a/frontend/src/components/dirent-list-view/dirent-list-item.js b/frontend/src/components/dirent-list-view/dirent-list-item.js index 328320ce38..b7ae4b779d 100644 --- a/frontend/src/components/dirent-list-view/dirent-list-item.js +++ b/frontend/src/components/dirent-list-view/dirent-list-item.js @@ -35,6 +35,7 @@ const propTypes = { onItemRename: PropTypes.func.isRequired, onItemMove: PropTypes.func.isRequired, onItemCopy: PropTypes.func.isRequired, + onItemConvert: PropTypes.func.isRequired, onDirentClick: PropTypes.func.isRequired, updateDirent: PropTypes.func.isRequired, showImagePopup: PropTypes.func.isRequired, @@ -268,6 +269,12 @@ class DirentListItem extends React.Component { case 'Lock': this.onLockItem(); break; + case 'Convert to Markdown': + this.onItemConvert(event, 'markdown'); + break; + case 'Convert to sdoc': + this.onItemConvert(event, 'sdoc'); + break; case 'Mark as draft': this.onMarkAsDraft(); break; @@ -299,6 +306,12 @@ class DirentListItem extends React.Component { } }; + onItemConvert = (e, dstType)=> { + e.preventDefault(); + e.nativeEvent.stopImmediatePropagation(); //for document event + this.props.onItemConvert(this.props.dirent, dstType); + } + onEditFileTagToggle = () => { this.setState({ isEditFileTagShow: !this.state.isEditFileTagShow diff --git a/frontend/src/components/dirent-list-view/dirent-list-view.js b/frontend/src/components/dirent-list-view/dirent-list-view.js index 26b51a9c8e..7871d84dcc 100644 --- a/frontend/src/components/dirent-list-view/dirent-list-view.js +++ b/frontend/src/components/dirent-list-view/dirent-list-view.js @@ -42,6 +42,7 @@ const propTypes = { selectedDirentList: PropTypes.array.isRequired, onItemsMove: PropTypes.func.isRequired, onItemsCopy: PropTypes.func.isRequired, + onItemConvert: PropTypes.func.isRequired, onItemsDelete: PropTypes.func.isRequired, onFileTagChanged: PropTypes.func, enableDirPrivateShare: PropTypes.bool.isRequired, @@ -620,6 +621,7 @@ class DirentListView extends React.Component { onItemRename={this.onItemRename} onItemMove={this.props.onItemMove} onItemCopy={this.props.onItemCopy} + onItemConvert={this.props.onItemConvert} updateDirent={this.props.updateDirent} isItemFreezed={this.state.isItemFreezed} freezeItem={this.freezeItem} diff --git a/frontend/src/pages/lib-content-view/lib-content-container.js b/frontend/src/pages/lib-content-view/lib-content-container.js index f295efb952..1d7bee64ed 100644 --- a/frontend/src/pages/lib-content-view/lib-content-container.js +++ b/frontend/src/pages/lib-content-view/lib-content-container.js @@ -69,6 +69,7 @@ const propTypes = { onItemCopy: PropTypes.func.isRequired, onAddFolder: PropTypes.func.isRequired, onAddFile: PropTypes.func.isRequired, + onItemConvert: PropTypes.func.isRequired, onFileTagChanged: PropTypes.func.isRequired, isDirentSelected: PropTypes.bool.isRequired, isAllDirentSelected: PropTypes.bool.isRequired, @@ -229,6 +230,7 @@ class LibContentContainer extends React.Component { onItemRename={this.props.onItemRename} onItemMove={this.onItemMove} onItemCopy={this.props.onItemCopy} + onItemConvert={this.props.onItemConvert} onDirentClick={this.onDirentClick} updateDirent={this.props.updateDirent} isAllItemSelected={this.props.isAllDirentSelected} @@ -264,6 +266,7 @@ class LibContentContainer extends React.Component { onItemDelete={this.props.onItemDelete} onItemMove={this.onItemMove} onItemCopy={this.props.onItemCopy} + onItemConvert={this.props.onItemConvert} updateDirent={this.props.updateDirent} onAddFolder={this.props.onAddFolder} showDirentDetail={this.props.showDirentDetail} @@ -322,6 +325,7 @@ class LibContentContainer extends React.Component { onItemRename={this.props.onItemRename} onItemMove={this.onItemMove} onItemCopy={this.props.onItemCopy} + onItemConvert={this.props.onItemConvert} onDirentClick={this.onDirentClick} updateDirent={this.props.updateDirent} isAllItemSelected={this.props.isAllDirentSelected} diff --git a/frontend/src/pages/lib-content-view/lib-content-view.js b/frontend/src/pages/lib-content-view/lib-content-view.js index 9bede7fe74..a11fcd031e 100644 --- a/frontend/src/pages/lib-content-view/lib-content-view.js +++ b/frontend/src/pages/lib-content-view/lib-content-view.js @@ -1236,6 +1236,32 @@ class LibContentView extends React.Component { }); }; + onConvertItem = (dirent, dstType) => { + let path = Utils.joinPath(this.state.path, dirent.name); + let repoID = this.props.repoID; + seafileAPI.convertFile(repoID, path, dstType).then((res) => { + let objName = res.data.obj_name; + let parentDir = res.data.parent_dir; + let file_size = res.data.size; + path = parentDir + '/' + objName; + let name = Utils.getFileName(path); + let parentPath = Utils.getDirName(path); + + if (this.state.currentMode === 'column') { + this.updateMoveCopyTreeNode(parentPath); + } + this.loadDirentList(this.state.path); + + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + if (errMessage === gettext('Error')) { + let name = Utils.getFileName(path); + errMessage = gettext('Renaming {name} failed').replace('{name}', name); + } + toaster.danger(errMessage); + }); + } + onDirentClick = (dirent) => { let direntList = this.state.direntList.map(dirent => { dirent.isSelected = false; @@ -2025,6 +2051,7 @@ class LibContentView extends React.Component { onItemRename={this.onMainPanelItemRename} onItemMove={this.onMoveItem} onItemCopy={this.onCopyItem} + onItemConvert={this.onConvertItem} onAddFolder={this.onAddFolder} onAddFile={this.onAddFile} onFileTagChanged={this.onFileTagChanged} diff --git a/frontend/src/utils/text-translation.js b/frontend/src/utils/text-translation.js index 2ea702a1cc..6e43c623b5 100644 --- a/frontend/src/utils/text-translation.js +++ b/frontend/src/utils/text-translation.js @@ -16,6 +16,8 @@ const TextTranslation = { 'OPEN_VIA_CLIENT' : {key : 'Open via Client', value : gettext('Open via Client')}, 'LOCK' : {key : 'Lock', value : gettext('Lock')}, 'UNLOCK' : {key : 'Unlock', value : gettext('Unlock')}, + 'CONVERT_TO_MARKDOWN' : {key : 'Convert to Markdown', value : gettext('Convert to Markdown')}, + 'CONVERT_TO_SDOC' : {key : 'Convert to sdoc', value : gettext('Convert to sdoc')}, 'MARK_AS_DRAFT' : {key : 'Mark as draft', value : gettext('Mark as draft')}, 'UNMARK_AS_DRAFT' : {key : 'Unmark as draft', value : gettext('Unmark as draft')}, 'COMMENT' : {key : 'Comment', value : gettext('Comment')}, diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 1c0448c262..d364f8d6db 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -1,4 +1,4 @@ -import { mediaUrl, gettext, serviceURL, siteRoot, isPro, fileAuditEnabled, canGenerateShareLink, canGenerateUploadLink, shareLinkPasswordMinLength, username, folderPermEnabled, onlyofficeConverterExtensions, enableOnlyoffice } from './constants'; +import { mediaUrl, gettext, serviceURL, siteRoot, isPro, fileAuditEnabled, canGenerateShareLink, canGenerateUploadLink, shareLinkPasswordMinLength, username, folderPermEnabled, onlyofficeConverterExtensions, enableOnlyoffice, enableSeadoc } from './constants'; import TextTranslation from './text-translation'; import React from 'react'; import toaster from '../components/toast'; @@ -528,7 +528,7 @@ export const Utils = { getFileOperationList: function(isRepoOwner, currentRepoInfo, dirent, isContextmenu) { let list = []; const { SHARE, DOWNLOAD, DELETE, RENAME, MOVE, COPY, TAGS, UNLOCK, LOCK, MARK_AS_DRAFT, UNMARK_AS_DRAFT, - HISTORY, ACCESS_LOG, PROPERTIES, OPEN_VIA_CLIENT, ONLYOFFICE_CONVERT } = TextTranslation; + HISTORY, ACCESS_LOG, PROPERTIES, OPEN_VIA_CLIENT, ONLYOFFICE_CONVERT, CONVERT_TO_MARKDOWN, CONVERT_TO_SDOC } = TextTranslation; const permission = dirent.permission; const { isCustomPermission, customPermission } = Utils.getUserPermission(permission); @@ -595,6 +595,19 @@ export const Utils = { } list.push('Divider'); + } + + if ((permission == 'rw' || permission == 'cloud-edit') && enableSeadoc) { + if (dirent.name.endsWith('.md')) { + list.push(CONVERT_TO_SDOC); + } + + if (dirent.name.endsWith('.sdoc')) { + list.push(CONVERT_TO_MARKDOWN); + } + } + + if (permission == 'rw') { if (Utils.isSdocFile(dirent.name)) { if (dirent.is_sdoc_draft) { list.push(UNMARK_AS_DRAFT); @@ -602,6 +615,7 @@ export const Utils = { list.push(MARK_AS_DRAFT); } } + list.push('Divider'); /* if (enableFileComment) { list.push(COMMENT); diff --git a/seahub/api2/endpoints/file.py b/seahub/api2/endpoints/file.py index cf34d7c626..cd34fba602 100644 --- a/seahub/api2/endpoints/file.py +++ b/seahub/api2/endpoints/file.py @@ -5,6 +5,7 @@ import json import logging import posixpath import requests +from pathlib import Path from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated @@ -20,18 +21,20 @@ from seahub.api2.utils import api_error from seahub.utils import check_filename_with_rename, is_pro_version, \ gen_inner_file_upload_url, is_valid_dirent_name, normalize_file_path, \ - normalize_dir_path, get_file_type_and_ext + normalize_dir_path, get_file_type_and_ext, check_filename_or_rename from seahub.utils.timeutils import timestamp_to_isoformat_timestr from seahub.views import check_folder_permission from seahub.utils.file_op import check_file_lock, if_locked_by_online_office from seahub.views.file import can_preview_file, can_edit_file from seahub.constants import PERMISSION_READ_WRITE from seahub.utils.repo import parse_repo_perm, is_repo_admin, is_repo_owner -from seahub.utils.file_types import MARKDOWN, TEXT, SEADOC +from seahub.utils.file_types import MARKDOWN, TEXT, SEADOC, MARKDOWN_SUPPORT_CONVERT_TYPES, SDOC_SUPPORT_CONVERT_TYPES from seahub.tags.models import FileUUIDMap from seahub.seadoc.models import SeadocHistoryName, SeadocDraft, SeadocCommentReply from seahub.base.models import FileComment from seahub.settings import MAX_UPLOAD_FILE_NAME_LEN, OFFICE_TEMPLATE_ROOT +from seahub.api2.endpoints.utils import convert_file +from seahub.seadoc.utils import get_seadoc_file_uuid from seahub.drafts.models import Draft from seahub.drafts.utils import is_draft_file, get_file_draft @@ -152,8 +155,8 @@ class FileView(APIView): return api_error(status.HTTP_400_BAD_REQUEST, error_msg) operation = operation.lower() - if operation not in ('create', 'rename', 'move', 'copy', 'revert'): - error_msg = "operation can only be 'create', 'rename', 'move', 'copy' or 'revert'." + if operation not in ('create', 'rename', 'move', 'copy', 'revert', 'convert'): + error_msg = "operation can only be 'create', 'rename', 'move', 'copy', 'convert' or 'revert'." return api_error(status.HTTP_400_BAD_REQUEST, error_msg) # resource check @@ -523,6 +526,80 @@ class FileView(APIView): return Response({'success': True}) + if operation == 'convert': + dst_type = request.data.get('dst_type') + + extension = Path(path).suffix + if extension not in ['.md', '.sdoc']: + error_msg = 'path invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if (extension == '.md' and dst_type not in MARKDOWN_SUPPORT_CONVERT_TYPES) or \ + (extension == '.sdoc' and dst_type not in SDOC_SUPPORT_CONVERT_TYPES): + error_msg = 'dst_type invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # resource check + try: + file_id = seafile_api.get_file_id_by_path(repo_id, path) + except SearpcError as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + if not file_id: + error_msg = 'File %s not found.' % path + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + if parse_repo_perm(check_folder_permission(request, repo_id, parent_dir)).can_edit_on_web is False: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # check file lock + try: + is_locked, locked_by_me = check_file_lock(repo_id, path, username) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + if is_locked and not locked_by_me: + error_msg = _("File is locked") + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + filename = os.path.basename(path) + + if extension == '.md': + src_type = 'markdown' + filename = filename[:-2] + 'sdoc' + elif extension == '.sdoc': + src_type = 'sdoc' + filename = filename[:-4] + 'md' + + new_file_name = check_filename_or_rename(repo_id, parent_dir, filename) + new_file_path = posixpath.join(parent_dir, new_file_name) + + download_token = seafile_api.get_fileserver_access_token(repo_id, file_id, 'download', username) + + obj_id = json.dumps({'parent_dir': parent_dir}) + upload_token = seafile_api.get_fileserver_access_token(repo_id, obj_id, 'upload-link', username, + use_onetime=True) + doc_uuid = get_seadoc_file_uuid(repo, path) + + try: + resp = convert_file(path, username, doc_uuid, download_token, upload_token, src_type, dst_type) + if resp.status_code == 500: + logger.error('convert file error status: %s body: %s', resp.status_code, resp.text) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + file_info = self.get_file_info(username, repo_id, new_file_path) + return Response(file_info) + def put(self, request, repo_id, format=None): """ Currently only support lock, unlock, refresh-lock file. diff --git a/seahub/api2/endpoints/utils.py b/seahub/api2/endpoints/utils.py index 50749e481e..7443645478 100644 --- a/seahub/api2/endpoints/utils.py +++ b/seahub/api2/endpoints/utils.py @@ -4,6 +4,9 @@ import datetime import time import urllib.request, urllib.parse, urllib.error import logging +import requests +import jwt +from urllib.parse import urljoin from rest_framework import status @@ -11,9 +14,9 @@ from seaserv import ccnet_api, seafile_api from pysearpc import SearpcError from seahub.api2.utils import api_error -from seahub.base.templatetags.seahub_tags import email2nickname, \ - email2contact_email +from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email from seahub.utils import get_log_events_by_time, is_pro_version, is_org_context +from seahub.settings import SECRET_KEY, FILE_CONVERTER_SERVER_URL try: from seahub.settings import MULTI_TENANCY @@ -210,3 +213,26 @@ def get_user_quota_usage_and_total(email, org_id=''): quota_usage = -1 quota_total = -1 return quota_usage, quota_total + + +def gen_headers(): + payload = {'exp': int(time.time()) + 300, } + token = jwt.encode(payload, SECRET_KEY, algorithm='HS256') + return {"Authorization": "Token %s" % token} + + +def convert_file(path, username, doc_uuid, download_token, upload_token, src_type, dst_type): + headers = gen_headers() + params = { + 'path': path, + 'username': username, + 'doc_uuid': doc_uuid, + 'download_token': download_token, + 'upload_token': upload_token, + 'src_type': src_type, + 'dst_type': dst_type, + } + url = urljoin(FILE_CONVERTER_SERVER_URL, '/api/v1/file-convert/') + resp = requests.post(url, json=params, headers=headers, timeout=30) + + return resp diff --git a/seahub/settings.py b/seahub/settings.py index 39521952d0..684229d903 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -623,6 +623,8 @@ SHOW_LOGOUT_ICON = False PRIVACY_POLICY_LINK = '' TERMS_OF_SERVICE_LINK = '' +FILE_CONVERTER_SERVER_URL = 'http://127.0.0.1:8888' + # For security consideration, please set to match the host/domain of your site, e.g., ALLOWED_HOSTS = ['.example.com']. # Please refer https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts for details. ALLOWED_HOSTS = ['*'] diff --git a/seahub/utils/__init__.py b/seahub/utils/__init__.py index f987ef94e6..2baf5aaa83 100644 --- a/seahub/utils/__init__.py +++ b/seahub/utils/__init__.py @@ -334,6 +334,10 @@ def get_no_duplicate_obj_name(obj_name, exist_obj_names): i += 1 def check_filename_with_rename(repo_id, parent_dir, obj_name): + exist_obj_names = list_obj_names_in_dir(repo_id, parent_dir) + return get_no_duplicate_obj_name(obj_name, exist_obj_names) + +def list_obj_names_in_dir(repo_id, parent_dir): cmmts = seafile_api.get_commit_list(repo_id, 0, 1) latest_commit = cmmts[0] if cmmts else None if not latest_commit: @@ -343,6 +347,13 @@ def check_filename_with_rename(repo_id, parent_dir, obj_name): latest_commit.id, parent_dir) exist_obj_names = [dirent.obj_name for dirent in dirents] + return exist_obj_names + +def check_filename_or_rename(repo_id, parent_dir, obj_name): + exist_obj_names = list_obj_names_in_dir(repo_id, parent_dir) + + if obj_name not in exist_obj_names: + return obj_name return get_no_duplicate_obj_name(obj_name, exist_obj_names) def get_user_repos(username, org_id=None): diff --git a/seahub/utils/file_types.py b/seahub/utils/file_types.py index d34020b2d6..878fd4061d 100644 --- a/seahub/utils/file_types.py +++ b/seahub/utils/file_types.py @@ -10,3 +10,7 @@ AUDIO = 'Audio' SPREADSHEET = 'SpreadSheet' XMIND = 'XMind' SEADOC = 'SDoc' + + +MARKDOWN_SUPPORT_CONVERT_TYPES = ['sdoc'] +SDOC_SUPPORT_CONVERT_TYPES = ['markdown']