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) => {