1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-26 15:26:19 +00:00

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 <aries@Mac.local>
Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
This commit is contained in:
Aries
2025-08-20 17:58:50 +08:00
committed by GitHub
parent c4d4d4369e
commit f0d3587fd9
9 changed files with 659 additions and 16 deletions

View File

@@ -7,9 +7,11 @@ const Title = ({ icon, iconSize, title }) => {
return ( return (
<div className="detail-title dirent-title"> <div className="detail-title dirent-title">
<div className="detail-header-icon-container"> {icon && (
<img src={icon} width={iconSize} height={iconSize} alt="" /> <div className="detail-header-icon-container">
</div> <img src={icon} width={iconSize} height={iconSize} alt="" />
</div>
)}
<span className="name ellipsis" title={title}>{title}</span> <span className="name ellipsis" title={title}>{title}</span>
</div> </div>
); );

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import LibDetail from './lib-details'; import LibDetail from './lib-details';
import DirentDetail from './dirent-details'; import DirentDetail from './dirent-details';
import MultiSelectionDetails from './multi-selection-details';
import ViewDetails from '../../metadata/components/view-details'; import ViewDetails from '../../metadata/components/view-details';
import ObjectUtils from '../../utils/object'; import ObjectUtils from '../../utils/object';
import { MetadataContext } from '../../metadata'; import { MetadataContext } from '../../metadata';
@@ -11,7 +12,18 @@ import { FACE_RECOGNITION_VIEW_ID } from '../../metadata/constants';
import { useTags } from '../../tag/hooks'; import { useTags } from '../../tag/hooks';
import { useMetadataStatus } from '../../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 { enableMetadata, enableFaceRecognition, detailsSettings, modifyDetailsSettings } = useMetadataStatus();
const { tagsData, addTag, modifyLocalFileTags } = useTags(); const { tagsData, addTag, modifyLocalFileTags } = useTags();
@@ -36,6 +48,20 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
if (isTag) return null; if (isTag) return null;
// Handle multi-selection case
if (selectedDirents && selectedDirents.length > 1) {
return (
<MultiSelectionDetails
repoID={repoID}
path={path}
selectedDirents={selectedDirents}
currentRepoInfo={currentRepoInfo}
modifyLocalFileTags={modifyLocalFileTags}
onClose={onClose}
/>
);
}
if (isView && !dirent) { if (isView && !dirent) {
const pathParts = path.split('/'); const pathParts = path.split('/');
const [, , viewId, children] = pathParts; const [, , viewId, children] = pathParts;
@@ -54,7 +80,7 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
<DirentDetail <DirentDetail
repoID={repoID} repoID={repoID}
path={isView ? dirent.path : path} path={isView ? dirent.path : path}
dirent={dirent} dirent={selectedDirents[0] || dirent}
currentRepoInfo={currentRepoInfo} currentRepoInfo={currentRepoInfo}
repoTags={repoTags} repoTags={repoTags}
fileTags={fileTags} fileTags={fileTags}
@@ -74,6 +100,7 @@ const Detail = React.memo(({ repoID, path, currentMode, dirent, currentRepoInfo,
props.repoID !== nextProps.repoID || props.repoID !== nextProps.repoID ||
props.path !== nextProps.path || props.path !== nextProps.path ||
!ObjectUtils.isSameObject(props.dirent, nextProps.dirent) || !ObjectUtils.isSameObject(props.dirent, nextProps.dirent) ||
!ObjectUtils.isSameObject(props.selectedDirents, nextProps.selectedDirents) ||
!ObjectUtils.isSameObject(props.currentRepoInfo, nextProps.currentRepoInfo) || !ObjectUtils.isSameObject(props.currentRepoInfo, nextProps.currentRepoInfo) ||
JSON.stringify(props.repoTags || []) !== JSON.stringify(nextProps.repoTags || []) || JSON.stringify(props.repoTags || []) !== JSON.stringify(nextProps.repoTags || []) ||
JSON.stringify(props.fileTags || []) !== JSON.stringify(nextProps.fileTags || []); JSON.stringify(props.fileTags || []) !== JSON.stringify(nextProps.fileTags || []);
@@ -85,6 +112,7 @@ Detail.propTypes = {
path: PropTypes.string, path: PropTypes.string,
currentMode: PropTypes.string, currentMode: PropTypes.string,
dirent: PropTypes.object, dirent: PropTypes.object,
selectedDirents: PropTypes.array,
currentRepoInfo: PropTypes.object, currentRepoInfo: PropTypes.object,
repoTags: PropTypes.array, repoTags: PropTypes.array,
fileTags: PropTypes.array, fileTags: PropTypes.array,

View File

@@ -0,0 +1,197 @@
import React, { useCallback, useMemo, useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Popover } from 'reactstrap';
import Editor from '../../../metadata/components/cell-editors/tags-editor';
import DeleteTag from '../../../metadata/components/cell-editors/tags-editor/delete-tags';
import { getRowById } from '../../sf-table/utils/table';
import { gettext } from '../../../utils/constants';
import { KeyCodes } from '../../../constants';
import { getEventClassName } from '../../../utils/dom';
import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../../../metadata/constants';
import { useTags } from '../../../tag/hooks';
import tagsAPI from '../../../tag/api';
import { Utils } from '../../../utils/utils';
import toaster from '../../toast';
import { getCellValueByColumn } from '../../../metadata/utils/cell';
import { getTagId, getTagName } from '../../../tag/utils/cell';
const DirentsTagsEditor = ({
records,
field,
onChange,
repoID,
modifyLocalFileTags
}) => {
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 (
<Popover
target={ref}
isOpen={true}
placement={placement}
hideArrow={true}
fade={false}
className="sf-metadata-property-editor-popover sf-metadata-tags-property-editor-popover"
boundariesElement="viewport"
>
<Editor
saveImmediately={true}
value={displayTags}
column={{ ...field, width: Math.max(width - 2, 400) }}
onSelect={onSelectTag}
onDeselect={onDeselectTag}
canEditData={canEditData}
canAddTag={context.canAddTag()}
/>
</Popover>
);
}, [showEditor, field, displayTags, context, canEditData, onSelectTag, onDeselectTag]);
return (
<div
className="sf-metadata-property-detail-editor sf-metadata-tags-property-detail-editor"
placeholder={gettext('Empty')}
ref={ref}
onClick={openEditor}
>
{displayTagIds.length > 0 && (<DeleteTag value={displayTagIds} tags={tagsData} onDelete={onDeleteTag} />)}
{renderEditor()}
</div>
);
};
DirentsTagsEditor.propTypes = {
records: PropTypes.array.isRequired,
field: PropTypes.object.isRequired,
onBatchMetadataRefresh: PropTypes.func.isRequired,
repoID: PropTypes.string.isRequired,
modifyLocalFileTags: PropTypes.func,
};
export default DirentsTagsEditor;

View File

@@ -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;
}

View File

@@ -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 (
<div
key={dirent.name}
className={className}
style={{
zIndex: 10 + index,
transform: `rotate(${angles[index] || 0}deg)`,
}}
>
<img
src={src}
alt={dirent.name}
onError={(e) => {
e.target.style.display = 'none';
const iconContainer = e.target.parentNode.querySelector('.fallback-icon');
if (iconContainer) iconContainer.style.display = 'flex';
}}
/>
</div>
);
};
if (imageDirents.length === 0) {
const overlayItems = selectedDirents.slice(0, maxOverlayThumbnails);
return (
<div className="multi-selection-thumbnails no-background">
{overlayItems.map((dirent, index) => renderOverlayItem(dirent, index, false))}
</div>
);
}
// With background image mode
const backgroundImage = imageDirents[0];
const overlayItems = selectedDirents
.filter(dirent => dirent.name !== backgroundImage.name)
.slice(-maxOverlayThumbnails);
const backgroundSrc = getThumbnailSrc(backgroundImage);
return (
<div className="multi-selection-thumbnails with-background">
<div className="background-thumbnail">
<img
src={backgroundSrc}
alt={backgroundImage.name}
/>
<div className="background-overlay"></div>
</div>
{overlayItems.map((dirent, index) => renderOverlayItem(dirent, index, true))}
</div>
);
};
return (
<Detail className="multi-selection-details">
<Header
title={gettext('Details')}
icon=''
onClose={onClose}
/>
<Body>
{renderOverlappingThumbnails()}
<div className="detail-content">
<p className="text-center">{gettext(`${selectedDirents.length} items have been selected`)}</p>
{isLoading && (
<div className="text-center py-4">
<Loading />
<p className="text-muted mt-2">{gettext('Loading metadata...')}</p>
</div>
)}
{!isLoading && onlyFiles && hasFiles && window.app.pageOptions.enableFileTags && enableTags && (
<DetailItem field={tagField} readonly={false} className="sf-metadata-property-detail-editor sf-metadata-tags-property-detail-editor">
<DirentsTagsEditor
records={records}
field={tagField}
onChange={onChange}
repoID={repoID}
modifyLocalFileTags={modifyLocalFileTags}
/>
</DetailItem>
)}
{!isLoading && onlyFiles && hasFiles && enableMetadata && (
<DetailItem field={rateField} className="sf-metadata-property-detail-editor sf-metadata-rate-property-detail-editor">
<RateEditor
value={rate}
field={rateField}
onChange={(value) => onChange(PRIVATE_COLUMN_KEY.FILE_RATE, value)}
/>
</DetailItem>
)}
</div>
</Body>
</Detail>
);
};
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;

View File

@@ -107,6 +107,11 @@ class MetadataManagerAPI {
return this.req.get(url, { params: params }); 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) { modifyRecord(repoID, { recordId, parentDir, fileName }, updateData) {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/'; const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/';
let data = { let data = {

View File

@@ -1838,7 +1838,7 @@ class LibContentView extends React.Component {
return item.name !== name; return item.name !== name;
}); });
this.recalculateSelectedDirents([name], direntList); this.recalculateSelectedDirents([name], direntList);
this.setState({ direntList: direntList }); this.setState({ direntList: direntList, currentDirent: null });
}; };
// only one scene: The moved items are inside current path // only one scene: The moved items are inside current path
@@ -1852,6 +1852,7 @@ class LibContentView extends React.Component {
selectedDirentList: [], selectedDirentList: [],
isDirentSelected: false, isDirentSelected: false,
isAllDirentSelected: false, isAllDirentSelected: false,
currentDirent: null,
}); });
}; };
@@ -2378,7 +2379,7 @@ class LibContentView extends React.Component {
} }
let detailPath = this.state.path; 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); detailPath = Utils.getDirName(this.state.path);
} }
@@ -2577,6 +2578,7 @@ class LibContentView extends React.Component {
repoID={this.props.repoID} repoID={this.props.repoID}
currentRepoInfo={{ ...this.state.currentRepoInfo }} currentRepoInfo={{ ...this.state.currentRepoInfo }}
dirent={detailDirent} dirent={detailDirent}
selectedDirents={this.state.selectedDirentList}
repoTags={this.state.repoTags} repoTags={this.state.repoTags}
fileTags={this.state.isViewFile ? this.state.fileTags : []} fileTags={this.state.isViewFile ? this.state.fileTags : []}
onFileTagChanged={this.onFileTagChanged} onFileTagChanged={this.onFileTagChanged}

View File

@@ -33,6 +33,7 @@ from seahub.settings import MD_FILE_COUNT_LIMIT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MetadataManage(APIView): class MetadataManage(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication) authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, ) permission_classes = (IsAuthenticated, )
@@ -749,6 +750,98 @@ class MetadataColumns(APIView):
return Response({'success': True}) 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): class MetadataFolders(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication) authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
@@ -2422,21 +2515,26 @@ class MetadataFileTags(APIView):
from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE
metadata_server_api = MetadataServerAPI(repo_id, request.user.username) metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
success_records = [] row_id_map = {}
failed_records = []
for file_tags in file_tags_data: for file_tags in file_tags_data:
record_id = file_tags.get('record_id', '') record_id = file_tags.get('record_id', '')
tags = file_tags.get('tags', []) tags = file_tags.get('tags', [])
if not record_id: if not record_id:
continue continue
try:
metadata_server_api.update_link(TAGS_TABLE.file_link_id, METADATA_TABLE.id, { record_id: tags }) row_id_map[record_id] = tags
success_records.append(record_id)
except Exception as e:
failed_records.append(record_id)
return Response({'success': success_records, 'fail': failed_records}) if not row_id_map:
return api_error(status.HTTP_400_BAD_REQUEST, 'No valid file_tags_data provided')
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): class MetadataTagFiles(APIView):

View File

@@ -3,12 +3,14 @@ from .apis import MetadataRecognizeFaces, MetadataRecords, MetadataManage, Metad
MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \
FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \
MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \ MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \
PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView, \
MetadataBatchRecords
urlpatterns = [ urlpatterns = [
re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'), 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'^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'^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'), re_path(r'^columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'),
# view # view