From 4d1d71c85d89a47a2a06c0d22af8e60cbc91570b Mon Sep 17 00:00:00 2001 From: Aries Date: Sat, 30 Aug 2025 11:13:23 +0800 Subject: [PATCH] Feature/table operations (#8149) * add move/copy/download operations on table rows * optimize * fix permission --------- Co-authored-by: zhouwenxuan --- .../components/toolbar/table-files-toolbar.js | 62 ++++- .../src/metadata/constants/event-bus-type.js | 3 + frontend/src/metadata/hooks/metadata-view.js | 216 +++++++++++++++++- frontend/src/metadata/store/data-processor.js | 4 +- frontend/src/metadata/store/index.js | 33 +++ .../src/metadata/store/operations/apply.js | 8 +- .../metadata/store/operations/constants.js | 6 + .../src/metadata/store/server-operator.js | 28 +++ .../src/metadata/views/table/context-menu.js | 51 ++++- .../lib-content-view/lib-content-view.js | 87 +++++-- 10 files changed, 453 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/toolbar/table-files-toolbar.js b/frontend/src/components/toolbar/table-files-toolbar.js index 1b608c4c28..729d0558f9 100644 --- a/frontend/src/components/toolbar/table-files-toolbar.js +++ b/frontend/src/components/toolbar/table-files-toolbar.js @@ -8,7 +8,7 @@ import { getFileName } from '../../tag/utils/file'; import RowUtils from '../../metadata/views/table/utils/row-utils'; import { checkIsDir } from '../../metadata/utils/row'; import { Utils } from '../../utils/utils'; -import { getFileNameFromRecord } from '../../metadata/utils/cell'; +import { getFileNameFromRecord, getParentDirFromRecord } from '../../metadata/utils/cell'; import { getColumnByKey } from '../../metadata/utils/column'; import { openInNewTab, openParentFolder } from '../../metadata/utils/file'; @@ -22,6 +22,12 @@ const TableFilesToolbar = ({ repoID }) => { const records = useMemo(() => selectedRecordIds.map(id => RowUtils.getRecordById(id, metadataRef.current)).filter(Boolean) || [], [selectedRecordIds]); + const areRecordsInSameFolder = useMemo(() => { + if (records.length <= 1) return true; + const firstPath = records[0] ? getParentDirFromRecord(records[0]) : null; + return firstPath && records.every(record => getParentDirFromRecord(record) === firstPath); + }, [records]); + const unSelect = useCallback(() => { setSelectedRecordIds([]); eventBus && eventBus.dispatch(EVENT_BUS_TYPE.UPDATE_SELECTED_RECORD_IDS, []); @@ -37,16 +43,32 @@ const TableFilesToolbar = ({ repoID }) => { }, [eventBus, selectedRecordIds]); const toggleMoveDialog = useCallback(() => { - eventBus && eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, records[0]); + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, records); }, [eventBus, records]); + const toggleCopyDialog = useCallback(() => { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_COPY_DIALOG, records); + }, [eventBus, records]); + + const downloadRecords = useCallback(() => { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.DOWNLOAD_RECORDS, selectedRecordIds); + }, [eventBus, selectedRecordIds]); + const checkCanModifyRow = (row) => window.sfMetadataContext.canModifyRow(row); const getMenuList = useCallback(() => { - const { EXTRACT_FILE_DETAIL, EXTRACT_FILE_DETAILS, OPEN_FILE_IN_NEW_TAB, OPEN_FOLDER_IN_NEW_TAB, OPEN_PARENT_FOLDER, GENERATE_DESCRIPTION, OCR } = TextTranslation; + const { EXTRACT_FILE_DETAIL, EXTRACT_FILE_DETAILS, OPEN_FILE_IN_NEW_TAB, OPEN_FOLDER_IN_NEW_TAB, OPEN_PARENT_FOLDER, GENERATE_DESCRIPTION, OCR, COPY, DOWNLOAD, MOVE } = TextTranslation; const length = selectedRecordIds.length; const list = []; if (length > 1) { + if (areRecordsInSameFolder) { + if (canModify) { + list.push(MOVE); + list.push(COPY); + } + list.push(DOWNLOAD); + } + if (enableSeafileAI) { const imageOrVideoRecords = records.filter(record => { if (checkIsDir(record) || !checkCanModifyRow(record)) return false; @@ -54,6 +76,7 @@ const TableFilesToolbar = ({ repoID }) => { return Utils.imageCheck(fileName) || Utils.videoCheck(fileName); }); if (imageOrVideoRecords.length > 0) { + list.push('Divider'); list.push(EXTRACT_FILE_DETAILS); } } @@ -69,6 +92,8 @@ const TableFilesToolbar = ({ repoID }) => { list.push(OPEN_PARENT_FOLDER); const modifyOptions = []; + modifyOptions.push(COPY); + modifyOptions.push(DOWNLOAD); if (modifyOptions.length > 0) { list.push('Divider'); @@ -103,11 +128,23 @@ const TableFilesToolbar = ({ repoID }) => { } } return list; - }, [selectedRecordIds, records]); + }, [selectedRecordIds, records, canModify, areRecordsInSameFolder]); const onMenuItemClick = useCallback((operation) => { const records = selectedRecordIds.map(id => RowUtils.getRecordById(id, metadataRef.current)).filter(Boolean); switch (operation) { + case TextTranslation.MOVE.key: { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, records); + break; + } + case TextTranslation.COPY.key: { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_COPY_DIALOG, records); + break; + } + case TextTranslation.DOWNLOAD.key: { + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.DOWNLOAD_RECORDS, selectedRecordIds); + break; + } case TextTranslation.EXTRACT_FILE_DETAIL.key: case TextTranslation.EXTRACT_FILE_DETAILS.key: { const imageOrVideoRecords = records.filter(record => { @@ -172,6 +209,23 @@ const TableFilesToolbar = ({ repoID }) => { } + {(length > 1 && canModify && areRecordsInSameFolder) && + <> + + + + + } + {((length === 1) || (length > 1 && areRecordsInSameFolder)) && + <> + + + + + + + + } {canModify && diff --git a/frontend/src/metadata/constants/event-bus-type.js b/frontend/src/metadata/constants/event-bus-type.js index 18c944d2a4..66c46004c9 100644 --- a/frontend/src/metadata/constants/event-bus-type.js +++ b/frontend/src/metadata/constants/event-bus-type.js @@ -43,6 +43,9 @@ export const EVENT_BUS_TYPE = { TOGGLE_MOVE_DIALOG: 'toggle_move_dialog', MOVE_RECORD: 'move_record', DUPLICATE_RECORD: 'duplicate_record', + TOGGLE_COPY_DIALOG: 'toggle_copy_dialog', + COPY_RECORDS: 'copy_records', + DOWNLOAD_RECORDS: 'download_records', DELETE_RECORDS: 'delete_records', UPDATE_RECORD_DETAILS: 'update_record_details', UPDATE_FACE_RECOGNITION: 'update_face_recognition', diff --git a/frontend/src/metadata/hooks/metadata-view.js b/frontend/src/metadata/hooks/metadata-view.js index b3daee561d..5d205a8c80 100644 --- a/frontend/src/metadata/hooks/metadata-view.js +++ b/frontend/src/metadata/hooks/metadata-view.js @@ -38,7 +38,7 @@ export const MetadataViewProvider = ({ const { collaborators, collaboratorsCache } = useCollaborators(); const { isBeingBuilt, setIsBeingBuilt } = useMetadata(); const { onOCR: OCRAPI, generateDescription, extractFilesDetails, faceRecognition, generateFileTags: generateFileTagsAPI } = useMetadataAIOperations(); - const { handleMove } = useFileOperations(); + const { handleMove, handleCopy, handleDownload } = useFileOperations(); const { globalHiddenColumns } = useMetadataStatus(); const storeRef = useRef(null); @@ -419,16 +419,206 @@ export const MetadataViewProvider = ({ }); }, [updateFileTags, generateFileTagsAPI]); - const handleMoveRecord = (record) => { - const path = getParentDirFromRecord(record); - const currentRecordId = getRecordIdFromRecord(record); - const fileName = getFileNameFromRecord(record); - const dirent = new Dirent({ name: fileName }); - const callback = (...params) => { - window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); - moveRecord && moveRecord(currentRecordId, ...params); + const areRecordsInSameFolder = useCallback((records) => { + if (!records || records.length <= 1) return true; + const firstPath = getParentDirFromRecord(records[0]); + return records.every(record => getParentDirFromRecord(record) === firstPath); + }, []); + + const calculateBatchMoveUpdateData = useCallback((records, targetRepoId, targetParentPath, sourceParentPath) => { + const { rows } = storeRef.current.data || metadata; + let needDeletedRowIds = []; + let updateRowIds = []; + let idRowUpdates = {}; + let idOldRowData = {}; + + records.forEach(record => { + const rowId = getRecordIdFromRecord(record); + const isDir = checkIsDir(record); + const oldName = getFileNameFromRecord(record); + const oldParentPath = Utils.joinPath(sourceParentPath, oldName); + + if (repoID === targetRepoId) { + const newName = getUniqueFileName(rows, targetParentPath, oldName); + updateRowIds.push(rowId); + idRowUpdates[rowId] = { + [PRIVATE_COLUMN_KEY.PARENT_DIR]: targetParentPath, + [PRIVATE_COLUMN_KEY.FILE_NAME]: newName + }; + idOldRowData[rowId] = { + [PRIVATE_COLUMN_KEY.PARENT_DIR]: sourceParentPath, + [PRIVATE_COLUMN_KEY.FILE_NAME]: oldName + }; + + if (isDir) { + const newPath = Utils.joinPath(targetParentPath, newName); + rows.forEach((row) => { + const parentDir = getParentDirFromRecord(row); + if (row && parentDir.startsWith(oldParentPath)) { + const updateRowId = getRecordIdFromRecord(row); + updateRowIds.push(updateRowId); + idRowUpdates[updateRowId] = { [PRIVATE_COLUMN_KEY.PARENT_DIR]: parentDir.replace(oldParentPath, newPath) }; + idOldRowData[updateRowId] = { [PRIVATE_COLUMN_KEY.PARENT_DIR]: parentDir }; + } + }); + } + } else { + needDeletedRowIds.push(rowId); + if (isDir) { + rows.forEach((row) => { + const parentDir = getParentDirFromRecord(row); + if (row && parentDir.startsWith(oldParentPath)) { + const id = getRecordIdFromRecord(row); + needDeletedRowIds.push(id); + } + }); + } + } + }); + + return { + modify_row_ids: updateRowIds, + modify_id_row_updates: idRowUpdates, + modify_id_old_row_data: idOldRowData, + delete_row_ids: needDeletedRowIds, }; - handleMove(path, dirent, false, callback); + }, [metadata, repoID]); + + const handleMoveRecords = (records) => { + if (records.length > 1) { + if (!areRecordsInSameFolder(records)) { + toaster.danger(gettext('Can only move files that are in the same folder')); + return; + } + + const path = getParentDirFromRecord(records[0]); + const recordIds = records.map(r => getRecordIdFromRecord(r)); + const dirents = records.map(r => getFileNameFromRecord(r)); + const selectedDirentList = records.map(r => { + const fileName = getFileNameFromRecord(r); + return new Dirent({ name: fileName, is_dir: checkIsDir(r) }); + }); + + const callback = (destRepo, destDirentPath, isByDialog = false) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + const updateData = calculateBatchMoveUpdateData(records, destRepo.repo_id, destDirentPath, path); + + storeRef.current.moveRecords(recordIds, destRepo.repo_id, dirents, destDirentPath, path, updateData, { + success_callback: (operation) => { + if (selectedDirentList.length > 0) { + moveFileCallback && moveFileCallback( + repoID, + destRepo, + selectedDirentList[0], + destDirentPath, + path, + operation.task_id, + isByDialog, + { + isBatchOperation: true, + batchFileNames: dirents, + } + ); + } + }, + fail_callback: (error) => { + error && toaster.danger(error); + } + }); + }; + handleMove(path, selectedDirentList, true, callback); + } else { + // Single record + const path = getParentDirFromRecord(records[0]); + const currentRecordId = getRecordIdFromRecord(records[0]); + const fileName = getFileNameFromRecord(records[0]); + const dirent = new Dirent({ name: fileName, is_dir: checkIsDir(records[0]) }); + const callback = (...params) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + moveRecord && moveRecord(currentRecordId, ...params); + }; + handleMove(path, dirent, false, callback); + } + }; + + const handleCopyRecords = (records) => { + if (records.length > 1) { + if (!areRecordsInSameFolder(records)) { + toaster.danger(gettext('Can only copy files that are in the same folder')); + return; + } + + const path = getParentDirFromRecord(records[0]); + const recordIds = records.map(r => getRecordIdFromRecord(r)); + const dirents = records.map(r => getFileNameFromRecord(r)); + const selectedDirentList = records.map(r => { + const fileName = getFileNameFromRecord(r); + return new Dirent({ name: fileName, is_dir: checkIsDir(r) }); + }); + + const callback = (destRepo, destDirentPath, isByDialog = false) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + storeRef.current.duplicateRecords(recordIds, destRepo.repo_id, dirents, destDirentPath, path, { + success_callback: (operation) => { + if (selectedDirentList.length > 0) { + copyFileCallback && copyFileCallback( + repoID, + destRepo, + selectedDirentList[0], + destDirentPath, + path, + operation.task_id, + isByDialog, + { + isBatchOperation: true, + batchFileNames: dirents, + } + ); + } + + if (repoID === destRepo.repo_id) { + delayReloadMetadata(); + } + }, + fail_callback: (error) => { + error && toaster.danger(error); + } + }); + }; + handleCopy(path, selectedDirentList, true, callback); + } else { + // Single record + const path = getParentDirFromRecord(records[0]); + const currentRecordId = getRecordIdFromRecord(records[0]); + const fileName = getFileNameFromRecord(records[0]); + const dirent = new Dirent({ name: fileName, is_dir: checkIsDir(records[0]) }); + const callback = (...params) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + duplicateRecord && duplicateRecord(currentRecordId, ...params); + }; + handleCopy(path, dirent, false, callback); + } + }; + + const handleDownloadRecords = (recordIds) => { + if (recordIds.length === 0) return; + + const records = recordIds.map(id => storeRef.current?.data?.id_row_map?.[id]).filter(Boolean); + if (records.length === 0) return; + + if (!areRecordsInSameFolder(records)) { + toaster.danger(gettext('Can only download files that are in the same folder')); + return; + } + + const path = getParentDirFromRecord(records[0]); + const direntList = records.map(record => { + const fileName = getFileNameFromRecord(record); + return { name: fileName, is_dir: checkIsDir(record) }; + }); + + handleDownload(path, direntList); + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); }; const getSearchableValue = useCallback((row, column, collaborators, tagsData, collaboratorsCache) => { @@ -639,7 +829,9 @@ export const MetadataViewProvider = ({ const unsubscribeUpdateFaceRecognition = eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_FACE_RECOGNITION, updateFaceRecognition); const unsubscribeUpdateDescription = eventBus.subscribe(EVENT_BUS_TYPE.GENERATE_DESCRIPTION, updateRecordDescription); const unsubscribeOCR = eventBus.subscribe(EVENT_BUS_TYPE.OCR, onOCR); - const unsubscribeToggleMoveDialog = eventBus.subscribe(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, handleMoveRecord); + const unsubscribeToggleMoveDialog = eventBus.subscribe(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, handleMoveRecords); + const unsubscribeToggleCopyDialog = eventBus.subscribe(EVENT_BUS_TYPE.TOGGLE_COPY_DIALOG, handleCopyRecords); + const unsubscribeDownloadRecords = eventBus.subscribe(EVENT_BUS_TYPE.DOWNLOAD_RECORDS, handleDownloadRecords); const unsubscribeSearchRows = eventBus.subscribe(EVENT_BUS_TYPE.SEARCH_ROWS, handleSearchRows); return () => { @@ -669,6 +861,8 @@ export const MetadataViewProvider = ({ unsubscribeUpdateDescription(); unsubscribeOCR(); unsubscribeToggleMoveDialog(); + unsubscribeToggleCopyDialog(); + unsubscribeDownloadRecords(); unsubscribeSearchRows(); delayReloadDataTimer.current && clearTimeout(delayReloadDataTimer.current); }; diff --git a/frontend/src/metadata/store/data-processor.js b/frontend/src/metadata/store/data-processor.js index 14a10039b8..a7c42213a7 100644 --- a/frontend/src/metadata/store/data-processor.js +++ b/frontend/src/metadata/store/data-processor.js @@ -255,7 +255,9 @@ class DataProcessor { this.updateSummaries(); break; } - case OPERATION_TYPE.MOVE_RECORD: { + case OPERATION_TYPE.MOVE_RECORD: + case OPERATION_TYPE.DUPLICATE_RECORD: + case OPERATION_TYPE.DUPLICATE_RECORDS: { this.run(table, { collaborators }); break; } diff --git a/frontend/src/metadata/store/index.js b/frontend/src/metadata/store/index.js index 3522f4ad4f..c3d8467cef 100644 --- a/frontend/src/metadata/store/index.js +++ b/frontend/src/metadata/store/index.js @@ -436,6 +436,23 @@ class Store { this.applyOperation(operation); } + moveRecords(row_ids, target_repo_id, dirents, target_parent_path, source_parent_path, update_data, { success_callback, fail_callback }) { + const type = OPERATION_TYPE.MOVE_RECORDS; + const operation = this.createOperation({ + type, + repo_id: this.repoId, + row_ids, + target_repo_id, + dirents, + target_parent_path, + source_parent_path, + update_data, + success_callback, + fail_callback, + }); + this.applyOperation(operation); + } + duplicateRecord(row_id, target_repo_id, dirent, target_parent_path, source_parent_path, { success_callback, fail_callback }) { const type = OPERATION_TYPE.DUPLICATE_RECORD; const operation = this.createOperation({ @@ -452,6 +469,22 @@ class Store { this.applyOperation(operation); } + duplicateRecords(row_ids, target_repo_id, dirents, target_parent_path, source_parent_path, { success_callback, fail_callback }) { + const type = OPERATION_TYPE.DUPLICATE_RECORDS; + const operation = this.createOperation({ + type, + repo_id: this.repoId, + row_ids, + target_repo_id, + dirents, + target_parent_path, + source_parent_path, + success_callback, + fail_callback, + }); + this.applyOperation(operation); + } + modifyFilters(filterConjunction, filters, basicFilters = []) { const type = OPERATION_TYPE.MODIFY_FILTERS; const operation = this.createOperation({ diff --git a/frontend/src/metadata/store/operations/apply.js b/frontend/src/metadata/store/operations/apply.js index d6b639e992..50f8ab4d38 100644 --- a/frontend/src/metadata/store/operations/apply.js +++ b/frontend/src/metadata/store/operations/apply.js @@ -128,7 +128,8 @@ export default function apply(data, operation) { data.rows = updatedRows; return data; } - case OPERATION_TYPE.MOVE_RECORD: { + case OPERATION_TYPE.MOVE_RECORD: + case OPERATION_TYPE.MOVE_RECORDS: { const { update_data } = operation; const { modify_row_ids: updateRowIds, @@ -145,6 +146,11 @@ export default function apply(data, operation) { } return data; } + case OPERATION_TYPE.DUPLICATE_RECORD: + case OPERATION_TYPE.DUPLICATE_RECORDS: { + // Duplicate operations don't modify local data immediately + return data; + } case OPERATION_TYPE.MODIFY_FILTERS: { const { filter_conjunction, filters, basic_filters } = operation; data.view.filter_conjunction = filter_conjunction; diff --git a/frontend/src/metadata/store/operations/constants.js b/frontend/src/metadata/store/operations/constants.js index a50f794425..dca155331a 100644 --- a/frontend/src/metadata/store/operations/constants.js +++ b/frontend/src/metadata/store/operations/constants.js @@ -26,7 +26,9 @@ export const OPERATION_TYPE = { MODIFY_RECORD_VIA_BUTTON: 'modify_record_via_button', MODIFY_LOCAL_RECORD: 'modify_local_record', MOVE_RECORD: 'move_record', + MOVE_RECORDS: 'move_records', DUPLICATE_RECORD: 'duplicate_record', + DUPLICATE_RECORDS: 'duplicate_records', // face table RENAME_PEOPLE_NAME: 'rename_people_name', @@ -54,6 +56,8 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'], [OPERATION_TYPE.MOVE_RECORD]: ['repo_id', 'row_id', 'target_repo_id', 'dirent', 'target_parent_path', 'source_parent_path', 'update_data'], [OPERATION_TYPE.DUPLICATE_RECORD]: ['repo_id', 'row_id', 'target_repo_id', 'dirent', 'target_parent_path', 'source_parent_path'], + [OPERATION_TYPE.MOVE_RECORDS]: ['repo_id', 'row_ids', 'target_repo_id', 'dirents', 'target_parent_path', 'source_parent_path', 'update_data'], + [OPERATION_TYPE.DUPLICATE_RECORDS]: ['repo_id', 'row_ids', 'target_repo_id', 'dirents', 'target_parent_path', 'source_parent_path'], [OPERATION_TYPE.MODIFY_LOCAL_RECORD]: ['repo_id', 'row_id', 'parent_dir', 'file_name', 'updates'], [OPERATION_TYPE.MODIFY_FILTERS]: ['repo_id', 'view_id', 'filter_conjunction', 'filters', 'basic_filters'], @@ -109,6 +113,8 @@ export const NEED_APPLY_AFTER_SERVER_OPERATION = [ OPERATION_TYPE.MODIFY_SORTS, OPERATION_TYPE.MOVE_RECORD, OPERATION_TYPE.DUPLICATE_RECORD, + OPERATION_TYPE.MOVE_RECORDS, + OPERATION_TYPE.DUPLICATE_RECORDS, ]; export const VIEW_OPERATION = [ diff --git a/frontend/src/metadata/store/server-operator.js b/frontend/src/metadata/store/server-operator.js index 548ca48e68..301ef66776 100644 --- a/frontend/src/metadata/store/server-operator.js +++ b/frontend/src/metadata/store/server-operator.js @@ -98,6 +98,20 @@ class ServerOperator { }); break; } + case OPERATION_TYPE.MOVE_RECORDS: { + const { repo_id, target_repo_id, dirents, target_parent_path, source_parent_path } = operation; + seafileAPI.moveDir(repo_id, target_repo_id, target_parent_path, source_parent_path, dirents).then(res => { + operation.task_id = res.data.task_id || null; + callback({ operation }); + }).catch(error => { + const count = dirents.length; + const errorMsg = count > 1 + ? gettext('Failed to move {n} items').replace('{n}', count) + : gettext('Failed to move file'); + callback({ error: errorMsg }); + }); + break; + } case OPERATION_TYPE.DUPLICATE_RECORD: { const { row_id, repo_id, target_repo_id, dirent, target_parent_path, source_parent_path } = operation; seafileAPI.copyDir(repo_id, target_repo_id, target_parent_path, source_parent_path, dirent.name).then(res => { @@ -110,6 +124,20 @@ class ServerOperator { }); break; } + case OPERATION_TYPE.DUPLICATE_RECORDS: { + const { repo_id, target_repo_id, dirents, target_parent_path, source_parent_path } = operation; + seafileAPI.copyDir(repo_id, target_repo_id, target_parent_path, source_parent_path, dirents).then(res => { + operation.task_id = res.data.task_id || null; + callback({ operation }); + }).catch(error => { + const count = dirents.length; + const errorMsg = count > 1 + ? gettext('Failed to copy {n} items').replace('{n}', count) + : gettext('Failed to copy file'); + callback({ error: errorMsg }); + }); + break; + } case OPERATION_TYPE.INSERT_COLUMN: { const { repo_id, name, column_type, column_key, data } = operation; window.sfMetadataContext.insertColumn(repo_id, name, column_type, { key: column_key, data }).then(res => { diff --git a/frontend/src/metadata/views/table/context-menu.js b/frontend/src/metadata/views/table/context-menu.js index 9b4fac433c..1855d3097c 100644 --- a/frontend/src/metadata/views/table/context-menu.js +++ b/frontend/src/metadata/views/table/context-menu.js @@ -29,6 +29,8 @@ const OPERATION = { FILE_DETAILS: 'file-details', DETECT_FACES: 'detect-faces', MOVE: 'move', + COPY: 'copy', + DOWNLOAD: 'download', }; const ContextMenu = ({ @@ -119,7 +121,6 @@ const ContextMenu = ({ return list; } - // handle selected records const selectedRecordsIds = recordMetrics ? Object.keys(recordMetrics.idSelectedRecordMap) : []; if (selectedRecordsIds.length > 1) { let records = []; @@ -130,6 +131,20 @@ const ContextMenu = ({ } }); + const areRecordsInSameFolder = (() => { + if (records.length <= 1) return true; + const firstPath = getParentDirFromRecord(records[0]); + return records.every(record => getParentDirFromRecord(record) === firstPath); + })(); + + if (areRecordsInSameFolder) { + if (!isReadonly) { + list.push({ value: OPERATION.MOVE, label: gettext('Move'), records }); + list.push({ value: OPERATION.COPY, label: gettext('Copy'), records }); + } + list.push({ value: OPERATION.DOWNLOAD, label: gettext('Download'), records }); + } + const ableDeleteRecords = getAbleDeleteRecords(records); if (ableDeleteRecords.length > 0) { list.push({ value: OPERATION.DELETE_RECORDS, label: gettext('Delete'), records: ableDeleteRecords }); @@ -177,6 +192,10 @@ const ContextMenu = ({ if (canModifyRow) { modifyOptions.push({ value: OPERATION.MOVE, label: isFolder ? gettext('Move folder') : gettext('Move file'), record }); } + // Add copy and download options for single record + modifyOptions.push({ value: OPERATION.COPY, label: isFolder ? gettext('Copy folder') : gettext('Copy file'), record }); + modifyOptions.push({ value: OPERATION.DOWNLOAD, label: isFolder ? gettext('Download folder') : gettext('Download file'), record }); + if (canDeleteRow) { modifyOptions.push({ value: OPERATION.DELETE_RECORD, label: isFolder ? gettext('Delete folder') : gettext('Delete file'), record }); } @@ -301,9 +320,33 @@ const ContextMenu = ({ break; } case OPERATION.MOVE: { - const { record } = option; - if (!record) break; - window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, record); + const { record, records } = option; + if (records) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, records); + } else if (record) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_MOVE_DIALOG, [record]); + } + break; + } + case OPERATION.COPY: { + const { record, records } = option; + if (records) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_COPY_DIALOG, records); + } else if (record) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_COPY_DIALOG, [record]); + } + break; + } + case OPERATION.DOWNLOAD: { + const { record, records } = option; + if (records) { + // Multiple records + const recordIds = records.map(r => r._id).filter(Boolean); + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.DOWNLOAD_RECORDS, recordIds); + } else if (record) { + // Single record + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.DOWNLOAD_RECORDS, [record._id]); + } break; } default: { 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 07d9263de1..823cfd0630 100644 --- a/frontend/src/pages/lib-content-view/lib-content-view.js +++ b/frontend/src/pages/lib-content-view/lib-content-view.js @@ -1305,15 +1305,23 @@ class LibContentView extends React.Component { }; // list operations - moveItemsAjaxCallback = (repoID, targetRepo, dirent, moveToDirentPath, nodeParentPath, taskId, byDialog = false) => { + moveItemsAjaxCallback = (repoID, targetRepo, dirent, moveToDirentPath, nodeParentPath, taskId, byDialog = false, options = {}) => { this.updateCurrentNotExistDirent(dirent); + const { + isBatchOperation = false, + batchFileNames = [], + customMessage = null + } = options; + const dirName = dirent.name; const direntPath = Utils.joinPath(nodeParentPath, dirName); + if (repoID !== targetRepo.repo_id) { + const operatedFilesLength = isBatchOperation ? batchFileNames.length : 1; this.setState({ asyncCopyMoveTaskId: taskId, - asyncOperatedFilesLength: 1, + asyncOperatedFilesLength: operatedFilesLength, asyncOperationProgress: 0, asyncOperationType: 'move', isCopyMoveProgressDialogShow: true, @@ -1324,28 +1332,38 @@ class LibContentView extends React.Component { }); } - // 1. move to current repo - // 2. tow columns mode need update left tree - const updateAfterMove = () => { - if (repoID === targetRepo.repo_id && this.state.isTreePanelShown) { - this.updateMoveCopyTreeNode(moveToDirentPath); - } - this.moveDirent(direntPath, moveToDirentPath); - }; - if (this.state.isTreePanelShown) { - this.deleteTreeNode(direntPath, updateAfterMove); - } else { - updateAfterMove(); + if (isBatchOperation) { + const direntPaths = batchFileNames.map(fileName => Utils.joinPath(nodeParentPath, fileName)); + this.deleteTreeNodes(direntPaths, () => { + if (repoID === targetRepo.repo_id) { + this.updateMoveCopyTreeNode(moveToDirentPath); + } + }); + } else { + this.deleteTreeNode(direntPath, () => { + if (repoID === targetRepo.repo_id) { + this.updateMoveCopyTreeNode(moveToDirentPath); + } + }); + } } - // show tip message if move to current repo - if (repoID === targetRepo.repo_id) { - let message = gettext('Successfully moved {name}.'); + let message; + + if (customMessage) { + message = customMessage; + } else if (isBatchOperation && batchFileNames.length > 1) { + message = gettext('Successfully moved {name} and {n} other items'); + message = message.replace('{name}', batchFileNames[0]) + .replace('{n}', batchFileNames.length - 1); + } else { + message = gettext('Successfully moved {name}.'); message = message.replace('{name}', dirName); - toaster.success(message); } + toaster.success(message); + if (byDialog) { this.updateRecentlyUsedList(targetRepo, moveToDirentPath); } @@ -1382,12 +1400,20 @@ class LibContentView extends React.Component { }); }; - copyItemsAjaxCallback = (repoID, targetRepo, dirent, copyToDirentPath, nodeParentPath, taskId, byDialog = false) => { + copyItemsAjaxCallback = (repoID, targetRepo, dirent, copyToDirentPath, nodeParentPath, taskId, byDialog = false, options = {}) => { this.onSelectedDirentListUpdate([]); + + const { + isBatchOperation = false, + batchFileNames = [], + customMessage = null + } = options; + if (repoID !== targetRepo.repo_id) { + const operatedFilesLength = isBatchOperation ? batchFileNames.length : 1; this.setState({ asyncCopyMoveTaskId: taskId, - asyncOperatedFilesLength: 1, + asyncOperatedFilesLength: operatedFilesLength, asyncOperationProgress: 0, asyncOperationType: 'copy', isCopyMoveProgressDialogShow: true @@ -1409,8 +1435,19 @@ class LibContentView extends React.Component { } const dirName = dirent.name; - let message = gettext('Successfully copied %(name)s.'); - message = message.replace('%(name)s', dirName); + let message; + + if (customMessage) { + message = customMessage; + } else if (isBatchOperation && batchFileNames.length > 1) { + message = gettext('Successfully copied {name} and {n} other items'); + message = message.replace('{name}', batchFileNames[0]) + .replace('{n}', batchFileNames.length - 1); + } else { + message = gettext('Successfully copied %(name)s.'); + message = message.replace('%(name)s', dirName); + } + toaster.success(message); if (byDialog) { @@ -2068,9 +2105,11 @@ class LibContentView extends React.Component { }); }; - deleteTreeNodes = (paths) => { + deleteTreeNodes = (paths, callback) => { let tree = treeHelper.deleteNodeListByPaths(this.state.treeData, paths); - this.setState({ treeData: tree }); + this.setState({ treeData: tree }, () => { + callback && callback(); + }); }; moveTreeNode = (nodePath, moveToPath, moveToRepo, nodeName) => {