diff --git a/frontend/src/components/dir-view-mode/constants.js b/frontend/src/components/dir-view-mode/constants.js index f2d9f97627..0f0485ca85 100644 --- a/frontend/src/components/dir-view-mode/constants.js +++ b/frontend/src/components/dir-view-mode/constants.js @@ -1,3 +1,4 @@ export const LIST_MODE = 'list'; export const GRID_MODE = 'grid'; export const METADATA_MODE = 'metadata'; +export const FACE_RECOGNITION_MODE = 'person_image'; diff --git a/frontend/src/components/dir-view-mode/dir-column-view.js b/frontend/src/components/dir-view-mode/dir-column-view.js index 77f440f285..37de95cfc0 100644 --- a/frontend/src/components/dir-view-mode/dir-column-view.js +++ b/frontend/src/components/dir-view-mode/dir-column-view.js @@ -9,7 +9,8 @@ import ResizeBar from '../resize-bar'; import { DRAG_HANDLER_HEIGHT, MAX_SIDE_PANEL_RATE, MIN_SIDE_PANEL_RATE } from '../resize-bar/constants'; import { SeafileMetadata } from '../../metadata'; import { mediaUrl } from '../../utils/constants'; -import { GRID_MODE, LIST_MODE, METADATA_MODE } from './constants'; +import { GRID_MODE, LIST_MODE, METADATA_MODE, FACE_RECOGNITION_MODE } from './constants'; +import FaceRecognition from '../../metadata/views/face-recognition'; const propTypes = { isSidePanelFolded: PropTypes.bool, @@ -203,6 +204,9 @@ class DirColumnView extends React.Component { renameFileCallback={this.props.renameFileCallback} /> } + {currentMode === FACE_RECOGNITION_MODE && + + } {currentMode === LIST_MODE && { @@ -12,18 +12,31 @@ const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { }, [window.app.pageOptions.enableMetadataManagement]); const [showMetadataStatusManagementDialog, setShowMetadataStatusManagementDialog] = useState(false); - const { enableMetadata, updateEnableMetadata, navigation } = useMetadata(); + const [showMetadataFaceRecognitionDialog, setShowMetadataFaceRecognitionDialog] = useState(false); + const { enableMetadata, updateEnableMetadata, enableFaceRecognition, updateEnableFaceRecognition, navigation } = useMetadata(); const moreOperations = useMemo(() => { if (!enableMetadataManagement || !currentRepoInfo.is_admin) return []; - return [ + let operations = [ { key: 'extended-properties', value: gettext('Extended properties') } ]; - }, [enableMetadataManagement, currentRepoInfo]); + if (enableMetadata) { + operations.push({ key: 'face-recognition', value: gettext('Face recognition') }); + } + return operations; + }, [enableMetadataManagement, enableMetadata, currentRepoInfo]); const moreOperationClick = useCallback((operationKey) => { - if (operationKey === 'extended-properties') { - setShowMetadataStatusManagementDialog(true); - return; + switch (operationKey) { + case 'extended-properties': { + setShowMetadataStatusManagementDialog(true); + break; + } + case 'face-recognition': { + setShowMetadataFaceRecognitionDialog(true); + break; + } + default: + break; } }, []); @@ -31,6 +44,14 @@ const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { setShowMetadataStatusManagementDialog(false); }, []); + const closeMetadataFaceRecognitionDialog = useCallback(() => { + setShowMetadataFaceRecognitionDialog(false); + }, []); + + const openMetadataFaceRecognition = useCallback(() => { + updateEnableFaceRecognition(true); + }, [updateEnableFaceRecognition]); + const toggleMetadataStatus = useCallback((value) => { updateEnableMetadata(value); }, [updateEnableMetadata]); @@ -63,6 +84,14 @@ const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { submit={toggleMetadataStatus} /> )} + {showMetadataFaceRecognitionDialog && ( + + )} ); }; diff --git a/frontend/src/components/dropdown-menu/item-dropdown-menu.js b/frontend/src/components/dropdown-menu/item-dropdown-menu.js index 24d219cacf..967fc00499 100644 --- a/frontend/src/components/dropdown-menu/item-dropdown-menu.js +++ b/frontend/src/components/dropdown-menu/item-dropdown-menu.js @@ -52,9 +52,9 @@ class ItemDropdownMenu extends React.Component { UNSAFE_componentWillReceiveProps(nextProps) { // for toolbar item operation let { item } = nextProps; - if (item.name !== this.props.item.name) { - let menuList = this.props.getMenuList(item); - this.setState({ menuList: menuList }); + const nextMenuList = nextProps.getMenuList(item); + if (item.name !== this.props.item.name || this.state.menuList !== nextMenuList) { + this.setState({ menuList: nextMenuList }); } } diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index fd1e8d1350..c4eff2a00e 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -4,7 +4,8 @@ import KeyCodes from './keyCodes'; export const DIALOG_MAX_HEIGHT = window.innerHeight - 56; // Dialog margin is 3.5rem (56px) export const PRIVATE_FILE_TYPE = { - FILE_EXTENDED_PROPERTIES: '__file_extended_properties' + FILE_EXTENDED_PROPERTIES: '__file_extended_properties', + FACE_RECOGNITION: '__face_recognition', }; const TAG_COLORS = ['#FBD44A', '#EAA775', '#F4667C', '#DC82D2', '#9860E5', '#9F8CF1', '#59CB74', '#ADDF84', diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js index f04b1e2c99..4dd3543242 100644 --- a/frontend/src/metadata/api.js +++ b/frontend/src/metadata/api.js @@ -247,6 +247,32 @@ class MetadataManagerAPI { }; return this.req.delete(url, { data }); } + + // face recognition + getFaceRecognitionStatus(repoID) { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/face-recognition/'; + return this.req.get(url); + } + + openFaceRecognition = (repoID) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/face-recognition/'; + return this.req.post(url); + }; + + getFaceData = (repoID, start = 0, limit = 1000) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/face-records/?start=' + start + '&limit=' + limit; + return this.req.get(url); + }; + + updateFaceName = (repoID, recordID, name) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/face-record/'; + const params = { + record_id: recordID, + name: name, + }; + return this.req.put(url, params); + }; + } const metadataAPI = new MetadataManagerAPI(); diff --git a/frontend/src/metadata/components/dialog/metadata-face-recognition-dialog/index.js b/frontend/src/metadata/components/dialog/metadata-face-recognition-dialog/index.js new file mode 100644 index 0000000000..a4ff3d3ee1 --- /dev/null +++ b/frontend/src/metadata/components/dialog/metadata-face-recognition-dialog/index.js @@ -0,0 +1,51 @@ +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { gettext } from '../../../../utils/constants'; +import metadataAPI from '../../../api'; +import toaster from '../../../../components/toast'; +import { Utils } from '../../../../utils/utils'; + +const MetadataFaceRecognitionDialog = ({ value, repoID, toggle, submit }) => { + const [submitting, setSubmitting] = useState(false); + + const onToggle = useCallback(() => { + toggle(); + }, [toggle]); + + const onSubmit = useCallback(() => { + setSubmitting(true); + metadataAPI.openFaceRecognition(repoID).then(res => { + submit(true); + toggle(); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + setSubmitting(false); + }); + }, [repoID, submit, toggle]); + + return ( + + {gettext('Face recognition')} + + {value ? gettext('Face recognition enabled.') : gettext('Whether to enable face recognition?')} + + {!value && ( + + + + + )} + + ); +}; + +MetadataFaceRecognitionDialog.propTypes = { + value: PropTypes.bool.isRequired, + repoID: PropTypes.string.isRequired, + toggle: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, +}; + +export default MetadataFaceRecognitionDialog; diff --git a/frontend/src/metadata/constants/column/common.js b/frontend/src/metadata/constants/column/common.js index 27e2a58b60..6217983b00 100644 --- a/frontend/src/metadata/constants/column/common.js +++ b/frontend/src/metadata/constants/column/common.js @@ -11,6 +11,7 @@ export const NOT_DISPLAY_COLUMN_KEYS = [ PRIVATE_COLUMN_KEY.FILE_DETAILS, PRIVATE_COLUMN_KEY.LOCATION, PRIVATE_COLUMN_KEY.IS_DIR, + PRIVATE_COLUMN_KEY.FACE_LINKS, ]; export const VIEW_NOT_DISPLAY_COLUMN_KEYS = [ diff --git a/frontend/src/metadata/constants/column/private.js b/frontend/src/metadata/constants/column/private.js index 90dc9de19c..17783b596e 100644 --- a/frontend/src/metadata/constants/column/private.js +++ b/frontend/src/metadata/constants/column/private.js @@ -29,6 +29,7 @@ export const PRIVATE_COLUMN_KEY = { CAPTURE_TIME: '_capture_time', FILE_REVIEWER: '_reviewer', OWNER: '_owner', + FACE_LINKS: '_face_links', }; export const PRIVATE_COLUMN_KEYS = [ @@ -59,6 +60,7 @@ export const PRIVATE_COLUMN_KEYS = [ PRIVATE_COLUMN_KEY.CAPTURE_TIME, PRIVATE_COLUMN_KEY.FILE_REVIEWER, PRIVATE_COLUMN_KEY.OWNER, + PRIVATE_COLUMN_KEY.FACE_LINKS, ]; export const EDITABLE_PRIVATE_COLUMN_KEYS = [ diff --git a/frontend/src/metadata/constants/view.js b/frontend/src/metadata/constants/view.js index 5b3315d8b7..319c8f168c 100644 --- a/frontend/src/metadata/constants/view.js +++ b/frontend/src/metadata/constants/view.js @@ -6,7 +6,7 @@ import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_CO export const VIEW_TYPE = { TABLE: 'table', - GALLERY: 'gallery' + GALLERY: 'gallery', }; export const VIEW_TYPE_ICON = { diff --git a/frontend/src/metadata/hooks/metadata.js b/frontend/src/metadata/hooks/metadata.js index 35aeb34752..7dcd49eaa8 100644 --- a/frontend/src/metadata/hooks/metadata.js +++ b/frontend/src/metadata/hooks/metadata.js @@ -15,8 +15,10 @@ export const MetadataProvider = ({ repoID, hideMetadataView, selectMetadataView, }, [window.app.pageOptions.enableMetadataManagement]); const [enableMetadata, setEnableExtendedProperties] = useState(false); + const [enableFaceRecognition, setEnableFaceRecognition] = useState(false); const [showFirstView, setShowFirstView] = useState(false); const [navigation, setNavigation] = useState([]); + const [staticView, setStaticView] = useState([]); const [, setCount] = useState(0); const viewsMap = useRef({}); @@ -55,12 +57,21 @@ export const MetadataProvider = ({ repoID, hideMetadataView, selectMetadataView, if (!newValue) { hideMetadataView && hideMetadataView(); cancelURLView(); + setEnableFaceRecognition(false); } else { setShowFirstView(true); } setEnableExtendedProperties(newValue); }, [enableMetadata, hideMetadataView, cancelURLView]); + const updateEnableFaceRecognition = useCallback((newValue) => { + if (newValue === enableFaceRecognition) return; + setEnableFaceRecognition(newValue); + if (newValue) { + toaster.success(gettext('Recognizing portraits. Please refresh the page later.')); + } + }, [enableFaceRecognition]); + // views useEffect(() => { if (enableMetadata) { @@ -71,6 +82,11 @@ export const MetadataProvider = ({ repoID, hideMetadataView, selectMetadataView, viewsMap.current[view._id] = view; }); } + viewsMap.current['_face_recognition'] = { + _id: '_face_recognition', + name: gettext('Photos - classfied by people'), + type: PRIVATE_FILE_TYPE.FACE_RECOGNITION, + }; setNavigation(navigation); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); @@ -84,8 +100,31 @@ export const MetadataProvider = ({ repoID, hideMetadataView, selectMetadataView, // eslint-disable-next-line react-hooks/exhaustive-deps }, [repoID, enableMetadata]); + useEffect(() => { + if (!enableMetadata) { + setStaticView([]); + setEnableFaceRecognition(false); + return; + } + metadataAPI.getFaceRecognitionStatus(repoID).then(res => { + setEnableFaceRecognition(res.data.enabled); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + }); + }, [repoID, enableMetadata]); + + useEffect(() => { + if (!enableFaceRecognition) { + setStaticView([]); + return; + } + setStaticView([{ _id: '_face_recognition', type: 'view' }]); + }, [enableFaceRecognition]); + const selectView = useCallback((view, isSelected) => { if (isSelected) return; + const isFaceRecognitionView = view.type === PRIVATE_FILE_TYPE.FACE_RECOGNITION; const node = { children: [], path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view._id, @@ -94,9 +133,9 @@ export const MetadataProvider = ({ repoID, hideMetadataView, selectMetadataView, isPreload: true, object: { file_tags: [], - id: PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, - name: gettext('File extended properties'), - type: PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, + id: isFaceRecognitionView ? PRIVATE_FILE_TYPE.FACE_RECOGNITION : PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, + name: isFaceRecognitionView ? gettext('Photos - classfied by people') : gettext('File extended properties'), + type: isFaceRecognitionView ? PRIVATE_FILE_TYPE.FACE_RECOGNITION : PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, isDir: () => false, }, parentNode: {}, @@ -177,9 +216,12 @@ export const MetadataProvider = ({ repoID, hideMetadataView, selectMetadataView, { }, [userPerm]); const [, setState] = useState(0); const { + enableFaceRecognition, showFirstView, navigation, + staticView, viewsMap, selectView, addView, @@ -196,6 +198,20 @@ const MetadataTreeView = ({ userPerm, currentPath }) => { /> )} + {enableFaceRecognition && staticView.map((item) => { + const view = viewsMap[item._id]; + const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view._id; + const isSelected = currentPath === viewPath; + return ( + selectView(view, isSelected)} + /> + ); + })} {canAdd && (
{ return gettext('Capture time'); case PRIVATE_COLUMN_KEY.OWNER: return gettext('File owner'); + case PRIVATE_COLUMN_KEY.FACE_FEATURES: + return gettext('Face Features'); default: return name; } diff --git a/frontend/src/metadata/views/face-recognition/face-group.js b/frontend/src/metadata/views/face-recognition/face-group.js new file mode 100644 index 0000000000..d6bb6f9aee --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/face-group.js @@ -0,0 +1,110 @@ +import React, { useCallback, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Input } from 'reactstrap'; +import toaster from '../../../components/toast'; +import { Utils } from '../../../utils/utils'; +import { gettext, siteRoot } from '../../../utils/constants'; +import metadataAPI from '../../api'; +import isHotkey from 'is-hotkey'; +import { isEnter } from '../../utils/hotkey'; + +const theadData = [ + { width: '5%', text: '' }, + { width: '39%', text: gettext('Name') }, + { width: '34%', text: gettext('Original path') }, + { width: '11%', text: gettext('Size') }, + { width: '11%', text: gettext('Last Update') }, +]; + +const FaceGroup = ({ repoID, group, onPhotoClick }) => { + const [name, setName] = useState(group.name); + const [isRenaming, setRenaming] = useState(false); + const serverName = useRef(group.name); + + const showPhoto = useCallback((event, photo) => { + event.preventDefault(); + onPhotoClick(photo); + }, [onPhotoClick]); + + const changeName = useCallback((event) => { + const value = event.target.value; + if (name === value) return; + setName(value); + }, [name]); + + const renameName = useCallback(() => { + setRenaming(true); + }, []); + + const updateName = useCallback(() => { + if (name === serverName.current) { + setRenaming(false); + return; + } + metadataAPI.updateFaceName(repoID, group.record_id, name).then(res => { + serverName.current = name; + setRenaming(false); + }).catch(err => { + const errorMsg = Utils.getErrorMsg(err); + toaster.danger(errorMsg); + setName(serverName.current); + setRenaming(false); + }); + }, [repoID, group, name]); + + const onRenameKeyDown = useCallback((event) => { + if (isEnter(event)) { + updateName(); + } else if (isHotkey('esc', event)) { + setName(serverName.current); + setRenaming(false); + } + }, [updateName]); + + return ( +
+ {isRenaming ? + () + : + (
{name}
) + } + + + + {theadData.map((item, index) => { + return ; + })} + + + + {group.photos.map((photo, index) => { + return ( + showPhoto(event, photo)}> + + + + + + + ); + })} + +
{item.text}
showPhoto(event, photo)}>{photo.file_name}{photo.parent_dir}{Utils.bytesToSize(photo.size)}{moment(photo.mtime).fromNow()}
+
+ ); +}; + +FaceGroup.propTypes = { + repoID: PropTypes.string, + group: PropTypes.object.isRequired, + onPhotoClick: PropTypes.func, +}; + +export default FaceGroup; diff --git a/frontend/src/metadata/views/face-recognition/index.css b/frontend/src/metadata/views/face-recognition/index.css new file mode 100644 index 0000000000..2c4fa763d0 --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/index.css @@ -0,0 +1,29 @@ +.sf-metadata-face-recognition { + height: 100%; + width: 100%; + padding: 16px; + overflow-x: hidden; + overflow-y: scroll; +} + +.sf-metadata-face-recognition .sf-metadata-face-recognition-item { + margin-bottom: 16px; +} + +.sf-metadata-face-recognition .sf-metadata-face-recognition-item:last-child { + margin-bottom: 0; +} + +.sf-metadata-face-recognition .sf-metadata-face-recognition-name { + border-color: transparent; + cursor: pointer; +} + +.sf-metadata-face-recognition .sf-metadata-face-recognition-loading-more { + height: 30px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} diff --git a/frontend/src/metadata/views/face-recognition/index.js b/frontend/src/metadata/views/face-recognition/index.js new file mode 100644 index 0000000000..15b41c0c9e --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/index.js @@ -0,0 +1,161 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; +import toaster from '../../../components/toast'; +import { Utils } from '../../../utils/utils'; +import metadataAPI from '../../api'; +import FaceGroup from './face-group'; +import ImageDialog from '../../../components/dialog/image-dialog'; +import ModalPortal from '../../../components/modal-portal'; +import { siteRoot, gettext, thumbnailSizeForOriginal, thumbnailDefaultSize } from '../../../utils/constants'; + +import './index.css'; + +const LIMIT = 1000; + +const FaceRecognition = ({ repoID }) => { + const [loading, setLoading] = useState(true); + const [faceOriginData, setFaceOriginData] = useState([]); + const [isLoadingMore, setLoadingMore] = useState(false); + const [isImagePopupOpen, setIsImagePopupOpen] = useState(false); + const [imageIndex, setImageIndex] = useState(-1); + const containerRef = useRef(null); + const hasMore = useRef(true); + + const faceData = useMemo(() => { + if (!Array.isArray(faceOriginData) || faceOriginData.length === 0) return []; + const data = faceOriginData.map(dataItem => { + const { record_id, link_photos } = dataItem; + const linkPhotos = link_photos || []; + const name = dataItem.name || gettext('Person Image'); + return { + record_id: record_id, + name: name || gettext('Person Image'), + photos: linkPhotos.map(photo => { + const { path } = photo; + return { + ...photo, + name: photo.file_name, + url: `${siteRoot}lib/${repoID}/file${path}`, + default_url: `${siteRoot}thumbnail/${repoID}/${thumbnailDefaultSize}${path}`, + src: `${siteRoot}thumbnail/${repoID}/${thumbnailDefaultSize}${path}`, + thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`, + }; + }), + }; + }); + return data; + }, [repoID, faceOriginData]); + + const imageItems = useMemo(() => { + return faceData.map(group => group.photos).flat(); + }, [faceData]); + + useEffect(() => { + setLoading(true); + metadataAPI.getFaceData(repoID, 0, LIMIT).then(res => { + const faceOriginData = res.data.results || []; + if (faceOriginData.length < LIMIT) { + hasMore.current = false; + } + setFaceOriginData(faceOriginData); + setLoading(false); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadMore = useCallback(() => { + if (!hasMore.current) return; + setLoadingMore(true); + metadataAPI.getFaceData(repoID, faceOriginData.length, LIMIT).then(res => { + const newFaceData = res.data.results || []; + if (newFaceData.length < LIMIT) { + hasMore.current = false; + } + setFaceOriginData([...faceOriginData, ...newFaceData]); + setLoadingMore(false); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + setLoadingMore(false); + }); + }, [repoID, faceOriginData]); + + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + if (scrollTop + clientHeight >= scrollHeight - 10) { + loadMore(); + } + }, [loadMore]); + + const onPhotoClick = useCallback((photo) => { + let imageIndex = imageItems.findIndex(item => item.url === photo.url); + if (imageIndex < 0) imageIndex = 0; + setImageIndex(imageIndex); + setIsImagePopupOpen(true); + }, [imageItems]); + + const closeImagePopup = useCallback(() => { + setIsImagePopupOpen(false); + setImageIndex(-1); + }, []); + + const moveToPrevImage = useCallback(() => { + let prevImageIndex = imageIndex - 1; + if (prevImageIndex < 0) prevImageIndex = imageItems.length - 1; + setImageIndex(prevImageIndex); + }, [imageIndex, imageItems]); + + const moveToNextImage = useCallback(() => { + let nextImageIndex = imageIndex + 1; + if (nextImageIndex > imageItems.length - 1) nextImageIndex = 0; + setImageIndex(nextImageIndex); + }, [imageIndex, imageItems]); + + if (loading) { + return (); + } + + return ( + <> +
+
+
+
+ {faceData.length > 0 && faceData.map((face) => { + return (); + })} + {isLoadingMore && ( +
+ +
+ )} +
+
+
+
+ {isImagePopupOpen && ( + + + + )} + + ); +}; + +FaceRecognition.propTypes = { + repoID: PropTypes.string.isRequired, +}; + +export default FaceRecognition; diff --git a/frontend/src/metadata/views/gallery/index.js b/frontend/src/metadata/views/gallery/index.js index 3809a5a22c..bc93037b90 100644 --- a/frontend/src/metadata/views/gallery/index.js +++ b/frontend/src/metadata/views/gallery/index.js @@ -340,17 +340,17 @@ const Gallery = () => { onDownload={handleDownload} onDelete={handleDelete} /> - {isImagePopupOpen && - - - - } + {isImagePopupOpen && ( + + + + )} {isZipDialogOpen && { + const repoID = this.props.repoID; + const repoInfo = this.state.currentRepoInfo; + this.setState({ + currentMode: FACE_RECOGNITION_MODE, + path: filePath, + viewId: viewId, + isDirentDetailShow: false + }); + const url = `${siteRoot}library/${repoID}/${encodeURIComponent(repoInfo.repo_name)}/?view=${encodeURIComponent(viewId)}`; + window.history.pushState({ url: url, path: '' }, '', url); + }; + hideFileMetadata = () => { this.setState({ currentMode: LIST_MODE, @@ -1890,6 +1903,10 @@ class LibContentView extends React.Component { if (node.path !== this.state.path) { this.showFileMetadata(node.path, node.view_id || '0000'); } + } else if (Utils.isFaceRecognition(node?.object?.type)) { + if (node.path !== this.state.path) { + this.showFaceRecognition(node.path, node.view_id || '0000'); + } } else { let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path); let dirent = node.object; @@ -2027,7 +2044,7 @@ class LibContentView extends React.Component { isDirentSelected: false, isAllDirentSelected: false, }); - if (this.state.currentMode === METADATA_MODE) { + if (this.state.currentMode === METADATA_MODE || this.state.currentMode === FACE_RECOGNITION_MODE) { this.setState({ currentMode: cookie.load('seafile_view_mode') || LIST_MODE, }); diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 3769e92a8a..11377c6bbf 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -154,6 +154,10 @@ export const Utils = { } }, + isFaceRecognition: function (type) { + return type === PRIVATE_FILE_TYPE.FACE_RECOGNITION; + }, + getShareLinkPermissionList: function (itemType, permission, path, canEdit) { // itemType: library, dir, file // permission: rw, r, admin, cloud-edit, preview, custom-* diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/api2/endpoints/metadata_manage.py index 57446f4ceb..50b1593c4a 100644 --- a/seahub/api2/endpoints/metadata_manage.py +++ b/seahub/api2/endpoints/metadata_manage.py @@ -1,4 +1,5 @@ import logging +import os from datetime import datetime from rest_framework.authentication import SessionAuthentication @@ -12,7 +13,7 @@ from seahub.api2.authentication import TokenAuthentication from seahub.repo_metadata.models import RepoMetadata, RepoMetadataViews from seahub.views import check_folder_permission from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id, init_metadata, \ - get_unmodifiable_columns, can_read_metadata + get_unmodifiable_columns, can_read_metadata, init_faces, add_init_face_recognition_task, get_metadata_by_faces from seahub.repo_metadata.metadata_server_api import MetadataServerAPI, list_metadata_view_records from seahub.utils.timeutils import datetime_to_isoformat_timestr from seahub.utils.repo import is_repo_admin @@ -815,3 +816,248 @@ class MetadataViewsMoveView(APIView): return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) return Response({'navigation': results['navigation']}) + + +class FacesRecords(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request, repo_id): + start = request.GET.get('start', 0) + limit = request.GET.get('limit', 100) + + try: + start = int(start) + limit = int(limit) + except: + start = 0 + limit = 1000 + + if start < 0: + error_msg = 'start invalid' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if limit < 0: + error_msg = 'limit invalid' + 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) + + permission = check_folder_permission(request, repo_id, '/') + if not permission: + 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 = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + + from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE + + try: + metadata = metadata_server_api.get_metadata() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + tables = metadata.get('tables', []) + faces_table_id = [table['id'] for table in tables if table['name'] == FACES_TABLE.name] + faces_table_id = faces_table_id[0] if faces_table_id else None + if not faces_table_id: + return api_error(status.HTTP_404_NOT_FOUND, 'face recognition not be used') + + sql = f'SELECT * FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.photo_links.name}` IS NOT NULL LIMIT {start}, {limit}' + + try: + query_result = metadata_server_api.query_rows(sql) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + faces = query_result.get('results') + + if not faces: + error_msg = 'Records not found' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + query_result = get_metadata_by_faces(faces, metadata_server_api) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + if not query_result: + error_msg = 'Records not found' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + classify_result = dict() + for row in query_result: + link_row_ids = [item['row_id'] for item in row.get(METADATA_TABLE.columns.face_links.name, [])] + if not link_row_ids: + continue + for link_row_id in link_row_ids: + if link_row_id not in classify_result: + classify_result[link_row_id] = [] + file_name = row.get(METADATA_TABLE.columns.file_name.name, '') + parent_dir = row.get(METADATA_TABLE.columns.parent_dir.name, '') + size = row.get(METADATA_TABLE.columns.size.name, 0) + mtime = row.get('_mtime') + classify_result[link_row_id].append({ + 'path': os.path.join(parent_dir, file_name), + 'file_name': file_name, + 'parent_dir': parent_dir, + 'size': size, + 'mtime': mtime + }) + + id_to_name = {item.get(FACES_TABLE.columns.id.name): item.get(FACES_TABLE.columns.name.name, '') for item in faces} + classify_result = [{ + 'record_id': key, + 'name': id_to_name.get(key, ''), + 'link_photos': value + } for key, value in classify_result.items()] + return Response({'results': classify_result}) + + +class FacesRecord(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def put(self, request, repo_id): + name = request.data.get('name') + record_id = request.data.get('record_id') + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled: + error_msg = f'The metadata module is not enabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # resource check + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if not is_repo_admin(request.user.username, repo_id): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + from seafevents.repo_metadata.utils import FACES_TABLE + + try: + metadata = metadata_server_api.get_metadata() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + tables = metadata.get('tables', []) + faces_table_id = [table['id'] for table in tables if table['name'] == FACES_TABLE.name] + faces_table_id = faces_table_id[0] if faces_table_id else None + if not faces_table_id: + return api_error(status.HTTP_404_NOT_FOUND, 'face recognition not be used') + + sql = f'SELECT * FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.id.name}` = "{record_id}"' + try: + results = metadata_server_api.query_rows(sql).get('results', []) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + if not results: + error_msg = 'Record not found' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + update_row = { + FACES_TABLE.columns.id.name: record_id, + FACES_TABLE.columns.name.name: name, + } + + try: + metadata_server_api.update_rows(faces_table_id, [update_row]) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) + + +class FaceRecognitionManage(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) + + def get(self, request, repo_id): + # recource check + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, 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 not enabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + if not can_read_metadata(request, repo_id): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + from seafevents.repo_metadata.utils import FACES_TABLE + + try: + metadata = metadata_server_api.get_metadata() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + tables = metadata.get('tables', []) + faces_table_id = [table['id'] for table in tables if table['name'] == FACES_TABLE.name] + is_enabled = True if faces_table_id else False + + return Response({'enabled': is_enabled}) + + def post(self, request, repo_id): + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled: + error_msg = f'The metadata module is not enabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # resource check + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if not is_repo_admin(request.user.username, repo_id): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + params = { + 'repo_id': repo_id, + } + + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + init_faces(metadata_server_api) + + try: + task_id = add_init_face_recognition_task(params=params) + except Exception as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + return Response({'task_id': task_id}) diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py index d5af0c6763..a247420c89 100644 --- a/seahub/repo_metadata/metadata_server_api.py +++ b/seahub/repo_metadata/metadata_server_api.py @@ -171,3 +171,16 @@ class MetadataServerAPI: } response = requests.put(url, json=data, headers=self.headers, timeout=self.timeout) return parse_response(response) + + def create_table(self, table_name): + url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/tables' + data = { + 'name': table_name, + } + response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout) + return parse_response(response) + + def get_metadata(self): + url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/metadata' + response = requests.get(url, headers=self.headers, timeout=self.timeout) + return parse_response(response) diff --git a/seahub/repo_metadata/utils.py b/seahub/repo_metadata/utils.py index 30ea3fe057..85171eafea 100644 --- a/seahub/repo_metadata/utils.py +++ b/seahub/repo_metadata/utils.py @@ -20,6 +20,42 @@ def add_init_metadata_task(params): return json.loads(resp.content)['task_id'] +def add_init_face_recognition_task(params): + payload = {'exp': int(time.time()) + 300, } + token = jwt.encode(payload, SECRET_KEY, algorithm='HS256') + headers = {"Authorization": "Token %s" % token} + url = urljoin(SEAFEVENTS_SERVER_URL, '/add-init-face-recognition-task') + resp = requests.get(url, params=params, headers=headers) + return json.loads(resp.content)['task_id'] + + +def get_metadata_by_faces(faces, metadata_server_api): + from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE + sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN (' + parameters = [] + query_result = [] + for face in faces: + link_row_ids = [item['row_id'] for item in face.get(FACES_TABLE.columns.photo_links.name, [])] + if not link_row_ids: + continue + for link_row_id in link_row_ids: + sql += '?, ' + parameters.append(link_row_id) + if len(parameters) >= 10000: + sql = sql.rstrip(', ') + ');' + results = metadata_server_api.query_rows(sql, parameters).get('results', []) + query_result.extend(results) + sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN (' + parameters = [] + + if parameters: + sql = sql.rstrip(', ') + ');' + results = metadata_server_api.query_rows(sql, parameters).get('results', []) + query_result.extend(results) + + return query_result + + def generator_base64_code(length=4): possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789' ids = random.sample(possible, length) @@ -57,6 +93,36 @@ def get_sys_columns(): return columns +def get_link_column(face_table_id): + from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE + columns = [ + METADATA_TABLE.columns.face_links.to_dict({ + 'link_id': FACES_TABLE.link_id, + 'table_id': METADATA_TABLE.id, + 'other_table_id': face_table_id, + 'display_column_key': FACES_TABLE.columns.name.key, + }), + ] + + return columns + + +def get_face_columns(face_table_id): + from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE + columns = [ + FACES_TABLE.columns.photo_links.to_dict({ + 'link_id': FACES_TABLE.link_id, + 'table_id': METADATA_TABLE.id, + 'other_table_id': face_table_id, + 'display_column_key': METADATA_TABLE.columns.obj_id.key, + }), + FACES_TABLE.columns.vector.to_dict(), + FACES_TABLE.columns.name.to_dict(), + ] + + return columns + + def get_unmodifiable_columns(): from seafevents.repo_metadata.utils import METADATA_TABLE columns = [ @@ -90,6 +156,20 @@ def init_metadata(metadata_server_api): metadata_server_api.add_columns(METADATA_TABLE.id, sys_columns) +def init_faces(metadata_server_api): + from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE + + resp = metadata_server_api.create_table(FACES_TABLE.name) + + # init link column + link_column = get_link_column(resp['id']) + metadata_server_api.add_columns(METADATA_TABLE.id, link_column) + + # init face column + face_columns = get_face_columns(resp['id']) + metadata_server_api.add_columns(resp['id'], face_columns) + + def get_file_download_token(repo_id, file_id, username): return seafile_api.get_fileserver_access_token(repo_id, file_id, 'download', username, use_onetime=True) diff --git a/seahub/urls.py b/seahub/urls.py index 804ecf95d8..f858d4a441 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -211,7 +211,8 @@ from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2DuplicatePageView, WikiPageTrashView, Wiki2PublishView, Wiki2PublishConfigView, Wiki2PublishPageView from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \ - MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView + MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ + FaceRecognitionManage, FacesRecord from seahub.api2.endpoints.user_list import UserListView from seahub.api2.endpoints.seahub_io import SeahubIOStatus @@ -1052,6 +1053,9 @@ if settings.ENABLE_METADATA_MANAGEMENT: re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/views/(?P[-0-9a-zA-Z]{4})/$', MetadataViewsDetailView.as_view(), name='api-v2.1-metadata-views-detail'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/move-views/$', MetadataViewsMoveView.as_view(), name='api-v2.1-metadata-views-move'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/duplicate-view/$', MetadataViewsDuplicateView.as_view(), name='api-v2.1-metadata-view-duplicate'), + re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-record/$', FacesRecord.as_view(), name='api-v2.1-metadata-face-record'), + re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-records/$', FacesRecords.as_view(), name='api-v2.1-metadata-face-records'), + re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-recognition/$', FaceRecognitionManage.as_view(), name='api-v2.1-metadata-face-recognition'), ] # ai API