From f0d3587fd931f00b3039cb86a19f160298e5315d Mon Sep 17 00:00:00 2001 From: Aries Date: Wed, 20 Aug 2025 17:58:50 +0800 Subject: [PATCH] show overlapped images in details, support modify details in batch (#8143) * show overlapped images in details, support modify details in batch * optimize * fix details state * code-optimize * Update apis.py --------- Co-authored-by: zhouwenxuan Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com> --- .../detail/header/title/index.js | 8 +- .../src/components/dirent-detail/index.js | 32 ++- .../dirents-tags-editor.js | 197 ++++++++++++++ .../multi-selection-details/index.css | 62 +++++ .../multi-selection-details/index.js | 247 ++++++++++++++++++ frontend/src/metadata/api.js | 5 + .../lib-content-view/lib-content-view.js | 6 +- seahub/repo_metadata/apis.py | 114 +++++++- seahub/repo_metadata/urls.py | 4 +- 9 files changed, 659 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/dirent-detail/multi-selection-details/dirents-tags-editor.js create mode 100644 frontend/src/components/dirent-detail/multi-selection-details/index.css create mode 100644 frontend/src/components/dirent-detail/multi-selection-details/index.js diff --git a/frontend/src/components/dirent-detail/detail/header/title/index.js b/frontend/src/components/dirent-detail/detail/header/title/index.js index a00f754583..6f9787c95f 100644 --- a/frontend/src/components/dirent-detail/detail/header/title/index.js +++ b/frontend/src/components/dirent-detail/detail/header/title/index.js @@ -7,9 +7,11 @@ const Title = ({ icon, iconSize, title }) => { return (
-
- -
+ {icon && ( +
+ +
+ )} {title}
); diff --git a/frontend/src/components/dirent-detail/index.js b/frontend/src/components/dirent-detail/index.js index f4fbc96e7e..d778aa410b 100644 --- a/frontend/src/components/dirent-detail/index.js +++ b/frontend/src/components/dirent-detail/index.js @@ -2,6 +2,7 @@ import React, { useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import LibDetail from './lib-details'; import DirentDetail from './dirent-details'; +import MultiSelectionDetails from './multi-selection-details'; import ViewDetails from '../../metadata/components/view-details'; import ObjectUtils from '../../utils/object'; import { MetadataContext } from '../../metadata'; @@ -11,7 +12,18 @@ import { FACE_RECOGNITION_VIEW_ID } from '../../metadata/constants'; import { useTags } from '../../tag/hooks'; import { useMetadataStatus } from '../../hooks'; -const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo, repoTags, fileTags, onClose, onFileTagChanged }) => { +const Detail = React.memo(({ + repoID, + path, + currentMode, + dirent, + selectedDirents, + currentRepoInfo, + repoTags, + fileTags, + onClose, + onFileTagChanged +}) => { const { enableMetadata, enableFaceRecognition, detailsSettings, modifyDetailsSettings } = useMetadataStatus(); const { tagsData, addTag, modifyLocalFileTags } = useTags(); @@ -36,6 +48,20 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo, if (isTag) return null; + // Handle multi-selection case + if (selectedDirents && selectedDirents.length > 1) { + return ( + + ); + } + if (isView && !dirent) { const pathParts = path.split('/'); const [, , viewId, children] = pathParts; @@ -54,7 +80,7 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo, { + const ref = useRef(null); + const [showEditor, setShowEditor] = useState(false); + + const { tagsData, context } = useTags(); + const canEditData = useMemo(() => window.sfMetadataContext && window.sfMetadataContext.canModifyRow() || false, []); + + const displayTags = useMemo(() => { + if (!records || records.length === 0) return []; + + return getCellValueByColumn(records[0], field) || []; + }, [records, field]); + + const displayTagIds = useMemo(() => { + if (!Array.isArray(displayTags) || displayTags.length === 0) return []; + + return displayTags.filter(tag => getRowById(tagsData, tag.row_id)).map(tag => tag.row_id); + }, [displayTags, tagsData]); + + const onClick = useCallback((event) => { + if (!event.target) return; + const className = getEventClassName(event); + if (className.indexOf('sf-metadata-search-tags') > -1) return; + const dom = document.querySelector('.sf-metadata-tags-editor'); + if (!dom) return; + if (dom.contains(event.target)) return; + if (ref.current && !ref.current.contains(event.target) && showEditor) { + setShowEditor(false); + } + }, [showEditor]); + + const onHotKey = useCallback((event) => { + if (event.keyCode === KeyCodes.Esc) { + if (showEditor) { + setShowEditor(false); + } + } + }, [showEditor]); + + useEffect(() => { + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onHotKey, true); + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onHotKey, true); + }; + }, [onClick, onHotKey]); + + const openEditor = useCallback(() => { + setShowEditor(true); + }, []); + + const updateBatchTags = useCallback((newTagIds, oldTagIds = []) => { + if (!records || records.length === 0) return; + + const newTags = newTagIds.map(id => { + const tag = getRowById(tagsData, id); + return { + display_value: getTagName(tag), + row_id: getTagId(tag), + }; + }); + + onChange && onChange(PRIVATE_COLUMN_KEY.TAGS, newTags); + + const batchTagUpdates = records.map(record => ({ + record_id: record._id, + tags: newTagIds + })); + + tagsAPI.updateFileTags(repoID, batchTagUpdates) + .then(() => { + batchTagUpdates.forEach(({ record_id, tags }) => { + modifyLocalFileTags && modifyLocalFileTags(record_id, tags); + + if (window?.sfMetadataContext?.eventBus) { + const newValue = tags ? tags.map(id => ({ row_id: id, display_value: id })) : []; + const update = { [PRIVATE_COLUMN_KEY.TAGS]: newValue }; + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { recordId: record_id }, update); + } + }); + }) + .catch((error) => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + }); + }, [records, repoID, tagsData, onChange, modifyLocalFileTags]); + + const onDeleteTag = useCallback((tagId, event) => { + event && event.stopPropagation(); + event && event.nativeEvent && event.nativeEvent.stopImmediatePropagation(); + const newValue = displayTagIds.slice(0); + let optionIdx = displayTagIds.indexOf(tagId); + if (optionIdx > -1) { + newValue.splice(optionIdx, 1); + } + updateBatchTags(newValue, displayTagIds); + setShowEditor(false); + }, [displayTagIds, updateBatchTags]); + + const onSelectTag = useCallback((tagId) => { + const newValue = displayTagIds.slice(0); + if (!newValue.includes(tagId)) { + newValue.push(tagId); + } + updateBatchTags(newValue, displayTagIds); + }, [displayTagIds, updateBatchTags]); + + const onDeselectTag = useCallback((tagId) => { + const newValue = displayTagIds.slice(0); + let optionIdx = displayTagIds.indexOf(tagId); + if (optionIdx > -1) { + newValue.splice(optionIdx, 1); + } + updateBatchTags(newValue, displayTagIds); + }, [displayTagIds, updateBatchTags]); + + const renderEditor = useCallback(() => { + if (!showEditor) return null; + const { width, top, bottom } = ref.current.getBoundingClientRect(); + const editorHeight = 400; + const viewportHeight = window.innerHeight; + let placement = 'bottom-end'; + if (viewportHeight - bottom < editorHeight && top > editorHeight) { + placement = 'top-end'; + } else if (viewportHeight - bottom < editorHeight && top < editorHeight) { + placement = 'left-start'; + } + + return ( + + + + ); + }, [showEditor, field, displayTags, context, canEditData, onSelectTag, onDeselectTag]); + + return ( +
+ {displayTagIds.length > 0 && ()} + {renderEditor()} +
+ ); +}; + +DirentsTagsEditor.propTypes = { + records: PropTypes.array.isRequired, + field: PropTypes.object.isRequired, + onBatchMetadataRefresh: PropTypes.func.isRequired, + repoID: PropTypes.string.isRequired, + modifyLocalFileTags: PropTypes.func, +}; + +export default DirentsTagsEditor; diff --git a/frontend/src/components/dirent-detail/multi-selection-details/index.css b/frontend/src/components/dirent-detail/multi-selection-details/index.css new file mode 100644 index 0000000000..5f55c62d84 --- /dev/null +++ b/frontend/src/components/dirent-detail/multi-selection-details/index.css @@ -0,0 +1,62 @@ +.multi-selection-thumbnails { + position: relative; + width: 100%; + height: 144px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 16px; + margin-bottom: 28px; + border-radius: 12px; +} + +.background-thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 1px solid var(--bs-border-secondary-color); + border-radius: 8px; + overflow: hidden; + z-index: 1; +} + +.background-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.background-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 2; +} + +.overlay-thumbnail { + background-color: var(--bs-body-bg); +} + +.multi-selection-thumbnails.with-background { + background: transparent; +} + +.overlay-thumbnail { + position: absolute; + width: auto; + height: 144px; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); + border: 1px solid var(--bs-border-secondary-color); +} + +.overlay-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/frontend/src/components/dirent-detail/multi-selection-details/index.js b/frontend/src/components/dirent-detail/multi-selection-details/index.js new file mode 100644 index 0000000000..d73037dc2a --- /dev/null +++ b/frontend/src/components/dirent-detail/multi-selection-details/index.js @@ -0,0 +1,247 @@ +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Detail, Header, Body } from '../detail'; +import { Utils } from '../../../utils/utils'; +import { gettext, siteRoot, thumbnailSizeForGrid } from '../../../utils/constants'; +import DetailItem from '../detail-item'; +import { CellType, PRIVATE_COLUMN_KEY } from '../../../metadata/constants'; +import { useMetadataStatus } from '../../../hooks'; +import RateEditor from '../../../metadata/components/detail-editor/rate-editor'; +import metadataAPI from '../../../metadata/api'; +import Loading from '../../loading'; +import { getColumnDisplayName } from '../../../metadata/utils/column'; +import DirentsTagsEditor from './dirents-tags-editor'; +import { getCellValueByColumn, getFileObjIdFromRecord, getRecordIdFromRecord } from '../../../metadata/utils/cell'; + +import './index.css'; + +const MultiSelectionDetails = ({ + repoID, + path, + selectedDirents, + currentRepoInfo, + modifyLocalFileTags, + onClose +}) => { + const { enableMetadata, enableTags } = useMetadataStatus(); + const [records, setRecords] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fileCount = useMemo(() => selectedDirents.filter(dirent => dirent.type === 'file').length, [selectedDirents]); + const folderCount = useMemo(() => selectedDirents.filter(dirent => dirent.type === 'dir').length, [selectedDirents]); + + const onlyFiles = folderCount === 0; + const hasFiles = fileCount > 0; + + const { imageDirents, fileDirents } = useMemo(() => ({ + imageDirents: selectedDirents.filter(dirent => + dirent.type === 'file' && Utils.imageCheck(dirent.name) + ), + fileDirents: selectedDirents.filter(dirent => dirent.type === 'file') + }), [selectedDirents]); + + const { rateField, tagField } = useMemo(() => { + const rateColumn = { key: PRIVATE_COLUMN_KEY.FILE_RATE, type: CellType.RATE, name: getColumnDisplayName(PRIVATE_COLUMN_KEY.FILE_RATE) }; + const tagColumn = { key: PRIVATE_COLUMN_KEY.TAGS, type: CellType.TAGS, name: getColumnDisplayName(PRIVATE_COLUMN_KEY.TAGS) }; + + return { + rateField: { + ...rateColumn, + name: getColumnDisplayName(rateColumn.key) + }, + tagField: { + ...tagColumn, + name: getColumnDisplayName(tagColumn.key), + type: CellType.TAGS + } + }; + }, []); + + const getRecords = useCallback(() => { + if (!enableMetadata || !onlyFiles || !hasFiles) return; + + setIsLoading(true); + const files = fileDirents.map(dirent => { + return { + parent_dir: path, + file_name: dirent.name + }; + }); + metadataAPI.getRecords(repoID, files) + .then(res => { + const { results } = res.data; + const orderedRecords = fileDirents.map(dirent => { + return results.find(record => { + const recordPath = record[PRIVATE_COLUMN_KEY.PARENT_DIR] || ''; + const recordName = record[PRIVATE_COLUMN_KEY.FILE_NAME] || ''; + return recordPath === path && recordName === dirent.name; + }); + }).filter(Boolean); + + setRecords(orderedRecords); + setIsLoading(false); + }) + .catch (err => { + setRecords([]); + setIsLoading(false); + }); + }, [enableMetadata, onlyFiles, hasFiles, fileDirents, repoID, path]); + + const onChange = useCallback((key, value) => { + const newRecords = records.map(record => ({ + ...record, + [key]: value + })); + setRecords(newRecords); + + if (key === PRIVATE_COLUMN_KEY.TAGS) return; + + const recordsData = records.map(record => ({ + record_id: getRecordIdFromRecord(record), + record: { [key]: value }, + obj_id: getFileObjIdFromRecord(record), + })); + metadataAPI.modifyRecords(repoID, recordsData); + }, [repoID, records]); + + useEffect(() => { + getRecords(); + }, [getRecords]); + + const rate = useMemo(() => { + if (!records || records.length === 0) return null; + + return getCellValueByColumn(records[0], rateField); + }, [records, rateField]); + + const getThumbnailSrc = (dirent) => { + if (dirent.type === 'dir' || (dirent.type === 'file' && !Utils.imageCheck(dirent.name))) { + return Utils.getDirentIcon(dirent, true); + } + + if (dirent.type !== 'file' || !Utils.imageCheck(dirent.name)) { + return null; + } + + return currentRepoInfo.encrypted + ? `${siteRoot}repo/${repoID}/raw${Utils.encodePath(`${path === '/' ? '' : path}/${dirent.name}`)}` + : `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${Utils.encodePath(`${path === '/' ? '' : path}/${dirent.name}`)}`; + }; + + const renderOverlappingThumbnails = () => { + const maxOverlayThumbnails = 6; + const angles = [-15, 8, -8, 12, -10, 6]; + + const renderOverlayItem = (dirent, index, isBackgroundMode = false) => { + const src = getThumbnailSrc(dirent); + const className = `overlay-thumbnail large${!isBackgroundMode ? ' no-background-item' : ''}`; + + return ( +
+ {dirent.name} { + e.target.style.display = 'none'; + const iconContainer = e.target.parentNode.querySelector('.fallback-icon'); + if (iconContainer) iconContainer.style.display = 'flex'; + }} + /> +
+ ); + }; + + if (imageDirents.length === 0) { + const overlayItems = selectedDirents.slice(0, maxOverlayThumbnails); + + return ( +
+ {overlayItems.map((dirent, index) => renderOverlayItem(dirent, index, false))} +
+ ); + } + + // With background image mode + const backgroundImage = imageDirents[0]; + const overlayItems = selectedDirents + .filter(dirent => dirent.name !== backgroundImage.name) + .slice(-maxOverlayThumbnails); + const backgroundSrc = getThumbnailSrc(backgroundImage); + + return ( +
+
+ {backgroundImage.name} +
+
+ {overlayItems.map((dirent, index) => renderOverlayItem(dirent, index, true))} +
+ ); + }; + + return ( + +
+ + {renderOverlappingThumbnails()} + +
+

{gettext(`${selectedDirents.length} items have been selected`)}

+ + {isLoading && ( +
+ +

{gettext('Loading metadata...')}

+
+ )} + + {!isLoading && onlyFiles && hasFiles && window.app.pageOptions.enableFileTags && enableTags && ( + + + + )} + {!isLoading && onlyFiles && hasFiles && enableMetadata && ( + + onChange(PRIVATE_COLUMN_KEY.FILE_RATE, value)} + /> + + )} +
+ + + ); +}; + +MultiSelectionDetails.propTypes = { + repoID: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + selectedDirents: PropTypes.array.isRequired, + currentRepoInfo: PropTypes.object.isRequired, + modifyLocalFileTags: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; + +export default MultiSelectionDetails; diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js index 0e7a5771dd..ed3a54ec82 100644 --- a/frontend/src/metadata/api.js +++ b/frontend/src/metadata/api.js @@ -107,6 +107,11 @@ class MetadataManagerAPI { return this.req.get(url, { params: params }); } + getRecords(repoID, files) { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/batch-records/'; + return this.req.post(url, { files: files }); + } + modifyRecord(repoID, { recordId, parentDir, fileName }, updateData) { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/'; let data = { 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 c30aead4a9..fb019c521a 100644 --- a/frontend/src/pages/lib-content-view/lib-content-view.js +++ b/frontend/src/pages/lib-content-view/lib-content-view.js @@ -1838,7 +1838,7 @@ class LibContentView extends React.Component { return item.name !== name; }); this.recalculateSelectedDirents([name], direntList); - this.setState({ direntList: direntList }); + this.setState({ direntList: direntList, currentDirent: null }); }; // only one scene: The moved items are inside current path @@ -1852,6 +1852,7 @@ class LibContentView extends React.Component { selectedDirentList: [], isDirentSelected: false, isAllDirentSelected: false, + currentDirent: null, }); }; @@ -2378,7 +2379,7 @@ class LibContentView extends React.Component { } let detailPath = this.state.path; - if (!currentDirent && currentMode !== METADATA_MODE && currentMode !== TAGS_MODE) { + if (!currentDirent && currentMode !== METADATA_MODE && currentMode !== TAGS_MODE && this.state.selectedDirentList.length === 0) { detailPath = Utils.getDirName(this.state.path); } @@ -2577,6 +2578,7 @@ class LibContentView extends React.Component { repoID={this.props.repoID} currentRepoInfo={{ ...this.state.currentRepoInfo }} dirent={detailDirent} + selectedDirents={this.state.selectedDirentList} repoTags={this.state.repoTags} fileTags={this.state.isViewFile ? this.state.fileTags : []} onFileTagChanged={this.onFileTagChanged} diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py index 1d20569610..0e2684b6d5 100644 --- a/seahub/repo_metadata/apis.py +++ b/seahub/repo_metadata/apis.py @@ -33,6 +33,7 @@ from seahub.settings import MD_FILE_COUNT_LIMIT logger = logging.getLogger(__name__) + class MetadataManage(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated, ) @@ -749,6 +750,98 @@ class MetadataColumns(APIView): return Response({'success': True}) +class MetadataBatchRecords(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def post(self, request, repo_id): + """ + Get metadata records for multiple files in batch + Request body: + files: list of {parent_dir, file_name} objects + """ + files = request.data.get('files', []) + if not files or not isinstance(files, list): + error_msg = 'files parameter is required and must be a list' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if len(files) > METADATA_RECORD_UPDATE_LIMIT: + error_msg = 'Number of records exceeds the limit of %s.' % METADATA_RECORD_UPDATE_LIMIT + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled: + error_msg = f'The metadata module is disabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if not can_read_metadata(request, repo_id): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = f'Library {repo_id} not found.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + + from seafevents.repo_metadata.constants import METADATA_TABLE + + try: + columns_data = metadata_server_api.list_columns(METADATA_TABLE.id) + all_columns = columns_data.get('columns', []) + metadata_columns = [] + for column in all_columns: + if column.get('key') in ['_rate', '_tags']: + metadata_columns.append({ + 'key': column.get('key'), + 'name': column.get('name'), + 'type': column.get('type'), + 'data': column.get('data', {}) + }) + + except Exception as e: + logger.exception(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + where_conditions = [] + parameters = [] + + for file_info in files: + parent_dir = file_info.get('parent_dir') + file_name = file_info.get('file_name') + + if not parent_dir or not file_name: + continue + + where_conditions.append(f'(`{METADATA_TABLE.columns.parent_dir.name}`=? AND `{METADATA_TABLE.columns.file_name.name}`=?)') + parameters.extend([parent_dir, file_name]) + + if not where_conditions: + return Response({ + 'results': [], + 'metadata': {'columns': metadata_columns} + }) + + where_clause = ' OR '.join(where_conditions) + sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE {where_clause};' + try: + query_result = metadata_server_api.query_rows(sql, parameters) + results = query_result.get('results', []) + + return Response({ + 'results': results, + 'metadata': {'columns': metadata_columns} + }) + + except Exception as e: + logger.exception(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + class MetadataFolders(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated,) @@ -2422,21 +2515,26 @@ class MetadataFileTags(APIView): from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE metadata_server_api = MetadataServerAPI(repo_id, request.user.username) - success_records = [] - failed_records = [] + row_id_map = {} + for file_tags in file_tags_data: record_id = file_tags.get('record_id', '') tags = file_tags.get('tags', []) if not record_id: continue - try: + + row_id_map[record_id] = tags - metadata_server_api.update_link(TAGS_TABLE.file_link_id, METADATA_TABLE.id, { record_id: tags }) - success_records.append(record_id) - except Exception as e: - failed_records.append(record_id) + if not row_id_map: + return api_error(status.HTTP_400_BAD_REQUEST, 'No valid file_tags_data provided') - return Response({'success': success_records, 'fail': failed_records}) + try: + metadata_server_api.update_link(TAGS_TABLE.file_link_id, METADATA_TABLE.id, row_id_map) + except Exception as e: + logger.exception(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + return Response({'success': True }) class MetadataTagFiles(APIView): diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py index 833399da89..1c5a88ddf7 100644 --- a/seahub/repo_metadata/urls.py +++ b/seahub/repo_metadata/urls.py @@ -3,12 +3,14 @@ from .apis import MetadataRecognizeFaces, MetadataRecords, MetadataManage, Metad MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \ - PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView + PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView, \ + MetadataBatchRecords urlpatterns = [ re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'), re_path(r'^records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'), re_path(r'^record/$', MetadataRecord.as_view(), name='api-v2.1-metadata-record-info'), + re_path(r'^batch-records/$', MetadataBatchRecords.as_view(), name='api-v2.1-metadata-batch-records'), re_path(r'^columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'), # view