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 ea0b5bc89b..92450a45bb 100644 --- a/frontend/src/components/dir-view-mode/dir-column-view.js +++ b/frontend/src/components/dir-view-mode/dir-column-view.js @@ -84,6 +84,7 @@ const propTypes = { eventBus: PropTypes.object, updateCurrentDirent: PropTypes.func.isRequired, updateCurrentPath: PropTypes.func, + toggleShowDirentToolbar: PropTypes.func, }; class DirColumnView extends React.Component { @@ -225,6 +226,11 @@ class DirColumnView extends React.Component { renameFileCallback={this.props.renameFileCallback} updateCurrentDirent={this.props.updateCurrentDirent} updateCurrentPath={this.props.updateCurrentPath} + moveFileCallback={this.props.moveFileCallback} + copyFileCallback={this.props.copyFileCallback} + convertFileCallback={this.props.convertFileCallback} + addFolderCallback={this.props.onAddFolder} + toggleShowDirentToolbar={this.props.toggleShowDirentToolbar} /> )} {currentMode === LIST_MODE && ( diff --git a/frontend/src/components/toolbar/tag-files-toolbar.js b/frontend/src/components/toolbar/tag-files-toolbar.js new file mode 100644 index 0000000000..e4aa72be66 --- /dev/null +++ b/frontend/src/components/toolbar/tag-files-toolbar.js @@ -0,0 +1,143 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import ItemDropdownMenu from '../dropdown-menu/item-dropdown-menu'; +import { gettext } from '../../utils/constants'; +import { EVENT_BUS_TYPE } from '../../metadata/constants'; +import TextTranslation from '../../utils/text-translation'; +import { getFileById, getFileName, getTagFileOperationList } from '../../tag/utils/file'; + +const TagFilesToolbar = ({ currentRepoInfo }) => { + const [selectedFileIds, setSelectedFileIds] = useState([]); + const tagFilesRef = useRef([]); + + const canModify = window.sfTagsDataContext && window.sfTagsDataContext.canModify(); + const eventBus = window.sfTagsDataContext && window.sfTagsDataContext.eventBus; + + const selectedFilesLen = useMemo(() => { + return selectedFileIds.length; + }, [selectedFileIds]); + + const unSelect = useCallback(() => { + setSelectedFileIds([]); + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.UNSELECT_TAG_FILES); + }, [eventBus]); + + const moveTagFile = useCallback(() => { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.MOVE_TAG_FILE); + }, [eventBus]); + + const copyTagFile = useCallback(() => { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.COPY_TAG_FILE); + }, [eventBus]); + + const deleteTagFiles = useCallback(() => { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.DELETE_TAG_FILES); + }, [eventBus]); + + const downloadTagFiles = useCallback(() => { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.DOWNLOAD_TAG_FILES); + }, [eventBus]); + + const getMenuList = useCallback(() => { + if (selectedFilesLen > 1) return []; + const fileId = selectedFileIds[0]; + const file = getFileById(tagFilesRef.current, fileId); + const fileName = getFileName(file); + const allOperations = getTagFileOperationList(fileName, currentRepoInfo, canModify); + const excludesOperations = ['Move', 'Copy', 'Delete', 'Download']; + const validOperations = allOperations.filter((item) => { + return excludesOperations.indexOf(item.key) == -1; + }); + return validOperations; + }, [canModify, currentRepoInfo, selectedFileIds, selectedFilesLen]); + + const onMenuItemClick = useCallback((operation) => { + switch (operation) { + case TextTranslation.SHARE.key: + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.SHARE_TAG_FILE); + break; + case TextTranslation.RENAME.key: + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_RENAME_DIALOG); + break; + case TextTranslation.HISTORY.key: + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.FILE_HISTORY); + break; + case TextTranslation.ACCESS_LOG.key: + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.FILE_ACCESS_LOG); + break; + case TextTranslation.OPEN_VIA_CLIENT.key: + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.OPEN_VIA_CLIENT); + break; + case TextTranslation.CONVERT_TO_SDOC.key: + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.CONVERT_FILE, 'sdoc'); + break; + case TextTranslation.CONVERT_TO_MARKDOWN.key: { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.CONVERT_FILE, 'markdown'); + break; + } + case TextTranslation.CONVERT_TO_DOCX.key: { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.CONVERT_FILE, 'docx'); + break; + } + case TextTranslation.EXPORT_DOCX.key: { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.EXPORT_DOCX); + break; + } + case TextTranslation.EXPORT_SDOC.key: { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.EXPORT_SDOC); + break; + } + default: + break; + } + }, [eventBus]); + + useEffect(() => { + const unsubscribeSelectedFileIds = eventBus && eventBus.subscribe(EVENT_BUS_TYPE.SELECT_TAG_FILES, (ids, tagFiles) => { + tagFilesRef.current = tagFiles || []; + setSelectedFileIds(ids); + }); + + return () => { + unsubscribeSelectedFileIds && unsubscribeSelectedFileIds(); + }; + }, []); + + return ( +
+ + + {selectedFilesLen}{' '}{gettext('selected')} + + {(selectedFilesLen === 1 && canModify) && + <> + + + + + + + + } + {canModify && + <> + + + + + + + + } + {selectedFilesLen === 1 && + + } +
+ ); +}; + +export default TagFilesToolbar; diff --git a/frontend/src/metadata/components/cell-editors/file-name-editor.js b/frontend/src/metadata/components/cell-editors/file-name-editor.js index 333b9cab73..e6e22c35e3 100644 --- a/frontend/src/metadata/components/cell-editors/file-name-editor.js +++ b/frontend/src/metadata/components/cell-editors/file-name-editor.js @@ -39,10 +39,11 @@ const FileNameEditor = React.forwardRef((props, ref) => { const fileType = getFileType(); const repoID = window.sfMetadataContext.getSetting('repoID'); const repoInfo = window.sfMetadataContext.getSetting('repoInfo'); + const canDelete = window.sfMetadataContext.checkCanDeleteRow(); if (fileType === 'image') { return ( - + ); } diff --git a/frontend/src/metadata/components/cell-formatter/image-previewer.js b/frontend/src/metadata/components/cell-formatter/image-previewer.js index 1b2a503a55..0c3145f77c 100644 --- a/frontend/src/metadata/components/cell-formatter/image-previewer.js +++ b/frontend/src/metadata/components/cell-formatter/image-previewer.js @@ -9,7 +9,7 @@ import { Utils } from '../../../utils/utils'; import { siteRoot, thumbnailSizeForOriginal, fileServerRoot, thumbnailDefaultSize } from '../../../utils/constants'; import { getFileNameFromRecord, getParentDirFromRecord, getRecordIdFromRecord } from '../../utils/cell'; -const ImagePreviewer = ({ record, table, repoID, repoInfo, closeImagePopup, deleteRecords }) => { +const ImagePreviewer = ({ record, table, repoID, repoInfo, closeImagePopup, deleteRecords, canDelete }) => { const [imageIndex, setImageIndex] = useState(0); const [imageItems, setImageItems] = useState([]); @@ -95,8 +95,6 @@ const ImagePreviewer = ({ record, table, repoID, repoInfo, closeImagePopup, dele } }; - const canDelete = window.sfMetadataContext.checkCanDeleteRow(); - return ( { + if (error) { + let errMessage = Utils.getErrorMsg(error); + if (errMessage === gettext('Error')) { + const name = Utils.getFileName(path); + errMessage = gettext('Failed to convert {name}.').replace('{name}', name); + } + toaster.danger(errMessage, { 'id': 'conversion' }); + return; + } + const new_path = parentDir + '/' + newName; + const parentPath = Utils.getDirName(new_path); + if (this.state.isTreePanelShown) { + this.addNodeToTree(newName, parentPath, 'file'); + } + this.addDirent(newName, 'file', size); + const message = gettext('Successfully converted the file.'); + toaster.success(message, { 'id': 'conversion' }); + }; + onConvertItem = (dirent, dstType) => { let path = Utils.joinPath(this.state.path, dirent.name); let repoID = this.props.repoID; toaster.notifyInProgress(gettext('Converting, please wait...'), { 'id': 'conversion' }); seafileAPI.convertFile(repoID, path, dstType).then((res) => { - let newFileName = res.data.obj_name; - let parentDir = res.data.parent_dir; - let new_path = parentDir + '/' + newFileName; - let parentPath = Utils.getDirName(new_path); - - if (this.state.isTreePanelShown) { - this.addNodeToTree(newFileName, parentPath, 'file'); - } - - this.addDirent(newFileName, 'file', res.data.size); - let message = gettext('Successfully converted the file.'); - toaster.success(message, { 'id': 'conversion' }); - + const newFileName = res.data.obj_name; + const parentDir = res.data.parent_dir; + this.convertFileAjaxCallback({ newName: newFileName, parentDir, size: res.data.size }); }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - if (errMessage === gettext('Error')) { - let name = Utils.getFileName(path); - errMessage = gettext('Failed to convert {name}.').replace('{name}', name); - } - toaster.danger(errMessage, { 'id': 'conversion' }); + this.convertFileAjaxCallback({ path, error }); }); }; @@ -2164,6 +2170,10 @@ class LibContentView extends React.Component { this.setState({ path }); }; + toggleShowDirentToolbar = (isDirentSelected) => { + this.setState({ isDirentSelected }); + }; + render() { const { repoID } = this.props; let { currentRepoInfo, userPerm, isCopyMoveProgressDialogShow, isDeleteFolderDialogOpen, errorMsg, @@ -2235,7 +2245,7 @@ class LibContentView extends React.Component { return ( - +
@@ -2251,33 +2261,36 @@ class LibContentView extends React.Component { 'w-100': !isDesktop, 'animation-children': isDirentSelected })}> - {isDirentSelected ? - - : + {isDirentSelected ? ( + this.state.currentMode === TAGS_MODE ? + + : + + ) : ( - } + )}
{isDesktop &&
@@ -2389,6 +2402,7 @@ class LibContentView extends React.Component { moveFileCallback={this.moveItemsAjaxCallback} onItemCopy={this.onCopyItem} copyFileCallback={this.copyItemsAjaxCallback} + convertFileCallback={this.convertFileAjaxCallback} onItemConvert={this.onConvertItem} onDirentClick={this.onDirentClick} updateDirent={this.updateDirent} @@ -2405,6 +2419,7 @@ class LibContentView extends React.Component { eventBus={this.props.eventBus} updateCurrentDirent={this.updateCurrentDirent} updateCurrentPath={this.updatePath} + toggleShowDirentToolbar={this.toggleShowDirentToolbar} /> :
{gettext('Folder does not exist.')}
diff --git a/frontend/src/tag/constants/file.js b/frontend/src/tag/constants/file.js new file mode 100644 index 0000000000..782f03448d --- /dev/null +++ b/frontend/src/tag/constants/file.js @@ -0,0 +1,8 @@ +export const TAG_FILE_KEY = { + ID: '_id', + NAME: '_name', + SIZE: '_size', + PARENT_DIR: '_parent_dir', + FILE_MTIME: '_file_mtime', + TAGS: '_tags', +}; diff --git a/frontend/src/tag/hooks/tag-view.js b/frontend/src/tag/hooks/tag-view.js index 62861c9ee1..121015d227 100644 --- a/frontend/src/tag/hooks/tag-view.js +++ b/frontend/src/tag/hooks/tag-view.js @@ -1,19 +1,34 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; +import toaster from '../../components/toast'; import { Utils } from '../../utils/utils'; +import { metadataAPI } from '../../metadata'; import tagsAPI from '../api'; import { useTags } from './tags'; import { getTreeNodeById, getTreeNodeByKey } from '../../components/sf-table/utils/tree'; import { getAllChildTagsIdsFromNode } from '../utils/tree'; +import { seafileAPI } from '../../utils/seafile-api'; +import { TAG_FILE_KEY } from '../constants/file'; +import { EVENT_BUS_TYPE } from '../../metadata/constants'; +import { getFileById } from '../utils/file'; +import { getRowById } from '../../metadata/utils/table'; +import { getTagFilesLinks } from '../utils/cell'; +import { PRIVATE_COLUMN_KEY } from '../constants'; +import URLDecorator from '../../utils/url-decorator'; +import { fileServerRoot, useGoFileserver } from '../../utils/constants'; // This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc. const TagViewContext = React.createContext(null); -export const TagViewProvider = ({ repoID, tagID, nodeKey, children, ...params }) => { +export const TagViewProvider = ({ + repoID, tagID, nodeKey, children, moveFileCallback, copyFileCallback, addFolderCallback, deleteFilesCallback, renameFileCallback, convertFileCallback, + toggleShowDirentToolbar, ...params +}) => { const [isLoading, setLoading] = useState(true); const [tagFiles, setTagFiles] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [selectedFileIds, setSelectedFileIds] = useState([]); - const { tagsData } = useTags(); + const { tagsData, updateLocalTags } = useTags(); const getChildTagsIds = useCallback((tagID, nodeKey) => { let displayNode = null; @@ -26,6 +41,121 @@ export const TagViewProvider = ({ repoID, tagID, nodeKey, children, ...params }) return getAllChildTagsIdsFromNode(displayNode); }, [tagsData]); + const updateSelectedFileIds = useCallback((ids) => { + toggleShowDirentToolbar(ids.length > 0); + setSelectedFileIds(ids); + setTimeout(() => { + window.sfTagsDataContext && window.sfTagsDataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_TAG_FILES, ids, tagFiles); + }, 0); + }, [setSelectedFileIds, tagFiles, toggleShowDirentToolbar]); + + const moveTagFile = useCallback((targetRepo, dirent, targetParentPath, sourceParentPath, isByDialog) => { + seafileAPI.moveDir(repoID, targetRepo.repo_id, targetParentPath, sourceParentPath, dirent.name).then(res => { + moveFileCallback && moveFileCallback(repoID, targetRepo, dirent, targetParentPath, sourceParentPath, res.data.task_id || null, isByDialog); + updateSelectedFileIds([]); + }); + }, [repoID, moveFileCallback, updateSelectedFileIds]); + + const copyTagFile = useCallback((targetRepo, dirent, targetParentPath, sourceParentPath, isByDialog) => { + seafileAPI.copyDir(repoID, targetRepo.repo_id, targetParentPath, sourceParentPath, dirent.name).then(res => { + copyFileCallback && copyFileCallback(repoID, targetRepo, dirent, targetParentPath, sourceParentPath, res.data.task_id || null, isByDialog); + updateSelectedFileIds([]); + }); + }, [repoID, copyFileCallback, updateSelectedFileIds]); + + const deleteTagFiles = useCallback(() => { + const files = selectedFileIds.map(id => getFileById(tagFiles, id)); + const paths = files.map(f => Utils.joinPath(f[TAG_FILE_KEY.PARENT_DIR], f[TAG_FILE_KEY.NAME])); + const fileNames = files.map(f => f[TAG_FILE_KEY.NAME]); + metadataAPI.batchDeleteFiles(repoID, paths).then(() => { + const updatedTags = new Set(); + files.forEach(file => { + file._tags.forEach(tag => updatedTags.add(tag.row_id)); + }); + + let idTagUpdates = {}; + updatedTags.forEach(tagID => { + const row = getRowById(tagsData, tagID); + const oldTagFileLinks = getTagFilesLinks(row); + if (Array.isArray(oldTagFileLinks) && oldTagFileLinks.length > 0) { + const newTagFileLinks = oldTagFileLinks.filter(link => !selectedFileIds.includes(link.row_id)); + const update = { [PRIVATE_COLUMN_KEY.TAG_FILE_LINKS]: newTagFileLinks }; + idTagUpdates[tagID] = update; + } + }); + updateLocalTags([...updatedTags], idTagUpdates); + + setTagFiles(prevTagFiles => ({ + ...prevTagFiles, + rows: prevTagFiles.rows.filter(row => !selectedFileIds.includes(row[TAG_FILE_KEY.ID])), + })); + + deleteFilesCallback && deleteFilesCallback(paths, fileNames); + updateSelectedFileIds([]); + }); + }, [repoID, tagsData, tagFiles, selectedFileIds, updateLocalTags, deleteFilesCallback, updateSelectedFileIds]); + + const getDownloadTarget = useCallback(() => { + if (!selectedFileIds.length) return []; + return selectedFileIds.map(id => { + const file = getFileById(tagFiles, id); + const path = file[TAG_FILE_KEY.PARENT_DIR] === '/' ? file[TAG_FILE_KEY.NAME] : `${file[TAG_FILE_KEY.PARENT_DIR]}/${file[TAG_FILE_KEY.NAME]}`; + return path; + }); + }, [tagFiles, selectedFileIds]); + + const downloadTagFiles = useCallback(() => { + if (!selectedFileIds.length) return; + if (selectedFileIds.length === 1) { + const file = getFileById(tagFiles, selectedFileIds[0]); + const filePath = Utils.joinPath(file[TAG_FILE_KEY.PARENT_DIR], file[TAG_FILE_KEY.NAME]); + const url = URLDecorator.getUrl({ type: 'download_file_url', repoID, filePath }); + location.href = url; + return; + } + if (!useGoFileserver) { + window.sfTagsDataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_ZIP_DIALOG); + return; + } + + const target = getDownloadTarget(); + metadataAPI.zipDownload(repoID, '/', target).then(res => { + const zipToken = res.data['zip_token']; + location.href = `${fileServerRoot}zip/${zipToken}`; + }).catch(error => { + const errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }, [repoID, tagFiles, selectedFileIds, getDownloadTarget]); + + const renameTagFile = useCallback((id, path, newName) => { + seafileAPI.renameFile(repoID, path, newName).then(res => { + renameFileCallback && renameFileCallback(path, newName); + setTagFiles(prevTagFiles => ({ + ...prevTagFiles, + rows: prevTagFiles.rows.map(row => { + if (row[TAG_FILE_KEY.ID] === id) { + return { ...row, [TAG_FILE_KEY.NAME]: newName }; + } + return row; + }) + })); + }).catch(error => { + const errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }, [repoID, renameFileCallback]); + + const convertFile = useCallback((path, dstType) => { + seafileAPI.convertFile(repoID, path, dstType).then((res) => { + const newFileName = res.data.obj_name; + const parentDir = res.data.parent_dir; + convertFileCallback({ newName: newFileName, parentDir, size: res.data.size }); + }).catch((error) => { + convertFileCallback({ path, error }); + }); + }, [repoID, convertFileCallback]); + useEffect(() => { setLoading(true); const childTagsIds = getChildTagsIds(tagID, nodeKey); @@ -53,9 +183,17 @@ export const TagViewProvider = ({ repoID, tagID, nodeKey, children, ...params }) repoID, tagID, repoInfo: params.repoInfo, - deleteFilesCallback: params.deleteFilesCallback, - renameFileCallback: params.renameFileCallback, updateCurrentDirent: params.updateCurrentDirent, + selectedFileIds, + updateSelectedFileIds, + moveTagFile, + copyTagFile, + addFolder: addFolderCallback, + deleteTagFiles, + getDownloadTarget, + downloadTagFiles, + renameTagFile, + convertFile, }}> {children} diff --git a/frontend/src/tag/hooks/tags.js b/frontend/src/tag/hooks/tags.js index f2c2a9de13..d70caece0c 100644 --- a/frontend/src/tag/hooks/tags.js +++ b/frontend/src/tag/hooks/tags.js @@ -192,6 +192,29 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. modifyLocalTags(tagIds, idTagUpdates, { [tagId]: originalRowUpdates }, { [tagId]: oldRowData }, { [tagId]: originalOldRowData }, { success_callback, fail_callback }); }, [tagsData, modifyLocalTags]); + const updateLocalTags = useCallback((tagIds, idTagUpdates, { success_callback, fail_callback } = { }) => { + if (!Array.isArray(tagIds) || tagIds.length === 0) { + return; + } + let idOriginalRowUpdates = {}; + let idOldRowData = {}; + let idOriginalOldRowData = {}; + tagIds.forEach((tagId) => { + const tag = getRowById(tagsData, tagId); + const tagUpdates = idTagUpdates[tagId]; + if (tagUpdates) { + Object.keys(tagUpdates).forEach((key) => { + const column = tagsData.key_column_map[key]; + const columnName = getColumnOriginName(column); + idOriginalRowUpdates[tagId] = Object.assign({}, idOriginalRowUpdates[tagId], { [key]: tagUpdates[key] }); + idOldRowData[tagId] = Object.assign({}, idOldRowData[tagId], { [key]: getCellValueByColumn(tag, column) }); + idOriginalOldRowData[tagId] = Object.assign({}, idOriginalOldRowData[tagId], { [columnName]: getCellValueByColumn(tag, column) }); + }); + } + }); + modifyLocalTags(tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback }); + }, [tagsData, modifyLocalTags]); + const addTagLinks = useCallback((columnKey, tagId, otherTagsIds, { success_callback, fail_callback } = {}) => { storeRef.current.addTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback); }, [storeRef]); @@ -291,9 +314,10 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. deleteTagsLinks, mergeTags, updateLocalTag, + updateLocalTags, selectTag: handleSelectTag, modifyColumnWidth, - modifyLocalFileTags, + modifyLocalFileTags }}> {children} diff --git a/frontend/src/tag/tags-tree-view/index.js b/frontend/src/tag/tags-tree-view/index.js index 88b55aecb2..9107a4351c 100644 --- a/frontend/src/tag/tags-tree-view/index.js +++ b/frontend/src/tag/tags-tree-view/index.js @@ -8,6 +8,7 @@ import { PRIVATE_COLUMN_KEY, ALL_TAGS_ID } from '../constants'; import { checkTreeNodeHasChildNodes, getTreeChildNodes, getTreeNodeDepth, getTreeNodeId, getTreeNodeKey } from '../../components/sf-table/utils/tree'; import { getRowById } from '../../metadata/utils/table'; import { SIDEBAR_INIT_LEFT_INDENT } from '../constants/sidebar-tree'; +import { EVENT_BUS_TYPE } from '../../metadata/constants'; import './index.css'; @@ -77,6 +78,7 @@ const TagsTreeView = ({ currentPath }) => { const nodeKey = getTreeNodeKey(node); selectTag(tag, nodeKey); setCurrSelectedNodeKey(nodeKey); + window.sfTagsDataContext && window.sfTagsDataContext.eventBus.dispatch(EVENT_BUS_TYPE.UNSELECT_TAG_FILES); }, [tagsData, selectTag]); const selectAllTags = useCallback((isSelected) => { diff --git a/frontend/src/tag/utils/file.js b/frontend/src/tag/utils/file.js new file mode 100644 index 0000000000..64c1591e0e --- /dev/null +++ b/frontend/src/tag/utils/file.js @@ -0,0 +1,62 @@ +import { enableSeadoc, fileAuditEnabled, isPro } from '../../utils/constants'; +import TextTranslation from '../../utils/text-translation'; +import { Utils } from '../../utils/utils'; +import { TAG_FILE_KEY } from '../constants/file'; + +export const getFileById = (tagFiles, fileId) => { + return fileId ? tagFiles.rows.find(file => file._id === fileId) : ''; +}; + +export const getFileName = (file) => { + return file ? file[TAG_FILE_KEY.NAME] : ''; +}; + +export const getFileParentDir = (file) => { + return file ? file[TAG_FILE_KEY.PARENT_DIR] : ''; +}; + +export const getTagFileOperationList = (fileName, repo, canModify) => { + const { + SHARE, DOWNLOAD, DELETE, RENAME, MOVE, COPY, HISTORY, ACCESS_LOG, OPEN_VIA_CLIENT, CONVERT_AND_EXPORT, CONVERT_TO_MARKDOWN, CONVERT_TO_DOCX, EXPORT_DOCX, CONVERT_TO_SDOC, EXPORT_SDOC + } = TextTranslation; + let menuList = [DOWNLOAD, SHARE]; + if (canModify) { + menuList.push(DELETE, 'Divider', RENAME, MOVE, COPY); + if (enableSeadoc && !repo.encrypted) { + menuList.push('Divider'); + if (fileName.endsWith('.md') || fileName.endsWith('.docx')) { + menuList.push(CONVERT_TO_SDOC); + } + if (fileName.endsWith('.sdoc')) { + if (Utils.isDesktop()) { + let subOpList = [CONVERT_TO_MARKDOWN, CONVERT_TO_DOCX, EXPORT_DOCX, EXPORT_SDOC]; + menuList.push({ ...CONVERT_AND_EXPORT, subOpList }); + } else { + menuList.push(CONVERT_TO_MARKDOWN); + menuList.push(CONVERT_TO_DOCX); + menuList.push(EXPORT_DOCX); + menuList.push(EXPORT_SDOC); + } + } + } + menuList.push('Divider', HISTORY); + if (isPro && fileAuditEnabled) { + menuList.push(ACCESS_LOG); + } + menuList.push('Divider', OPEN_VIA_CLIENT); + } + + // if the last item of menuList is ‘Divider’, delete the last item + if (menuList[menuList.length - 1] === 'Divider') { + menuList.pop(); + } + + // Remove adjacent excess 'Divider' + for (let i = 0; i < menuList.length; i++) { + if (menuList[i] === 'Divider' && menuList[i + 1] === 'Divider') { + menuList.splice(i, 1); + i--; + } + } + return menuList; +}; diff --git a/frontend/src/tag/views/tag-files/index.css b/frontend/src/tag/views/tag-files/index.css index 169670e511..33b0ca563d 100644 --- a/frontend/src/tag/views/tag-files/index.css +++ b/frontend/src/tag/views/tag-files/index.css @@ -1,3 +1,5 @@ .sf-metadata-tag-files-wrapper .sf-metadata-tags-main { + display: flex; + flex-direction: column; overflow: auto !important; } diff --git a/frontend/src/tag/views/tag-files/index.js b/frontend/src/tag/views/tag-files/index.js index e08cf1f3a6..13525b7ae1 100644 --- a/frontend/src/tag/views/tag-files/index.js +++ b/frontend/src/tag/views/tag-files/index.js @@ -1,25 +1,67 @@ -import React, { useCallback, useState, useRef, useMemo } from 'react'; +import React, { useCallback, useState, useRef, useMemo, useEffect } from 'react'; import { useTagView, useTags } from '../../hooks'; -import { gettext } from '../../../utils/constants'; -import TagFile from './tag-file'; -import { getRecordIdFromRecord } from '../../../metadata/utils/cell'; +import { gettext, username } from '../../../utils/constants'; import EmptyTip from '../../../components/empty-tip'; -import ImagePreviewer from '../../../metadata/components/cell-formatter/image-previewer'; +import toaster from '../../../components/toast'; +import ContextMenu from '../../../components/context-menu/context-menu'; +import MoveDirent from '../../../components/dialog/move-dirent-dialog'; +import CopyDirent from '../../../components/dialog/copy-dirent-dialog'; +import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog'; +import ShareDialog from '../../../components/dialog/share-dialog'; +import FileAccessLog from '../../../components/dialog/file-access-log'; +import Rename from '../../../components/dialog/rename-dirent'; import FixedWidthTable from '../../../components/common/fixed-width-table'; +import ImagePreviewer from '../../../metadata/components/cell-formatter/image-previewer'; +import TagFile from './tag-file'; +import TextTranslation from '../../../utils/text-translation'; +import { getRecordIdFromRecord } from '../../../metadata/utils/cell'; +import { seafileAPI } from '../../../utils/seafile-api'; +import { TAG_FILE_KEY } from '../../constants/file'; +import { Utils } from '../../../utils/utils'; +import { getFileById, getFileName, getFileParentDir, getTagFileOperationList } from '../../utils/file'; +import { EVENT_BUS_TYPE } from '../../../metadata/constants'; +import { hideMenu, showMenu } from '../../../components/context-menu/actions'; +import URLDecorator from '../../../utils/url-decorator'; import './index.css'; +const TAG_FILE_CONTEXT_MENU_ID = 'tag-files-context-menu'; + const TagFiles = () => { - const { tagFiles, repoID, repoInfo } = useTagView(); const { tagsData } = useTags(); - const [selectedFiles, setSelectedFiles] = useState(null); + const { + tagFiles, repoID, repoInfo, selectedFileIds, updateSelectedFileIds, + moveTagFile, copyTagFile, addFolder, deleteTagFiles, renameTagFile, getDownloadTarget, downloadTagFiles, convertFile, + } = useTagView(); + + const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); + const [isCopyDialogOpen, setIsCopyDialogOpen] = useState(false); + const [isZipDialogOpen, setIsZipDialogOpen] = useState(false); + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isImagePreviewerVisible, setImagePreviewerVisible] = useState(false); + const [isFileAccessLogDialogOpen, setIsFileAccessLogDialogOpen] = useState(false); const currentImageRef = useRef(null); const isSelectedAll = useMemo(() => { - return selectedFiles ? selectedFiles.length === tagFiles.rows.length : false; - }, [selectedFiles, tagFiles]); + return selectedFileIds ? selectedFileIds.length === tagFiles.rows.length : false; + }, [selectedFileIds, tagFiles]); + + // selectedFile + const selectedFile = useMemo(() => { + if (!selectedFileIds || selectedFileIds.length === 0) return null; + return getFileById(tagFiles, selectedFileIds[0]); + }, [selectedFileIds, tagFiles]); + const selectedFileParentDir = useMemo(() => { + return getFileParentDir(selectedFile); + }, [selectedFile]); + const selectedFileName = useMemo(() => { + return getFileName(selectedFile); + }, [selectedFile]); + const selectedFilePath = useMemo(() => { + return selectedFileParentDir && selectedFileName ? Utils.joinPath(selectedFileParentDir, selectedFileName) : ''; + }, [selectedFileParentDir, selectedFileName]); const onMouseDown = useCallback((event) => { if (event.button === 2) { @@ -38,30 +80,48 @@ const TagFiles = () => { const onSelectedAll = useCallback(() => { if (isSelectedAll) { - setSelectedFiles([]); + updateSelectedFileIds([]); } else { const allIds = tagFiles.rows.map(record => getRecordIdFromRecord(record)); - setSelectedFiles(allIds); + updateSelectedFileIds(allIds); } - }, [tagFiles, isSelectedAll]); + }, [tagFiles, isSelectedAll, updateSelectedFileIds]); - const onSelectFile = useCallback((fileId) => { - let newSelectedFiles = selectedFiles ? selectedFiles.slice(0) : []; - if (newSelectedFiles.includes(fileId)) { - newSelectedFiles = newSelectedFiles.filter(item => item !== fileId); - } else { - newSelectedFiles.push(fileId); - } - if (newSelectedFiles.length > 0) { - setSelectedFiles(newSelectedFiles); - } else { - setSelectedFiles(null); - } - }, [selectedFiles]); + const onContainerClick = (event) => { + hideMenu(); + if (selectedFileIds.length > 0) updateSelectedFileIds([]); + }; - const reSelectFiles = useCallback((fileId) => { - setSelectedFiles([fileId]); - }, []); + const onSelectFile = useCallback((event, fileId) => { + if (event.target.tagName === 'TD') { + updateSelectedFileIds([fileId]); + return; + } + const newSelectedFileIds = selectedFileIds.includes(fileId) + ? selectedFileIds.filter(id => id !== fileId) + : [...selectedFileIds, fileId]; + updateSelectedFileIds(newSelectedFileIds); + }, [selectedFileIds, updateSelectedFileIds]); + + const toggleMoveDialog = useCallback(() => { + setIsMoveDialogOpen(!isMoveDialogOpen); + }, [isMoveDialogOpen]); + + const toggleCopyDialog = useCallback(() => { + setIsCopyDialogOpen(!isCopyDialogOpen); + }, [isCopyDialogOpen]); + + const toggleZipDialog = useCallback(() => { + setIsZipDialogOpen(!isZipDialogOpen); + }, [isZipDialogOpen]); + + const toggleShareDialog = useCallback(() => { + setIsShareDialogOpen(!isShareDialogOpen); + }, [isShareDialogOpen]); + + const toggleRenameDialog = useCallback(() => { + setIsRenameDialogOpen(!isRenameDialogOpen); + }, [isRenameDialogOpen]); const openImagePreview = useCallback((record) => { currentImageRef.current = record; @@ -73,6 +133,197 @@ const TagFiles = () => { setImagePreviewerVisible(false); }, []); + const handleDeleteTagFiles = useCallback(() => { + deleteTagFiles(); + updateSelectedFileIds([]); + }, [deleteTagFiles, updateSelectedFileIds]); + + const handleRenameTagFile = useCallback((newName) => { + const path = selectedFile[TAG_FILE_KEY.PARENT_DIR]; + const newPath = Utils.joinPath(path, newName); + seafileAPI.getFileInfo(repoID, newPath).then(() => { + let errMessage = gettext('The name "{name}" is already taken. Please choose a different name.'); + errMessage = errMessage.replace('{name}', Utils.HTMLescape(newName)); + toaster.danger(errMessage); + }).catch(error => { + if (error && error.response && error.response.status === 404) { + const fullPath = Utils.joinPath(path, selectedFile[TAG_FILE_KEY.NAME]); + renameTagFile(selectedFile[TAG_FILE_KEY.ID], fullPath, newName); + } else { + const errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + } + }); + }, [repoID, selectedFile, renameTagFile]); + + const openViaClient = useCallback(() => { + let url = URLDecorator.getUrl({ type: 'open_via_client', repoID, selectedFilePath }); + location.href = url; + }, [repoID, selectedFilePath]); + + const onHistory = useCallback(() => { + let url = URLDecorator.getUrl({ + type: 'file_revisions', + repoID: repoID, + filePath: selectedFilePath, + }); + location.href = url; + }, [repoID, selectedFilePath]); + + const onConvertFile = useCallback((dstType) => { + toaster.notifyInProgress(gettext('Converting, please wait...'), { 'id': 'conversion' }); + convertFile(selectedFilePath, dstType); + }, [selectedFilePath, convertFile]); + + const exportDocx = useCallback((dirent) => { + const serviceUrl = window.app.config.serviceURL; + const exportToDocxUrl = serviceUrl + '/repo/sdoc_export_to_docx/' + repoID + '/?file_path=' + selectedFilePath; + window.location.href = exportToDocxUrl; + }, [repoID, selectedFilePath]); + + const exportSdoc = useCallback((dirent) => { + const serviceUrl = window.app.config.serviceURL; + const exportToSdocUrl = serviceUrl + '/lib/' + repoID + '/file/' + selectedFilePath + '?dl=1'; + window.location.href = exportToSdocUrl; + }, [repoID, selectedFilePath]); + + const getMenuContainerSize = useCallback(() => { + return { + width: window.innerWidth, + height: window.innerHeight, + }; + }, []); + + const getMenuList = useCallback((file) => { + const { DOWNLOAD, DELETE } = TextTranslation; + if (selectedFileIds.length > 1) { + return [DOWNLOAD, DELETE]; + } + const canModify = window.sfTagsDataContext && window.sfTagsDataContext.canModify(); + const fileName = file ? getFileName(file) : selectedFileName; + return getTagFileOperationList(fileName, repoInfo, canModify); + }, [selectedFileIds, selectedFileName, repoInfo]); + + const onMenuItemClick = useCallback((option) => { + if (!option) return; + switch (option) { + case TextTranslation.MOVE.key: + toggleMoveDialog(); + break; + case TextTranslation.COPY.key: + toggleCopyDialog(); + break; + case TextTranslation.DELETE.key: + handleDeleteTagFiles(); + break; + case TextTranslation.SHARE.key: + toggleShareDialog(); + break; + case TextTranslation.DOWNLOAD.key: + downloadTagFiles(); + break; + case TextTranslation.RENAME.key: + window.sfTagsDataContext && window.sfTagsDataContext.eventBus.dispatch(EVENT_BUS_TYPE.RENAME_TAG_FILE, selectedFileIds[0]); + break; + case TextTranslation.CONVERT_TO_SDOC.key: + onConvertFile('sdoc'); + break; + case TextTranslation.CONVERT_TO_MARKDOWN.key: { + onConvertFile('markdown'); + break; + } + case TextTranslation.CONVERT_TO_DOCX.key: { + onConvertFile('docx'); + break; + } + case TextTranslation.EXPORT_DOCX.key: { + exportDocx(); + break; + } + case TextTranslation.EXPORT_SDOC.key: { + exportSdoc(); + break; + } + case TextTranslation.HISTORY.key: + onHistory(); + break; + case TextTranslation.ACCESS_LOG.key: + setIsFileAccessLogDialogOpen(true); + break; + case TextTranslation.OPEN_VIA_CLIENT.key: + openViaClient(); + break; + default: + break; + } + hideMenu(); + }, [toggleMoveDialog, toggleCopyDialog, handleDeleteTagFiles, downloadTagFiles, selectedFileIds, onConvertFile, exportDocx, exportSdoc, toggleShareDialog, openViaClient, onHistory]); + + const onTagFileContextMenu = useCallback((event, file) => { + let menuList = []; + if (selectedFileIds.length <= 1) { + const fileId = getRecordIdFromRecord(file); + updateSelectedFileIds([fileId]); + menuList = getMenuList(file); + } else { + menuList = getMenuList(); + } + const id = TAG_FILE_CONTEXT_MENU_ID; + let x = event.clientX || (event.touches && event.touches[0].pageX); + let y = event.clientY || (event.touches && event.touches[0].pageY); + + hideMenu(); + + let showMenuConfig = { + id: id, + position: { x, y }, + target: event.target, + currentObject: file, + menuList: menuList, + }; + + if (menuList.length === 0) { + return; + } + + showMenu(showMenuConfig); + }, [selectedFileIds, updateSelectedFileIds, getMenuList]); + + useEffect(() => { + if (!window.sfTagsDataContext) return; + const unsubscribeUnselectFiles = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.UNSELECT_TAG_FILES, () => updateSelectedFileIds([])); + const unsubscribeDeleteTagFiles = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.DELETE_TAG_FILES, deleteTagFiles); + const unsubScribeMoveTagFile = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.MOVE_TAG_FILE, toggleMoveDialog); + const unsubScribeCopyTagFile = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.COPY_TAG_FILE, toggleCopyDialog); + const unsubscribeShareTagFile = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.SHARE_TAG_FILE, toggleShareDialog); + const unsubscribeRenameTagFile = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.TOGGLE_RENAME_DIALOG, toggleRenameDialog); + const unsubscribeDownloadTagFiles = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.DOWNLOAD_TAG_FILES, downloadTagFiles); + const unsubscribeZipDownload = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.TOGGLE_ZIP_DIALOG, toggleZipDialog); + const unsubscribeFileHistory = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.FILE_HISTORY, onHistory); + const unsubscribeFileAccessLog = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.FILE_ACCESS_LOG, () => setIsFileAccessLogDialogOpen(true)); + const unsubscribeOpenViaClient = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.OPEN_VIA_CLIENT, openViaClient); + const unsubscribeConvertFile = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.CONVERT_FILE, onConvertFile); + const unsubscribeExportDocx = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.EXPORT_DOCX, exportDocx); + const unsubscribeExportSDoc = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.EXPORT_SDOC, exportSdoc); + + return () => { + unsubscribeUnselectFiles(); + unsubscribeDeleteTagFiles(); + unsubScribeMoveTagFile(); + unsubScribeCopyTagFile(); + unsubscribeShareTagFile(); + unsubscribeRenameTagFile(); + unsubscribeDownloadTagFiles(); + unsubscribeZipDownload(); + unsubscribeFileHistory(); + unsubscribeFileAccessLog(); + unsubscribeOpenViaClient(); + unsubscribeConvertFile(); + unsubscribeExportDocx(); + unsubscribeExportSDoc(); + }; + }); + if (tagFiles.rows.length === 0) { return (); } @@ -117,9 +368,18 @@ const TagFiles = () => { } ]; + let enableDirPrivateShare = false; + let isRepoOwner = repoInfo.owner_email === username; + let isVirtual = repoInfo.is_virtual; + let isAdmin = repoInfo.is_admin; + if (!isVirtual && (isRepoOwner || isAdmin)) { + enableDirPrivateShare = true; + } + const isGroupOwnedRepo = repoInfo.owner_email.includes('@seafile_group'); + const canDelete = window.sfTagsDataContext && window.sfTagsDataContext.canModifyTag(); return ( <> -
+
{ ); })} @@ -151,8 +412,75 @@ const TagFiles = () => { record={currentImageRef.current} table={tagFiles} closeImagePopup={closeImagePreviewer} + canDelete={canDelete} /> )} + + {isMoveDialogOpen && ( + + )} + {isCopyDialogOpen && ( + + )} + {isZipDialogOpen && ( + + )} + {isShareDialogOpen && + + } + {isRenameDialogOpen && + {}} + toggleCancel={toggleRenameDialog} + /> + } + {isFileAccessLogDialogOpen && + setIsFileAccessLogDialogOpen(false)} + /> + } ); }; diff --git a/frontend/src/tag/views/tag-files/tag-file/index.js b/frontend/src/tag/views/tag-files/tag-file/index.js index d4c15821d3..9c9b48461a 100644 --- a/frontend/src/tag/views/tag-files/tag-file/index.js +++ b/frontend/src/tag/views/tag-files/tag-file/index.js @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import dayjs from 'dayjs'; @@ -10,14 +10,18 @@ import { getParentDirFromRecord, getRecordIdFromRecord, getFileNameFromRecord, g } from '../../../../metadata/utils/cell'; import { Utils } from '../../../../utils/utils'; import { openFile } from '../../../../metadata/utils/file'; +import Rename from '../../../../components/rename'; +import { TAG_FILE_KEY } from '../../../constants/file'; +import { EVENT_BUS_TYPE } from '../../../../metadata/constants'; import './index.css'; dayjs.extend(relativeTime); -const TagFile = ({ isSelected, repoID, file, tagsData, onSelectFile, reSelectFiles, openImagePreview }) => { +const TagFile = ({ isSelected, repoID, file, tagsData, onSelectFile, openImagePreview, onRenameFile, onContextMenu }) => { const [highlight, setHighlight] = useState(false); const [isIconLoadError, setIconLoadError] = useState(false); + const [isRenameing, setIsRenaming] = useState(false); const fileId = useMemo(() => getRecordIdFromRecord(file), [file]); const parentDir = useMemo(() => getParentDirFromRecord(file), [file]); @@ -62,7 +66,7 @@ const TagFile = ({ isSelected, repoID, file, tagsData, onSelectFile, reSelectFil const handleSelected = useCallback((event) => { event.stopPropagation(); - onSelectFile(fileId); + onSelectFile(event, fileId); }, [fileId, onSelectFile]); const onIconLoadError = useCallback(() => { @@ -71,16 +75,45 @@ const TagFile = ({ isSelected, repoID, file, tagsData, onSelectFile, reSelectFil const handelClickFileName = useCallback((event) => { event.preventDefault(); + if (isRenameing) return; openFile(repoID, file, () => { openImagePreview(file); }); - }, [repoID, file, openImagePreview]); + }, [repoID, file, openImagePreview, isRenameing]); const handelClick = useCallback((event) => { - if (event.target.tagName == 'TD') { - reSelectFiles(fileId); - } - }, [fileId, reSelectFiles]); + event.stopPropagation(); + if (isRenameing) return; + onSelectFile(event, fileId); + }, [fileId, onSelectFile, isRenameing]); + + const onRenameCancel = useCallback(() => { + setIsRenaming(false); + }, []); + + const onRenameConfirm = useCallback((newName) => { + onRenameFile(newName); + onRenameCancel(); + }, [onRenameFile, onRenameCancel]); + + const toggleRename = useCallback((id) => { + if (id === file[TAG_FILE_KEY.ID]) setIsRenaming(true); + }, [file]); + + const handleContextMenu = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + onContextMenu(event, file); + }, [file, onContextMenu]); + + useEffect(() => { + if (!window.sfTagsDataContext) return; + const unsubscribeRenameTagFile = window.sfTagsDataContext.eventBus.subscribe(EVENT_BUS_TYPE.RENAME_TAG_FILE, (id) => toggleRename(id)); + + return () => { + unsubscribeRenameTagFile && unsubscribeRenameTagFile(); + }; + }, [toggleRename]); return ( - {name} + {isRenameing ? ( + + ) : ( + {name} + )} @@ -129,6 +172,7 @@ TagFile.propTypes = { onSelectFile: PropTypes.func, openImagePreview: PropTypes.func, reSelectFiles: PropTypes.func, + onRenameFile: PropTypes.func, }; export default TagFile;