diff --git a/frontend/src/assets/icons/face-recognition-view.svg b/frontend/src/assets/icons/face-recognition-view.svg new file mode 100644 index 0000000000..6c49f84c9a --- /dev/null +++ b/frontend/src/assets/icons/face-recognition-view.svg @@ -0,0 +1,15 @@ + + + + +photos-classfied-by-people + + + + + diff --git a/frontend/src/components/cur-dir-path/dir-tool.js b/frontend/src/components/cur-dir-path/dir-tool.js index e0584ff3d7..e4c2822a40 100644 --- a/frontend/src/components/cur-dir-path/dir-tool.js +++ b/frontend/src/components/cur-dir-path/dir-tool.js @@ -11,7 +11,6 @@ import ReposSortMenu from '../../components/repos-sort-menu'; import MetadataViewToolBar from '../../metadata/components/view-toolbar'; import { PRIVATE_FILE_TYPE } from '../../constants'; import { DIRENT_DETAIL_MODE } from '../dir-view-mode/constants'; -import { FACE_RECOGNITION_VIEW_ID } from '../../metadata/constants'; const propTypes = { repoID: PropTypes.string.isRequired, @@ -118,7 +117,6 @@ class DirTool extends React.Component { }); if (isFileExtended) { - if (viewId === FACE_RECOGNITION_VIEW_ID) return null; return (
diff --git a/frontend/src/components/dir-view-mode/constants.js b/frontend/src/components/dir-view-mode/constants.js index b3e41123ec..96c85d5843 100644 --- a/frontend/src/components/dir-view-mode/constants.js +++ b/frontend/src/components/dir-view-mode/constants.js @@ -2,4 +2,3 @@ export const LIST_MODE = 'list'; export const GRID_MODE = 'grid'; export const DIRENT_DETAIL_MODE = 'detail'; 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 0559d211f2..0687c0f771 100644 --- a/frontend/src/components/dir-view-mode/dir-column-view.js +++ b/frontend/src/components/dir-view-mode/dir-column-view.js @@ -9,8 +9,7 @@ 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, FACE_RECOGNITION_MODE } from './constants'; -import FaceRecognition from '../../metadata/views/face-recognition'; +import { GRID_MODE, LIST_MODE, METADATA_MODE } from './constants'; const propTypes = { isSidePanelFolded: PropTypes.bool, @@ -81,6 +80,7 @@ const propTypes = { onItemsScroll: PropTypes.func.isRequired, eventBus: PropTypes.object, updateCurrentDirent: PropTypes.func.isRequired, + closeDirentDetail: PropTypes.func.isRequired, }; class DirColumnView extends React.Component { @@ -195,7 +195,7 @@ class DirColumnView extends React.Component { onScroll={this.props.isViewFile ? () => {} : this.props.onItemsScroll} ref={this.dirContentMain} > - {currentMode === METADATA_MODE && + {currentMode === METADATA_MODE && ( - } - {currentMode === FACE_RECOGNITION_MODE && - - } + )} {currentMode === LIST_MODE && { + renamePeople = (repoID, recordId, name) => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/face-record/'; const params = { - record_id: recordID, + record_id: recordId, name: name, }; return this.req.put(url, params); }; + getPeoplePhotos = (repoID, peopleId, start = 0, limit = 1000) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/people-photos/' + peopleId + '/?start=' + start + '&limit=' + limit; + return this.req.get(url); + }; + } const metadataAPI = new MetadataManagerAPI(); diff --git a/frontend/src/metadata/components/view-details/index.js b/frontend/src/metadata/components/view-details/index.js index 0f60dc5426..ea1f4cc5e1 100644 --- a/frontend/src/metadata/components/view-details/index.js +++ b/frontend/src/metadata/components/view-details/index.js @@ -16,6 +16,7 @@ const ViewDetails = ({ viewId, onClose }) => { const type = view.type; if (type === VIEW_TYPE.GALLERY) return `${mediaUrl}favicons/gallery.png`; if (type === VIEW_TYPE.TABLE) return `${mediaUrl}favicons/table.png`; + if (type === VIEW_TYPE.FACE_RECOGNITION) return `${mediaUrl}favicons/face-recognition-view.png`; return `${mediaUrl}img/file/256/file.png`; }, [view]); diff --git a/frontend/src/metadata/components/view-toolbar/face-recognition/index.js b/frontend/src/metadata/components/view-toolbar/face-recognition/index.js new file mode 100644 index 0000000000..9cef59d51d --- /dev/null +++ b/frontend/src/metadata/components/view-toolbar/face-recognition/index.js @@ -0,0 +1,46 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { GalleryGroupBySetter, GallerySliderSetter } from '../../data-process-setter'; +import { gettext } from '../../../../utils/constants'; +import { EVENT_BUS_TYPE } from '../../../constants'; + +const FaceRecognitionViewToolbar = ({ isCustomPermission, view, showDetail }) => { + const [isShow, setShow] = useState(false); + + const onToggle = useCallback((isShow) => { + setShow(isShow); + }, []); + + useEffect(() => { + const unsubscribeToggle = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.TOGGLE_VIEW_TOOLBAR, onToggle); + return () => { + unsubscribeToggle && unsubscribeToggle(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!isShow) return null; + + return ( + <> +
+ + + {!isCustomPermission && ( +
+ +
+ )} +
+
+ + ); +}; + +FaceRecognitionViewToolbar.propTypes = { + isCustomPermission: PropTypes.bool, + view: PropTypes.object.isRequired, + showDetail: PropTypes.func, +}; + +export default FaceRecognitionViewToolbar; diff --git a/frontend/src/metadata/components/view-toolbar/index.js b/frontend/src/metadata/components/view-toolbar/index.js index 1404e14291..fc3ffcf607 100644 --- a/frontend/src/metadata/components/view-toolbar/index.js +++ b/frontend/src/metadata/components/view-toolbar/index.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { EVENT_BUS_TYPE, VIEW_TYPE } from '../../constants'; import TableViewToolbar from './table-view-toolbar'; import GalleryViewToolbar from './gallery-view-toolbar'; +import FaceRecognitionViewToolbar from './face-recognition'; import './index.css'; @@ -89,6 +90,13 @@ const ViewToolBar = ({ viewId, isCustomPermission, showDetail }) => { showDetail={showDetail} /> )} + {viewType === VIEW_TYPE.FACE_RECOGNITION && ( + + )}
); }; diff --git a/frontend/src/metadata/constants/event-bus-type.js b/frontend/src/metadata/constants/event-bus-type.js index 71863a3f75..083005bd4b 100644 --- a/frontend/src/metadata/constants/event-bus-type.js +++ b/frontend/src/metadata/constants/event-bus-type.js @@ -53,4 +53,7 @@ export const EVENT_BUS_TYPE = { // gallery MODIFY_GALLERY_ZOOM_GEAR: 'modify_gallery_zoom_gear', SWITCH_GALLERY_GROUP_BY: 'switch_gallery_group_by', + + // face recognition + TOGGLE_VIEW_TOOLBAR: 'toggle_view_toolbar', }; diff --git a/frontend/src/metadata/constants/view.js b/frontend/src/metadata/constants/view.js index 035877d52e..1cc557d067 100644 --- a/frontend/src/metadata/constants/view.js +++ b/frontend/src/metadata/constants/view.js @@ -7,6 +7,7 @@ import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_CO export const VIEW_TYPE = { TABLE: 'table', GALLERY: 'gallery', + FACE_RECOGNITION: 'face_recognition', }; export const FACE_RECOGNITION_VIEW_ID = '_face_recognition'; @@ -14,6 +15,7 @@ export const FACE_RECOGNITION_VIEW_ID = '_face_recognition'; export const VIEW_TYPE_ICON = { [VIEW_TYPE.TABLE]: 'table', [VIEW_TYPE.GALLERY]: 'image', + [VIEW_TYPE.FACE_RECOGNITION]: 'face-recognition-view', 'image': 'image' }; @@ -36,6 +38,7 @@ export const VIEW_TYPE_DEFAULT_BASIC_FILTER = { filter_term: 'picture' } ], + [VIEW_TYPE.FACE_RECOGNITION]: [], }; export const VIEW_TYPE_DEFAULT_SORTS = { diff --git a/frontend/src/metadata/context.js b/frontend/src/metadata/context.js index 48593cb04c..c242c8e182 100644 --- a/frontend/src/metadata/context.js +++ b/frontend/src/metadata/context.js @@ -1,6 +1,8 @@ import metadataAPI from './api'; import { PRIVATE_COLUMN_KEYS, EDITABLE_DATA_PRIVATE_COLUMN_KEYS, EDITABLE_PRIVATE_COLUMN_KEYS, DELETABLE_PRIVATE_COLUMN_KEY, + FACE_RECOGNITION_VIEW_ID, + VIEW_TYPE, } from './constants'; import LocalStorage from './utils/local-storage'; import EventBus from '../components/common/event-bus'; @@ -71,8 +73,14 @@ class Context { // metadata getMetadata = (params) => { + if (!this.metadataAPI) return null; const repoID = this.settings['repoID']; - return this.metadataAPI ? this.metadataAPI.getMetadata(repoID, params) : null; + const { view_id, start, limit } = params; + if (view_id === FACE_RECOGNITION_VIEW_ID) { + return this.metadataAPI.getFaceData(repoID, start, limit); + } + + return this.metadataAPI.getMetadata(repoID, params); }; getRecord = (parentDir, fileName) => { @@ -86,6 +94,17 @@ class Context { }; getView = (viewId) => { + if (viewId === FACE_RECOGNITION_VIEW_ID) { + return { + data: { + view: { + _id: FACE_RECOGNITION_VIEW_ID, + type: VIEW_TYPE.FACE_RECOGNITION, + } + } + }; + } + const repoID = this.settings['repoID']; return this.metadataAPI.getView(repoID, viewId); }; @@ -223,6 +242,18 @@ class Context { const repoID = this.settings['repoID']; return this.metadataAPI.extractFileDetails(repoID, objIds); }; + + // face api + renamePeople = (recordId, name) => { + const repoID = this.settings['repoID']; + return this.metadataAPI.renamePeople(repoID, recordId, name); + }; + + getPeoplePhotos = (recordId, start, limit) => { + const repoID = this.settings['repoID']; + return this.metadataAPI.getPeoplePhotos(repoID, recordId, start, limit); + }; + } export default Context; diff --git a/frontend/src/metadata/hooks/metadata-view.js b/frontend/src/metadata/hooks/metadata-view.js index 0fc8f31cf5..1740342ac6 100644 --- a/frontend/src/metadata/hooks/metadata-view.js +++ b/frontend/src/metadata/hooks/metadata-view.js @@ -127,6 +127,7 @@ export const MetadataViewProvider = ({ deleteFilesCallback: params.deleteFilesCallback, renameFileCallback: params.renameFileCallback, updateCurrentDirent: params.updateCurrentDirent, + closeDirentDetail: params.closeDirentDetail, }} > {children} diff --git a/frontend/src/metadata/hooks/metadata.js b/frontend/src/metadata/hooks/metadata.js index 2c74897c04..a9dfc1e1a5 100644 --- a/frontend/src/metadata/hooks/metadata.js +++ b/frontend/src/metadata/hooks/metadata.js @@ -4,7 +4,7 @@ import { Utils } from '../../utils/utils'; import toaster from '../../components/toast'; import { gettext } from '../../utils/constants'; import { PRIVATE_FILE_TYPE } from '../../constants'; -import { FACE_RECOGNITION_VIEW_ID } from '../constants'; +import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE } from '../constants'; // This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc. const MetadataContext = React.createContext(null); @@ -87,7 +87,7 @@ export const MetadataProvider = ({ repoID, currentRepoInfo, hideMetadataView, se viewsMap.current[FACE_RECOGNITION_VIEW_ID] = { _id: FACE_RECOGNITION_VIEW_ID, name: gettext('Photos - classfied by people'), - type: PRIVATE_FILE_TYPE.FACE_RECOGNITION, + type: VIEW_TYPE.FACE_RECOGNITION, }; setNavigation(navigation); }).catch(error => { diff --git a/frontend/src/metadata/metadata-tree-view/index.js b/frontend/src/metadata/metadata-tree-view/index.js index e16ee47d25..045d322266 100644 --- a/frontend/src/metadata/metadata-tree-view/index.js +++ b/frontend/src/metadata/metadata-tree-view/index.js @@ -15,16 +15,20 @@ import { isEnter } from '../utils/hotkey'; import './index.css'; -const updateFavicon = (iconName) => { +const updateFavicon = (type) => { const favicon = document.getElementById('favicon'); if (favicon) { - switch (iconName) { + switch (type) { + case VIEW_TYPE.GALLERY: case 'image': favicon.href = `${mediaUrl}favicons/gallery.png`; break; case VIEW_TYPE.TABLE: favicon.href = `${mediaUrl}favicons/table.png`; break; + case VIEW_TYPE.FACE_RECOGNITION: + favicon.href = `${mediaUrl}favicons/face-recognition-view.png`; + break; default: favicon.href = `${mediaUrl}favicons/favicon.png`; } @@ -70,7 +74,7 @@ const MetadataTreeView = ({ userPerm, currentPath }) => { if (lastOpenedView) { selectView(lastOpenedView); document.title = `${lastOpenedView.name} - Seafile`; - updateFavicon(VIEW_TYPE_ICON[lastOpenedView.type] || VIEW_TYPE.TABLE); + updateFavicon(lastOpenedView.type); return; } const url = `${origin}${pathname}`; @@ -82,7 +86,7 @@ const MetadataTreeView = ({ userPerm, currentPath }) => { if (showFirstView && firstView) { selectView(firstView); document.title = `${firstView.name} - Seafile`; - updateFavicon(VIEW_TYPE_ICON[firstView.type] || VIEW_TYPE.TABLE); + updateFavicon(firstView.type); } else { document.title = originalTitle; updateFavicon('default'); @@ -95,7 +99,7 @@ const MetadataTreeView = ({ userPerm, currentPath }) => { const currentView = viewsMap[currentViewId]; if (currentView) { document.title = `${currentView.name} - Seafile`; - updateFavicon(VIEW_TYPE_ICON[currentView.type] || VIEW_TYPE.TABLE); + updateFavicon(currentView.type); } else { document.title = originalTitle; updateFavicon('default'); diff --git a/frontend/src/metadata/metadata-view/view.js b/frontend/src/metadata/metadata-view/view.js index 8ef8892a80..dd62d399fb 100644 --- a/frontend/src/metadata/metadata-view/view.js +++ b/frontend/src/metadata/metadata-view/view.js @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; import Table from '../views/table'; import Gallery from '../views/gallery'; +import FaceRecognition from '../views/face-recognition'; import { useMetadataView } from '../hooks/metadata-view'; import { gettext } from '../../utils/constants'; import { VIEW_TYPE } from '../constants'; @@ -10,7 +11,7 @@ const View = () => { const { isLoading, metadata, errorMsg } = useMetadataView(); const renderView = useCallback((metadata) => { - if (!metadata) return false; + if (!metadata) return null; const viewType = metadata.view.type; switch (viewType) { case VIEW_TYPE.GALLERY: { @@ -19,6 +20,9 @@ const View = () => { case VIEW_TYPE.TABLE: { return ; } + case VIEW_TYPE.FACE_RECOGNITION: { + return (); + } default: return null; } diff --git a/frontend/src/metadata/store/index.js b/frontend/src/metadata/store/index.js index 2685117f8c..610b28a171 100644 --- a/frontend/src/metadata/store/index.js +++ b/frontend/src/metadata/store/index.js @@ -528,6 +528,22 @@ class Store { return this.data.rows.some((row) => newPath === Utils.joinPath(row._parent_dir, row._name)); }; + renamePeopleName = (peopleId, newName, oldName) => { + const type = OPERATION_TYPE.RENAME_PEOPLE_NAME; + const operation = this.createOperation({ + type, repo_id: this.repoId, people_id: peopleId, new_name: newName, old_name: oldName + }); + this.applyOperation(operation); + }; + + deletePeoplePhotos = (peopleId, deletedPhotos) => { + const type = OPERATION_TYPE.DELETE_PEOPLE_PHOTOS; + const operation = this.createOperation({ + type, repo_id: this.repoId, people_id: peopleId, deleted_photos: deletedPhotos + }); + this.applyOperation(operation); + }; + } export default Store; diff --git a/frontend/src/metadata/store/operations/apply.js b/frontend/src/metadata/store/operations/apply.js index 9bd97bc0a2..e3e4528854 100644 --- a/frontend/src/metadata/store/operations/apply.js +++ b/frontend/src/metadata/store/operations/apply.js @@ -184,6 +184,44 @@ export default function apply(data, operation) { data.view = new View({ ...data.view, columns_keys: new_columns_keys }, data.columns); return data; } + // face table op + case OPERATION_TYPE.RENAME_PEOPLE_NAME: { + const { people_id, new_name } = operation; + const { rows } = data; + let updatedRows = [...rows]; + rows.forEach((row, index) => { + const { _id: rowId } = row; + if (rowId === people_id) { + const updatedRow = Object.assign({}, row, { _name: new_name }); + updatedRows[index] = updatedRow; + data.id_row_map[rowId] = updatedRow; + } + }); + data.rows = updatedRows; + return data; + } + case OPERATION_TYPE.DELETE_PEOPLE_PHOTOS: { + const { people_id, deleted_photos } = operation; + const { rows } = data; + const idNeedDeletedMap = deleted_photos.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {}); + let updatedRows = [...rows]; + rows.forEach((row, index) => { + const { _id: rowId, _photo_links: photoLinks } = row; + if (rowId === people_id) { + const updatedRow = Object.assign({}, row, { _photo_links: photoLinks.filter(p => !idNeedDeletedMap[p.row_id]) }); + if (updatedRow._photo_links.length === 0) { + updatedRows.splice(index, 1); + delete data.id_row_map[rowId]; + } else { + updatedRows[index] = updatedRow; + data.id_row_map[rowId] = updatedRow; + } + } + }); + data.rows = updatedRows; + data.recordsCount = updatedRows.length; + return data; + } default: { return data; } diff --git a/frontend/src/metadata/store/operations/constants.js b/frontend/src/metadata/store/operations/constants.js index 425425b33a..5115b0f9f1 100644 --- a/frontend/src/metadata/store/operations/constants.js +++ b/frontend/src/metadata/store/operations/constants.js @@ -17,6 +17,10 @@ export const OPERATION_TYPE = { MODIFY_COLUMN_DATA: 'modify_column_data', MODIFY_COLUMN_WIDTH: 'modify_column_width', MODIFY_COLUMN_ORDER: 'modify_column_order', + + // face table + RENAME_PEOPLE_NAME: 'rename_people_name', + DELETE_PEOPLE_PHOTOS: 'delete_people_photos', }; export const COLUMN_DATA_OPERATION_TYPE = { @@ -45,6 +49,8 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.DELETE_COLUMN]: ['repo_id', 'column_key', 'column'], [OPERATION_TYPE.MODIFY_COLUMN_WIDTH]: ['column_key', 'new_width', 'old_width'], [OPERATION_TYPE.MODIFY_COLUMN_ORDER]: ['repo_id', 'view_id', 'new_columns_keys', 'old_columns_keys'], + [OPERATION_TYPE.RENAME_PEOPLE_NAME]: ['repo_id', 'people_id', 'new_name', 'old_name'], + [OPERATION_TYPE.DELETE_PEOPLE_PHOTOS]: ['repo_id', 'people_id', 'deleted_photos'], }; export const UNDO_OPERATION_TYPE = [ diff --git a/frontend/src/metadata/store/server-operator.js b/frontend/src/metadata/store/server-operator.js index 60b3bfffc8..f8961e1913 100644 --- a/frontend/src/metadata/store/server-operator.js +++ b/frontend/src/metadata/store/server-operator.js @@ -174,6 +174,17 @@ class ServerOperator { }); break; } + + // face table op + case OPERATION_TYPE.RENAME_PEOPLE_NAME: { + const { people_id, new_name } = operation; + window.sfMetadataContext.renamePeople(people_id, new_name).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: gettext('Failed to modify people name') }); + }); + break; + } default: { break; } diff --git a/frontend/src/metadata/views/face-recognition/face-group.js b/frontend/src/metadata/views/face-recognition/face-group.js deleted file mode 100644 index 46691db18b..0000000000 --- a/frontend/src/metadata/views/face-recognition/face-group.js +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useCallback, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; -import dayjs from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; -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'; - -dayjs.extend(relativeTime); - -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)}{dayjs(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 index 2c4fa763d0..d810532f1d 100644 --- a/frontend/src/metadata/views/face-recognition/index.css +++ b/frontend/src/metadata/views/face-recognition/index.css @@ -1,29 +1,7 @@ -.sf-metadata-face-recognition { +.sf-metadata-face-recognition-container { 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 index 15b41c0c9e..ce51022f43 100644 --- a/frontend/src/metadata/views/face-recognition/index.js +++ b/frontend/src/metadata/views/face-recognition/index.js @@ -1,161 +1,48 @@ -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 React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useMetadataView } from '../../hooks/metadata-view'; +import Peoples from './peoples'; +import PeoplePhotos from './person-photos'; import './index.css'; -const LIMIT = 1000; +const FaceRecognition = () => { + const [showPeopleFaces, setShowPeopleFaces] = useState(false); + const peopleRef = useRef(null); -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 { metadata, store } = useMetadataView(); - 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 peoples = useMemo(() => { + if (!Array.isArray(metadata.rows) || metadata.rows.length === 0) return []; + return metadata.rows; + }, [metadata]); - const imageItems = useMemo(() => { - return faceData.map(group => group.photos).flat(); - }, [faceData]); + const onDeletePeoplePhotos = useCallback((peopleId, peoplePhotos) => { + store.deletePeoplePhotos(peopleId, peoplePhotos); + }, [store]); - 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 openPeople = useCallback((people) => { + peopleRef.current = people; + setShowPeopleFaces(true); }, []); - 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 closePeople = useCallback(() => { + peopleRef.current = null; + setShowPeopleFaces(false); }, []); - 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 (); - } + const onRename = useCallback((id, newName, oldName) => { + store.renamePeopleName(id, newName, oldName); + }, [store]); return ( - <> -
-
-
-
- {faceData.length > 0 && faceData.map((face) => { - return (); - })} - {isLoadingMore && ( -
- -
- )} -
-
-
-
- {isImagePopupOpen && ( - - - +
+ {showPeopleFaces ? ( + + ) : ( + )} - +
); }; -FaceRecognition.propTypes = { - repoID: PropTypes.string.isRequired, -}; - export default FaceRecognition; diff --git a/frontend/src/metadata/views/face-recognition/peoples/index.css b/frontend/src/metadata/views/face-recognition/peoples/index.css new file mode 100644 index 0000000000..e4929feefd --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/peoples/index.css @@ -0,0 +1,17 @@ +/* loading more */ +.sf-metadata-face-recognition-container .sf-metadata-face-recognition-loading-more { + height: 30px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.sf-metadata-peoples-container { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + height: fit-content !important; + max-height: 100%; +} diff --git a/frontend/src/metadata/views/face-recognition/peoples/index.js b/frontend/src/metadata/views/face-recognition/peoples/index.js new file mode 100644 index 0000000000..2c2cb37e5c --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/peoples/index.js @@ -0,0 +1,103 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; +import EmptyTip from '../../../../components/empty-tip'; +import { gettext } from '../../../../utils/constants'; +import { useMetadataView } from '../../../hooks/metadata-view'; +import { PER_LOAD_NUMBER } from '../../../constants'; +import toaster from '../../../../components/toast'; +import { Utils } from '../../../../utils/utils'; +import People from './people'; + +import './index.css'; + +const Peoples = ({ peoples, onOpenPeople, onRename }) => { + const [isLoadingMore, setLoadingMore] = useState(false); + const [haveFreezed, setHaveFreezed] = useState(false); + + const containerRef = useRef(null); + + const { metadata, store, closeDirentDetail } = useMetadataView(); + + const loadMore = useCallback(async () => { + if (isLoadingMore) return; + if (!metadata.hasMore) return; + setLoadingMore(true); + + try { + await store.loadMore(PER_LOAD_NUMBER); + setLoadingMore(false); + } catch (error) { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + setLoadingMore(false); + return; + } + + }, [isLoadingMore, metadata, store]); + + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + if (scrollTop + clientHeight >= scrollHeight - 10) { + loadMore(); + } + window.sfMetadataContext.localStorage.setItem('scroll_top', scrollTop); + }, [loadMore]); + + const onFreezed = useCallback(() => { + setHaveFreezed(true); + }, []); + + const onUnFreezed = useCallback(() => { + setHaveFreezed(false); + }, []); + + useEffect(() => { + const _localStorage = window.sfMetadataContext.localStorage; + if (!containerRef.current) return; + const scrollTop = _localStorage.getItem('scroll_top') || 0; + if (scrollTop) { + containerRef.current.scrollTop = Number(scrollTop); + } + return () => {}; + }, []); + + useEffect(() => { + closeDirentDetail(); + return () => {}; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!Array.isArray(peoples) || peoples.length === 0) return (); + + return ( +
+ {peoples.length > 0 && peoples.map((people) => { + return ( + + ); + })} + {isLoadingMore && ( +
+ +
+ )} +
+ ); +}; + +Peoples.propTypes = { + peoples: PropTypes.array, + onOpenPeople: PropTypes.func, +}; + +export default Peoples; diff --git a/frontend/src/metadata/views/face-recognition/peoples/people/index.css b/frontend/src/metadata/views/face-recognition/peoples/people/index.css new file mode 100644 index 0000000000..a6050c98d2 --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/peoples/people/index.css @@ -0,0 +1,75 @@ +.sf-metadata-peoples-container .sf-metadata-people-info { + height: 56px; + width: 48%; + flex-shrink: 0; + overflow: visible; +} + +.sf-metadata-peoples-container .sf-metadata-people-info:hover { + background: #f5f5f5; +} + +.sf-metadata-peoples-container .sf-metadata-people-info:not(.readonly) { + cursor: pointer; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-img { + height: 36px; + width: 36px; + overflow: hidden; + border-radius: 50%; + flex-shrink: 0; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-name-count { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 6px 0; + overflow: hidden; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-name { + width: 100%; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-name-display { + height: 100%; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-renaming { + box-sizing: border-box; + padding: 2px 3px; + width: 20rem; + max-width: 100%; + height: 22px; + line-height: 16px; + border-radius: 2px; + word-wrap: break-word; + vertical-align: middle; + border: 1px solid #ccc; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-renaming:focus { + border-color: #1991eb; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-count { + font-size: 12px; + color: #666; +} + +.sf-metadata-peoples-container .sf-metadata-people-info .sf-metadata-people-info-op { + width: 24px; + flex-shrink: 0; +} + +.sf-metadata-peoples-container .sf-metadata-people-info-op .sf-dropdown-toggle { + line-height: 1.5; +} diff --git a/frontend/src/metadata/views/face-recognition/peoples/people/index.js b/frontend/src/metadata/views/face-recognition/peoples/people/index.js new file mode 100644 index 0000000000..6b3d921940 --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/peoples/people/index.js @@ -0,0 +1,124 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Input } from 'reactstrap'; +import classNames from 'classnames'; +import isHotkey from 'is-hotkey'; +import { gettext, siteRoot, thumbnailDefaultSize } from '../../../../../utils/constants'; +import OpMenu from './op-menu'; +import { isEnter } from '../../../../utils/hotkey'; +import { Utils } from '../../../../../utils/utils'; +import { getFileNameFromRecord, getParentDirFromRecord } from '../../../../utils/cell'; + +import './index.css'; + +const People = ({ haveFreezed, people, onOpenPeople, onRename, onFreezed, onUnFreezed }) => { + + const similarPhotoURL = useMemo(() => { + const similarPhoto = people._similar_photo; + if (!similarPhoto) return ''; + const repoID = window.sfMetadataContext.getSetting('repoID'); + const fileName = getFileNameFromRecord(similarPhoto); + const parentDir = getParentDirFromRecord(similarPhoto); + const path = Utils.encodePath(Utils.joinPath(parentDir, fileName)); + return `${siteRoot}thumbnail/${repoID}/${thumbnailDefaultSize}${path}`; + }, [people._similar_photo]); + + const photosCount = useMemo(() => { + return Array.isArray(people._photo_links) ? people._photo_links.length : 0; + }, [people._photo_links]); + + const [name, setName] = useState(people._name || gettext('Person image')); + const [renaming, setRenaming] = useState(false); + const [active, setActive] = useState(false); + const readonly = !window.sfMetadataContext.canModify(); + + const onMouseEnter = useCallback(() => { + if (haveFreezed) return; + setActive(true); + }, [haveFreezed]); + + const onMouseLeave = useCallback(() => { + if (haveFreezed) return; + setActive(false); + }, [haveFreezed]); + + const setRenamingState = useCallback(() => { + onFreezed(); + setRenaming(true); + }, [onFreezed]); + + const onBlur = useCallback(() => { + if (!name) return; + setRenaming(false); + if (name !== (people._name || gettext('Person image'))) { + onUnFreezed(); + onRename(people._id, name, people._name); + } + }, [people, name, onRename, onUnFreezed]); + + const onChange = useCallback((event) => { + const value = event.target.value; + if (value === name) return; + setName(value); + }, [name]); + + const onKeyDown = useCallback((event) => { + if (isEnter(event)) { + onBlur(); + return; + } else if (isHotkey('esc', event)) { + setName(people.name); + setRenaming(false); + return; + } + }, [people, onBlur]); + + const _onUnFreezed = useCallback(() => { + onUnFreezed(); + setActive(false); + }, [onUnFreezed]); + + return ( +
{} : () => onOpenPeople(people)} + > +
+ {name} +
+
+
+ {renaming ? ( + + ) : ( +
{name}
+ )} +
+
+ {photosCount + ' ' + gettext('items')} +
+
+ {!readonly && people._is_someone && ( +
+ {active && ( + + )} +
+ )} +
+ ); +}; + +People.propTypes = { + haveFreezed: PropTypes.bool, + people: PropTypes.object.isRequired, + onOpenPeople: PropTypes.func, + onFreezed: PropTypes.func, + onUnFreezed: PropTypes.func, +}; + +export default People; diff --git a/frontend/src/metadata/views/face-recognition/peoples/people/op-menu/index.js b/frontend/src/metadata/views/face-recognition/peoples/people/op-menu/index.js new file mode 100644 index 0000000000..f7728bf7d1 --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/peoples/people/op-menu/index.js @@ -0,0 +1,65 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, DropdownMenu, DropdownToggle, DropdownItem } from 'reactstrap'; +import { gettext } from '../../../../../../utils/constants'; + +const OpMenu = ({ onRename, onFreezed, onUnFreezed }) => { + let [isShow, setShow] = useState(false); + + const toggle = useCallback((event) => { + const dataset = event.target ? event.target.dataset : null; + if (dataset && dataset.toggle && dataset.toggle === 'rename') { + onRename(); + setShow(!isShow); + return; + } + if (isShow) { + onUnFreezed(); + } else { + onFreezed(); + } + setShow(!isShow); + }, [isShow, onRename, onFreezed, onUnFreezed, setShow]); + + const onClick = useCallback((event) => { + toggle(event); + }, [toggle]); + + const onItemClick = useCallback((event) => { + toggle(event); + }, [toggle]); + + useEffect(() => { + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + setShow = () => {}; + }; + }, []); + + return ( + + + + {gettext('Rename')} + + + ); +}; + +OpMenu.propTypes = { + onRename: PropTypes.func, + onFreezed: PropTypes.func, + onUnFreezed: PropTypes.func, +}; + +export default OpMenu; + diff --git a/frontend/src/metadata/views/face-recognition/person-photos/index.css b/frontend/src/metadata/views/face-recognition/person-photos/index.css new file mode 100644 index 0000000000..53c51897cc --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/person-photos/index.css @@ -0,0 +1,56 @@ +.sf-metadata-people-photos-container { + padding: 0 !important; + overflow-y: hidden !important; +} + +.sf-metadata-people-photos-container .sf-metadata-people-photos-header { + height: 48px; + display: flex; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid #eee; +} + +.sf-metadata-people-photos-container .sf-metadata-icon-btn { + margin-left: -4px; + border-radius: 3px; +} + +.sf-metadata-people-photos-container .sf-metadata-icon-btn:hover { + background-color: #EFEFEF; + cursor: pointer; +} + +.sf-metadata-people-photos-container .sf-metadata-people-photos-header .sf-metadata-people-photos-header-back { + font-size: 14px; + height: 24px; + min-width: 24px; + display: flex; + align-items: center; + justify-content: center; + margin-left: -5px; + border-radius: 3px; +} + +.sf-metadata-people-photos-container .sf-metadata-people-photos-header .sf-metadata-people-photos-header-back:hover { + background-color: #DBDBDB; + background-color: #EFEFEF; + cursor: pointer; +} + +.sf-metadata-people-photos-container .sf-metadata-people-photos-header-back .sf3-font-arrow { + color: #666; + font-size: 14px !important; +} + +.sf-metadata-people-photos-container .sf-metadata-people-photos-header .sf-metadata-people-name { + margin-left: 4px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sf-metadata-people-photos-container .sf-metadata-gallery-container { + height: calc(100% - 48px); +} diff --git a/frontend/src/metadata/views/face-recognition/person-photos/index.js b/frontend/src/metadata/views/face-recognition/person-photos/index.js new file mode 100644 index 0000000000..aa648a9f1e --- /dev/null +++ b/frontend/src/metadata/views/face-recognition/person-photos/index.js @@ -0,0 +1,153 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import deepCopy from 'deep-copy'; +import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; +import metadataAPI from '../../../api'; +import Metadata from '../../../model/metadata'; +import { normalizeColumns } from '../../../utils/column'; +import { gettext } from '../../../../utils/constants'; +import { Utils } from '../../../../utils/utils'; +import toaster from '../../../../components/toast'; +import Gallery from '../../gallery/main'; +import { useMetadataView } from '../../../hooks/metadata-view'; +import { PER_LOAD_NUMBER, VIEW_TYPE, VIEW_TYPE_DEFAULT_SORTS, EVENT_BUS_TYPE } from '../../../constants'; + +import './index.css'; +import '../../gallery/index.css'; + +const PeoplePhotos = ({ people, onClose, onDeletePeoplePhotos }) => { + const [isLoading, setLoading] = useState(true); + const [isLoadingMore, setLoadingMore] = useState(false); + const [metadata, setMetadata] = useState({ rows: [] }); + const repoID = window.sfMetadataContext.getSetting('repoID'); + + const { deleteFilesCallback } = useMetadataView(); + + const onLoadMore = useCallback(async () => { + if (isLoadingMore) return; + if (!metadata.hasMore) return; + setLoadingMore(true); + + metadataAPI.getPeoplePhotos(repoID, people._id, metadata.recordsCount, PER_LOAD_NUMBER).then(res => { + const rows = res?.data?.results || []; + let newMetadata = deepCopy(metadata); + if (Array.isArray(rows) && rows.length > 0) { + newMetadata.rows.push(...rows); + rows.forEach(record => { + newMetadata.row_ids.push(record._id); + newMetadata.id_row_map[record._id] = record; + }); + const loadedCount = rows.length; + newMetadata.hasMore = loadedCount === PER_LOAD_NUMBER; + newMetadata.recordsCount = newMetadata.row_ids.length; + } else { + newMetadata.hasMore = false; + } + setMetadata(newMetadata); + setLoadingMore(false); + }).catch(error => { + const errorMessage = Utils.getErrorMsg(error); + toaster.danger(errorMessage); + setLoadingMore(false); + }); + + }, [isLoadingMore, metadata, people, repoID]); + + const deletedByIds = useCallback((ids) => { + if (!Array.isArray(ids) || ids.length === 0) return; + const newMetadata = deepCopy(metadata); + const idNeedDeletedMap = ids.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {}); + newMetadata.rows = newMetadata.rows.filter((row) => !idNeedDeletedMap[row._id]); + newMetadata.row_ids = newMetadata.row_ids.filter((id) => !idNeedDeletedMap[id]); + + // delete rows in id_row_map + ids.forEach(rowId => { + delete newMetadata.id_row_map[rowId]; + }); + newMetadata.recordsCount = newMetadata.row_ids.length; + setMetadata(newMetadata); + + if (newMetadata.rows.length === 0) { + onClose && onClose(); + } + onDeletePeoplePhotos && onDeletePeoplePhotos(people._id, ids); + }, [metadata, onClose, people, onDeletePeoplePhotos]); + + const handelDelete = useCallback((deletedImages, callback) => { + if (!deletedImages.length) return; + let recordIds = []; + let paths = []; + let fileNames = []; + deletedImages.forEach((record) => { + const { id, path: parentDir, name } = record || {}; + if (parentDir && name) { + const path = Utils.joinPath(parentDir, name); + paths.push(path); + fileNames.push(name); + recordIds.push(id); + } + }); + window.sfMetadataContext.batchDeleteFiles(repoID, paths).then(res => { + callback && callback(); + deletedByIds(recordIds); + deleteFilesCallback(paths, fileNames); + let msg = fileNames.length > 1 + ? gettext('Successfully deleted {name} and {n} other items') + : gettext('Successfully deleted {name}'); + msg = msg.replace('{name}', fileNames[0]) + .replace('{n}', fileNames.length - 1); + toaster.success(msg); + }).catch(error => { + toaster.danger(gettext('Failed to delete records')); + }); + }, [deleteFilesCallback, repoID, deletedByIds]); + + useEffect(() => { + const repoID = window.sfMetadataContext.getSetting('repoID'); + metadataAPI.getPeoplePhotos(repoID, people._id, 0, PER_LOAD_NUMBER).then(res => { + const rows = res?.data?.results || []; + const columns = normalizeColumns(res?.data?.metadata); + let metadata = new Metadata({ rows, columns, view: { sorts: VIEW_TYPE_DEFAULT_SORTS[VIEW_TYPE.GALLERY] } }); + if (rows.length < PER_LOAD_NUMBER) { + metadata.hasMore = false; + } + setMetadata(metadata); + setLoading(false); + }).catch(error => { + const errorMessage = Utils.getErrorMsg(error); + toaster.danger(errorMessage); + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_VIEW_TOOLBAR, true); + return () => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.TOGGLE_VIEW_TOOLBAR, false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (isLoading) return (); + + return ( +
+
+
+ +
+
{people._name || gettext('Person image')}
+
+ +
+ ); +}; + +PeoplePhotos.propTypes = { + people: PropTypes.object, + onClose: PropTypes.func, + onDelete: PropTypes.func, +}; + +export default PeoplePhotos; diff --git a/frontend/src/metadata/views/gallery/content.js b/frontend/src/metadata/views/gallery/content.js new file mode 100644 index 0000000000..05dd419247 --- /dev/null +++ b/frontend/src/metadata/views/gallery/content.js @@ -0,0 +1,197 @@ +import React, { useState, useCallback, useMemo, useRef } from 'react'; +import PropTypes from 'prop-types'; +import EmptyTip from '../../../components/empty-tip'; +import { gettext } from '../../../utils/constants'; +import { GALLERY_DATE_MODE } from '../../constants'; +import Image from './image'; + +const Content = ({ + groups, + overScan, + columns, + size, + gap, + mode, + selectedImages, + onImageSelect, + onImageClick, + onImageDoubleClick, + onImageRightClick +}) => { + const containerRef = useRef(null); + const imageRef = useRef(null); + const animationFrameRef = useRef(null); + + const [isSelecting, setIsSelecting] = useState(false); + const [selectionStart, setSelectionStart] = useState(null); + + const imageHeight = useMemo(() => size + gap, [size, gap]); + const selectedImageIds = useMemo(() => selectedImages.map(img => img.id), [selectedImages]); + + const handleMouseDown = useCallback((e) => { + if (e.button !== 0) return; + if (e.ctrlKey || e.metaKey || e.shiftKey) return; + + setIsSelecting(true); + setSelectionStart({ x: e.clientX, y: e.clientY }); + }, []); + + const handleMouseMove = useCallback((e) => { + if (!isSelecting) return; + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + animationFrameRef.current = requestAnimationFrame(() => { + e.preventDefault(); + e.stopPropagation(); + + const selectionEnd = { x: e.clientX, y: e.clientY }; + const selected = []; + + groups.forEach(group => { + group.children.forEach((row) => { + row.children.forEach((img) => { + const imgElement = document.getElementById(img.id); + if (imgElement) { + const rect = imgElement.getBoundingClientRect(); + if ( + rect.left < Math.max(selectionStart.x, selectionEnd.x) && + rect.right > Math.min(selectionStart.x, selectionEnd.x) && + rect.top < Math.max(selectionStart.y, selectionEnd.y) && + rect.bottom > Math.min(selectionStart.y, selectionEnd.y) + ) { + selected.push(img); + } + } + }); + }); + }); + + onImageSelect(selected); + }); + }, [groups, isSelecting, selectionStart, onImageSelect]); + + const handleMouseUp = useCallback((e) => { + if (e.button !== 0) return; + + e.preventDefault(); + e.stopPropagation(); + setIsSelecting(false); + }, []); + + const renderDisplayGroup = useCallback((group) => { + const { top: overScanTop, bottom: overScanBottom } = overScan; + const { name, children, height, top, paddingTop } = group; + + // group not in rendering area, return empty div + if (top >= overScanBottom || top + height <= overScanTop) { + return (
); + } + + let childrenStartIndex = children.findIndex(r => r.top >= overScanTop); + let childrenEndIndex = children.findIndex(r => r.top >= overScanBottom); + + // group in rendering area, but the image not need to render. eg: overScan: { top: 488, bottom: 1100 }, group: { top: 0, height: 521 }, + // in this time, part of an image is in the rendering area, don't render image + if (childrenStartIndex === -1 && childrenEndIndex === -1) { + return (
); + } + + childrenStartIndex = Math.max(childrenStartIndex, 0); + if (childrenEndIndex === -1) { + childrenEndIndex = children.length; + } + if (childrenEndIndex > 0) { + childrenEndIndex = childrenEndIndex - 1; + } + + return ( +
+ {mode !== GALLERY_DATE_MODE.ALL && childrenStartIndex === 0 && ( +
{name || gettext('Empty')}
+ )} +
+ {children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => { + return row.children.map((img) => { + const isSelected = selectedImageIds.includes(img.id); + return ( + onImageClick(e, img)} + onDoubleClick={(e) => onImageDoubleClick(e, img)} + onContextMenu={(e) => onImageRightClick(e, img)} + /> + ); + }); + })} +
+
+ ); + }, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onImageRightClick]); + + if (!Array.isArray(groups) || groups.length === 0) { + return ; + } + + return ( +
+ {groups.map((group) => { + return renderDisplayGroup(group); + })} +
+ ); +}; + +Content.propTypes = { + groups: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.isRequired, + children: PropTypes.arrayOf(PropTypes.shape({ + top: PropTypes.number.isRequired, + children: PropTypes.arrayOf(PropTypes.shape({ + src: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + })).isRequired, + })).isRequired, + height: PropTypes.number.isRequired, + top: PropTypes.number.isRequired, + paddingTop: PropTypes.number.isRequired, + })), + overScan: PropTypes.shape({ + top: PropTypes.number.isRequired, + bottom: PropTypes.number.isRequired, + }).isRequired, + columns: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + gap: PropTypes.number.isRequired, + mode: PropTypes.string, + selectedImages: PropTypes.array.isRequired, + onImageSelect: PropTypes.func.isRequired, + onImageClick: PropTypes.func.isRequired, + onImageDoubleClick: PropTypes.func.isRequired, + onImageRightClick: PropTypes.func.isRequired, +}; + +export default Content; diff --git a/frontend/src/metadata/views/gallery/index.js b/frontend/src/metadata/views/gallery/index.js index af741a5633..82378589d7 100644 --- a/frontend/src/metadata/views/gallery/index.js +++ b/frontend/src/metadata/views/gallery/index.js @@ -1,135 +1,19 @@ -import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; -import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; -import metadataAPI from '../../api'; -import URLDecorator from '../../../utils/url-decorator'; +import React, { useState, useCallback } from 'react'; import toaster from '../../../components/toast'; import Main from './main'; -import ContextMenu from './context-menu'; -import ImageDialog from '../../../components/dialog/image-dialog'; -import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog'; -import ModalPortal from '../../../components/modal-portal'; import { useMetadataView } from '../../hooks/metadata-view'; import { Utils } from '../../../utils/utils'; -import { getDateDisplayString, getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell'; -import { siteRoot, fileServerRoot, useGoFileserver, gettext, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants'; -import { EVENT_BUS_TYPE, PER_LOAD_NUMBER, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT, GALLERY_IMAGE_GAP } from '../../constants'; -import { getRowById } from '../../utils/table'; -import { getEventClassName } from '../../utils/common'; +import { gettext } from '../../../utils/constants'; +import { PER_LOAD_NUMBER } from '../../constants'; import './index.css'; const Gallery = () => { - const [isFirstLoading, setFirstLoading] = useState(true); const [isLoadingMore, setLoadingMore] = useState(false); - const [zoomGear, setZoomGear] = useState(0); - const [containerWidth, setContainerWidth] = useState(0); - const [overScan, setOverScan] = useState({ top: 0, bottom: 0 }); - const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY); - const [isImagePopupOpen, setIsImagePopupOpen] = useState(false); - const [isZipDialogOpen, setIsZipDialogOpen] = useState(false); - const [imageIndex, setImageIndex] = useState(0); - const [selectedImages, setSelectedImages] = useState([]); - const containerRef = useRef(null); - const renderMoreTimer = useRef(null); - const lastState = useRef({ visibleAreaFirstImage: { groupIndex: 0, rowIndex: 0 } }); + const { metadata, store, deleteFilesCallback } = useMetadataView(); - const { metadata, store, updateCurrentDirent, deleteFilesCallback } = useMetadataView(); - const repoID = window.sfMetadataContext.getSetting('repoID'); - - useEffect(() => { - updateCurrentDirent(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Number of images per row - const columns = useMemo(() => { - return 8 - zoomGear; - }, [zoomGear]); - - const imageSize = useMemo(() => { - return (containerWidth - (columns - 1) * 2 - 32) / columns; - }, [containerWidth, columns]); - - const dateMode = useMemo(() => { - switch (mode) { - case GALLERY_DATE_MODE.YEAR: - return 'YYYY'; - case GALLERY_DATE_MODE.MONTH: - return 'YYYY-MM'; - case GALLERY_DATE_MODE.DAY: - return 'YYYY-MM-DD'; - default: - return 'YYYY-MM-DD'; - } - }, [mode]); - - const groups = useMemo(() => { - if (isFirstLoading) return []; - const firstSort = metadata.view.sorts[0]; - let init = metadata.rows.filter(row => Utils.imageCheck(getFileNameFromRecord(row))) - .reduce((_init, record) => { - const id = record[PRIVATE_COLUMN_KEY.ID]; - const fileName = getFileNameFromRecord(record); - const parentDir = getParentDirFromRecord(record); - const path = Utils.encodePath(Utils.joinPath(parentDir, fileName)); - const date = mode !== GALLERY_DATE_MODE.ALL ? getDateDisplayString(record[firstSort.column_key], dateMode) : ''; - const img = { - id, - name: fileName, - path: parentDir, - url: `${siteRoot}lib/${repoID}/file${path}`, - src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`, - thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`, - downloadURL: `${fileServerRoot}repos/${repoID}/files${path}?op=download`, - date: date, - }; - let _group = _init.find(g => g.name === date); - if (_group) { - _group.children.push(img); - } else { - _init.push({ - name: date, - children: [img], - }); - } - return _init; - }, []); - - let _groups = []; - const imageHeight = imageSize + GALLERY_IMAGE_GAP; - const paddingTop = mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT; - init.forEach((_init, index) => { - const { children, ...__init } = _init; - let top = 0; - let rows = []; - if (index > 0) { - const lastGroup = _groups[index - 1]; - const { top: lastGroupTop, height: lastGroupHeight } = lastGroup; - top = lastGroupTop + lastGroupHeight; - } - children.forEach((child, childIndex) => { - const rowIndex = ~~(childIndex / columns); - if (!rows[rowIndex]) rows[rowIndex] = { top: paddingTop + top + rowIndex * imageHeight, children: [] }; - child.groupIndex = index; - child.rowIndex = rowIndex; - rows[rowIndex].children.push(child); - }); - - const height = rows.length * imageHeight + paddingTop; - _groups.push({ - ...__init, - top, - height, - paddingTop, - children: rows - }); - }); - return _groups; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize, mode]); - - const loadMore = useCallback(async () => { + const onLoadMore = useCallback(async () => { if (isLoadingMore) return; if (!metadata.hasMore) return; setLoadingMore(true); @@ -146,207 +30,12 @@ const Gallery = () => { }, [isLoadingMore, metadata, store]); - useEffect(() => { - const gear = window.sfMetadataContext.localStorage.getItem('zoom-gear', 0) || 0; - setZoomGear(gear); - - const mode = window.sfMetadataContext.localStorage.getItem('gallery-group-by', GALLERY_DATE_MODE.DAY) || GALLERY_DATE_MODE.DAY; - setMode(mode); - - const switchGalleryModeSubscribe = window.sfMetadataContext.eventBus.subscribe( - EVENT_BUS_TYPE.SWITCH_GALLERY_GROUP_BY, - (mode) => { - setMode(mode); - window.sfMetadataContext.localStorage.setItem('gallery-group-by', mode); - } - ); - - const container = containerRef.current; - if (container) { - const { offsetWidth, clientHeight } = container; - setContainerWidth(offsetWidth); - - // Calculate initial overScan information - const columns = 8 - gear; - const imageSize = (offsetWidth - columns * 2 - 2) / columns; - setOverScan({ top: 0, bottom: clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 2 }); - } - setFirstLoading(false); - - // resize - const handleResize = () => { - if (!container) return; - setContainerWidth(container.offsetWidth); - }; - const resizeObserver = new ResizeObserver(handleResize); - container && resizeObserver.observe(container); - - // op - const modifyGalleryZoomGearSubscribe = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.MODIFY_GALLERY_ZOOM_GEAR, (zoomGear) => { - window.sfMetadataContext.localStorage.setItem('zoom-gear', zoomGear); - setZoomGear(zoomGear); - }); - - return () => { - container && resizeObserver.unobserve(container); - modifyGalleryZoomGearSubscribe(); - switchGalleryModeSubscribe(); - renderMoreTimer.current && clearTimeout(renderMoreTimer.current); - }; - }, []); - - useEffect(() => { - if (!imageSize || imageSize < 0) return; - if (imageSize === lastState.current.imageSize) return; - const perImageOffset = imageSize - lastState.current.imageSize; - const { groupIndex, rowIndex } = lastState.current.visibleAreaFirstImage; - const rowOffset = groups.reduce((previousValue, current, currentIndex) => { - if (currentIndex < groupIndex) { - return previousValue + current.children.length; - } - return previousValue; - }, 0) + rowIndex; - const topOffset = rowOffset * perImageOffset + groupIndex * (mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT); - containerRef.current.scrollTop = containerRef.current.scrollTop + topOffset; - lastState.current = { ...lastState.current, imageSize }; - }, [imageSize, groups, mode]); - - const handleScroll = useCallback(() => { - if (!containerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - if (scrollTop + clientHeight >= scrollHeight - 10) { - loadMore(); - } else { - renderMoreTimer.current && clearTimeout(renderMoreTimer.current); - renderMoreTimer.current = setTimeout(() => { - const { scrollTop, clientHeight } = containerRef.current; - const overScanTop = Math.max(0, scrollTop - (imageSize + GALLERY_IMAGE_GAP) * 3); - const overScanBottom = scrollTop + clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 3; - let groupIndex = 0; - let rowIndex = 0; - let flag = false; - for (let i = 0; i < groups.length; i++) { - const group = groups[i]; - for (let j = 0; j < group.children.length; j++) { - const row = group.children[j]; - if (row.top >= scrollTop) { - groupIndex = i; - rowIndex = j; - flag = true; - } - if (flag) break; - } - if (flag) break; - } - lastState.current = { ...lastState.current, visibleAreaFirstImage: { groupIndex, rowIndex } }; - setOverScan({ top: overScanTop, bottom: overScanBottom }); - renderMoreTimer.current = null; - }, 200); - } - }, [imageSize, loadMore, renderMoreTimer, groups]); - - const imageItems = useMemo(() => { - return groups.flatMap(group => group.children.flatMap(row => row.children)); - }, [groups]); - - const updateSelectedImage = useCallback((image = null) => { - const imageInfo = image ? getRowById(metadata, image.id) : null; - if (!imageInfo) { - updateCurrentDirent(); - return; - } - updateCurrentDirent({ - type: 'file', - name: image.name, - path: image.path, - file_tags: [] - }); - }, [metadata, updateCurrentDirent]); - - const handleClick = useCallback((event, image) => { - if (event.metaKey || event.ctrlKey) { - setSelectedImages(prev => - prev.includes(image) ? prev.filter(img => img !== image) : [...prev, image] - ); - updateSelectedImage(image); - } else if (event.shiftKey && selectedImages.length > 0) { - const lastSelected = selectedImages[selectedImages.length - 1]; - const start = imageItems.indexOf(lastSelected); - const end = imageItems.indexOf(image); - const range = imageItems.slice(Math.min(start, end), Math.max(start, end) + 1); - setSelectedImages(prev => Array.from(new Set([...prev, ...range]))); - updateSelectedImage(null); - } else { - setSelectedImages([image]); - updateSelectedImage(image); - } - }, [imageItems, selectedImages, updateSelectedImage]); - - const handleDoubleClick = useCallback((event, image) => { - const index = imageItems.findIndex(item => item.id === image.id); - setImageIndex(index); - setIsImagePopupOpen(true); - }, [imageItems]); - - const handleRightClick = useCallback((event, image) => { - event.preventDefault(); - const index = imageItems.findIndex(item => item.id === image.id); - if (isNaN(index) || index === -1) return; - - setSelectedImages(prev => prev.length < 2 ? [image] : [...prev]); - }, [imageItems]); - - const moveToPrevImage = () => { - const imageItemsLength = imageItems.length; - setImageIndex((prevState) => (prevState + imageItemsLength - 1) % imageItemsLength); - }; - - const moveToNextImage = () => { - const imageItemsLength = imageItems.length; - setImageIndex((prevState) => (prevState + 1) % imageItemsLength); - }; - - const handleImageSelection = useCallback((selectedImages) => { - setSelectedImages(selectedImages); - }, []); - - const closeImagePopup = () => { - setIsImagePopupOpen(false); - }; - - const handleDownload = useCallback(() => { - if (selectedImages.length) { - if (selectedImages.length === 1) { - const image = selectedImages[0]; - let direntPath = image.path === '/' ? image.name : Utils.joinPath(image.path, image.name); - let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath }); - location.href = url; - } else { - if (!useGoFileserver) { - setIsZipDialogOpen(true); - } else { - const dirents = selectedImages.map(image => { - const value = image.path === '/' ? image.name : `${image.path}/${image.name}`; - return value; - }); - metadataAPI.zipDownload(repoID, '/', dirents).then((res) => { - const zipToken = res.data['zip_token']; - location.href = `${fileServerRoot}zip/${zipToken}`; - }).catch(error => { - const errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - } - } - }, [repoID, selectedImages]); - - const handleDelete = useCallback(() => { - if (!selectedImages.length) return; + const handleDelete = useCallback((deletedImages, callback) => { + if (!deletedImages.length) return; let recordsIds = []; let paths = []; let fileNames = []; - selectedImages.forEach((record) => { + deletedImages.forEach((record) => { const { path: parentDir, name } = record || {}; if (parentDir && name) { const path = Utils.joinPath(parentDir, name); @@ -360,7 +49,7 @@ const Gallery = () => { toaster.danger(error); }, success_callback: () => { - setSelectedImages([]); + callback && callback(); deleteFilesCallback(paths, fileNames); let msg = fileNames.length > 1 ? gettext('Successfully deleted {name} and {n} other items') @@ -370,75 +59,11 @@ const Gallery = () => { toaster.success(msg); }, }); - }, [selectedImages, store, deleteFilesCallback]); - - const closeZipDialog = () => { - setIsZipDialogOpen(false); - }; - - const handleClickOutside = useCallback((event) => { - const className = getEventClassName(event); - const isClickInsideImage = className.includes('metadata-gallery-image-item') || className.includes('metadata-gallery-grid-image'); - - if (!isClickInsideImage && containerRef.current.contains(event.target)) { - handleImageSelection([]); - updateSelectedImage(); - } - }, [handleImageSelection, updateSelectedImage]); + }, [store, deleteFilesCallback]); return ( -
-
- {!isFirstLoading && ( - <> -
- {isLoadingMore && -
- -
- } - - )} -
- containerRef.current.getBoundingClientRect()} - getContainerRect={() => containerRef.current.getBoundingClientRect()} - onDownload={handleDownload} - onDelete={handleDelete} - /> - {isImagePopupOpen && ( - - - - )} - {isZipDialogOpen && - - image.path === '/' ? image.name : `${image.path}/${image.name}`)} - toggleDialog={closeZipDialog} - /> - - } +
+
); }; diff --git a/frontend/src/metadata/views/gallery/main.js b/frontend/src/metadata/views/gallery/main.js index d05db1c80d..549db124fb 100644 --- a/frontend/src/metadata/views/gallery/main.js +++ b/frontend/src/metadata/views/gallery/main.js @@ -1,197 +1,416 @@ -import React, { useState, useCallback, useMemo, useRef } from 'react'; +import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import PropTypes from 'prop-types'; -import EmptyTip from '../../../components/empty-tip'; -import { gettext } from '../../../utils/constants'; -import { GALLERY_DATE_MODE } from '../../constants'; -import Image from './image'; +import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; +import metadataAPI from '../../api'; +import URLDecorator from '../../../utils/url-decorator'; +import toaster from '../../../components/toast'; +import Content from './content'; +import ContextMenu from './context-menu'; +import ImageDialog from '../../../components/dialog/image-dialog'; +import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog'; +import ModalPortal from '../../../components/modal-portal'; +import { useMetadataView } from '../../hooks/metadata-view'; +import { Utils } from '../../../utils/utils'; +import { getDateDisplayString, getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell'; +import { siteRoot, fileServerRoot, useGoFileserver, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants'; +import { EVENT_BUS_TYPE, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT, GALLERY_IMAGE_GAP } from '../../constants'; +import { getRowById } from '../../utils/table'; +import { getEventClassName } from '../../utils/common'; + +import './index.css'; + +const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { + const [isFirstLoading, setFirstLoading] = useState(true); + const [zoomGear, setZoomGear] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); + const [overScan, setOverScan] = useState({ top: 0, bottom: 0 }); + const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY); + const [isImagePopupOpen, setIsImagePopupOpen] = useState(false); + const [isZipDialogOpen, setIsZipDialogOpen] = useState(false); + const [imageIndex, setImageIndex] = useState(0); + const [selectedImages, setSelectedImages] = useState([]); -const GalleryMain = ({ - groups, - overScan, - columns, - size, - gap, - mode, - selectedImages, - onImageSelect, - onImageClick, - onImageDoubleClick, - onImageRightClick -}) => { const containerRef = useRef(null); - const imageRef = useRef(null); - const animationFrameRef = useRef(null); + const renderMoreTimer = useRef(null); + const lastState = useRef({ visibleAreaFirstImage: { groupIndex: 0, rowIndex: 0 } }); - const [isSelecting, setIsSelecting] = useState(false); - const [selectionStart, setSelectionStart] = useState(null); + const repoID = window.sfMetadataContext.getSetting('repoID'); + const { updateCurrentDirent } = useMetadataView(); - const imageHeight = useMemo(() => size + gap, [size, gap]); - const selectedImageIds = useMemo(() => selectedImages.map(img => img.id), [selectedImages]); - - const handleMouseDown = useCallback((e) => { - if (e.button !== 0) return; - if (e.ctrlKey || e.metaKey || e.shiftKey) return; - - setIsSelecting(true); - setSelectionStart({ x: e.clientX, y: e.clientY }); + useEffect(() => { + updateCurrentDirent(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleMouseMove = useCallback((e) => { - if (!isSelecting) return; + // Number of images per row + const columns = useMemo(() => { + return 8 - zoomGear; + }, [zoomGear]); - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); + const imageSize = useMemo(() => { + return (containerWidth - (columns - 1) * 2 - 32) / columns; + }, [containerWidth, columns]); + + const dateMode = useMemo(() => { + switch (mode) { + case GALLERY_DATE_MODE.YEAR: + return 'YYYY'; + case GALLERY_DATE_MODE.MONTH: + return 'YYYY-MM'; + case GALLERY_DATE_MODE.DAY: + return 'YYYY-MM-DD'; + default: + return 'YYYY-MM-DD'; } + }, [mode]); - animationFrameRef.current = requestAnimationFrame(() => { - e.preventDefault(); - e.stopPropagation(); - - const selectionEnd = { x: e.clientX, y: e.clientY }; - const selected = []; - - groups.forEach(group => { - group.children.forEach((row) => { - row.children.forEach((img) => { - const imgElement = document.getElementById(img.id); - if (imgElement) { - const rect = imgElement.getBoundingClientRect(); - if ( - rect.left < Math.max(selectionStart.x, selectionEnd.x) && - rect.right > Math.min(selectionStart.x, selectionEnd.x) && - rect.top < Math.max(selectionStart.y, selectionEnd.y) && - rect.bottom > Math.min(selectionStart.y, selectionEnd.y) - ) { - selected.push(img); - } - } + const groups = useMemo(() => { + if (isFirstLoading) return []; + const firstSort = metadata.view.sorts[0]; + let init = metadata.rows.filter(row => Utils.imageCheck(getFileNameFromRecord(row))) + .reduce((_init, record) => { + const id = record[PRIVATE_COLUMN_KEY.ID]; + const fileName = getFileNameFromRecord(record); + const parentDir = getParentDirFromRecord(record); + const path = Utils.encodePath(Utils.joinPath(parentDir, fileName)); + const date = mode !== GALLERY_DATE_MODE.ALL ? getDateDisplayString(record[firstSort.column_key], dateMode) : ''; + const img = { + id, + name: fileName, + path: parentDir, + url: `${siteRoot}lib/${repoID}/file${path}`, + src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`, + thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`, + downloadURL: `${fileServerRoot}repos/${repoID}/files${path}?op=download`, + date: date, + }; + let _group = _init.find(g => g.name === date); + if (_group) { + _group.children.push(img); + } else { + _init.push({ + name: date, + children: [img], }); - }); + } + return _init; + }, []); + + let _groups = []; + const imageHeight = imageSize + GALLERY_IMAGE_GAP; + const paddingTop = mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT; + init.forEach((_init, index) => { + const { children, ...__init } = _init; + let top = 0; + let rows = []; + if (index > 0) { + const lastGroup = _groups[index - 1]; + const { top: lastGroupTop, height: lastGroupHeight } = lastGroup; + top = lastGroupTop + lastGroupHeight; + } + children.forEach((child, childIndex) => { + const rowIndex = ~~(childIndex / columns); + if (!rows[rowIndex]) rows[rowIndex] = { top: paddingTop + top + rowIndex * imageHeight, children: [] }; + child.groupIndex = index; + child.rowIndex = rowIndex; + rows[rowIndex].children.push(child); }); - onImageSelect(selected); + const height = rows.length * imageHeight + paddingTop; + _groups.push({ + ...__init, + top, + height, + paddingTop, + children: rows + }); }); - }, [groups, isSelecting, selectionStart, onImageSelect]); + return _groups; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFirstLoading, metadata, metadata.recordsCount, repoID, columns, imageSize, mode]); - const handleMouseUp = useCallback((e) => { - if (e.button !== 0) return; + useEffect(() => { + const gear = window.sfMetadataContext.localStorage.getItem('zoom-gear', 0) || 0; + setZoomGear(gear); - e.preventDefault(); - e.stopPropagation(); - setIsSelecting(false); + const mode = window.sfMetadataContext.localStorage.getItem('gallery-group-by', GALLERY_DATE_MODE.DAY) || GALLERY_DATE_MODE.DAY; + setMode(mode); + + const switchGalleryModeSubscribe = window.sfMetadataContext.eventBus.subscribe( + EVENT_BUS_TYPE.SWITCH_GALLERY_GROUP_BY, + (mode) => { + setMode(mode); + window.sfMetadataContext.localStorage.setItem('gallery-group-by', mode); + } + ); + + const container = containerRef.current; + if (container) { + const { offsetWidth, clientHeight } = container; + setContainerWidth(offsetWidth); + + // Calculate initial overScan information + const columns = 8 - gear; + const imageSize = (offsetWidth - columns * 2 - 2) / columns; + setOverScan({ top: 0, bottom: clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 2 }); + } + setFirstLoading(false); + + // resize + const handleResize = () => { + if (!container) return; + setContainerWidth(container.offsetWidth); + }; + const resizeObserver = new ResizeObserver(handleResize); + container && resizeObserver.observe(container); + + // op + const modifyGalleryZoomGearSubscribe = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.MODIFY_GALLERY_ZOOM_GEAR, (zoomGear) => { + window.sfMetadataContext.localStorage.setItem('zoom-gear', zoomGear); + setZoomGear(zoomGear); + }); + + return () => { + container && resizeObserver.unobserve(container); + modifyGalleryZoomGearSubscribe(); + switchGalleryModeSubscribe(); + renderMoreTimer.current && clearTimeout(renderMoreTimer.current); + }; }, []); - const renderDisplayGroup = useCallback((group) => { - const { top: overScanTop, bottom: overScanBottom } = overScan; - const { name, children, height, top, paddingTop } = group; + useEffect(() => { + if (!imageSize || imageSize < 0) return; + if (imageSize === lastState.current.imageSize) return; + const perImageOffset = imageSize - lastState.current.imageSize; + const { groupIndex, rowIndex } = lastState.current.visibleAreaFirstImage; + const rowOffset = groups.reduce((previousValue, current, currentIndex) => { + if (currentIndex < groupIndex) { + return previousValue + current.children.length; + } + return previousValue; + }, 0) + rowIndex; + const topOffset = rowOffset * perImageOffset + groupIndex * (mode === GALLERY_DATE_MODE.ALL ? 0 : DATE_TAG_HEIGHT); + containerRef.current.scrollTop = containerRef.current.scrollTop + topOffset; + lastState.current = { ...lastState.current, imageSize }; + }, [imageSize, groups, mode]); - // group not in rendering area, return empty div - if (top >= overScanBottom || top + height <= overScanTop) { - return (
); + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + if (scrollTop + clientHeight >= scrollHeight - 10) { + onLoadMore(); + } else { + renderMoreTimer.current && clearTimeout(renderMoreTimer.current); + renderMoreTimer.current = setTimeout(() => { + const { scrollTop, clientHeight } = containerRef.current; + const overScanTop = Math.max(0, scrollTop - (imageSize + GALLERY_IMAGE_GAP) * 3); + const overScanBottom = scrollTop + clientHeight + (imageSize + GALLERY_IMAGE_GAP) * 3; + let groupIndex = 0; + let rowIndex = 0; + let flag = false; + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + for (let j = 0; j < group.children.length; j++) { + const row = group.children[j]; + if (row.top >= scrollTop) { + groupIndex = i; + rowIndex = j; + flag = true; + } + if (flag) break; + } + if (flag) break; + } + lastState.current = { ...lastState.current, visibleAreaFirstImage: { groupIndex, rowIndex } }; + setOverScan({ top: overScanTop, bottom: overScanBottom }); + renderMoreTimer.current = null; + }, 200); } + }, [imageSize, onLoadMore, renderMoreTimer, groups]); - let childrenStartIndex = children.findIndex(r => r.top >= overScanTop); - let childrenEndIndex = children.findIndex(r => r.top >= overScanBottom); + const imageItems = useMemo(() => { + return groups.flatMap(group => group.children.flatMap(row => row.children)); + }, [groups]); - // group in rendering area, but the image not need to render. eg: overScan: { top: 488, bottom: 1100 }, group: { top: 0, height: 521 }, - // in this time, part of an image is in the rendering area, don't render image - if (childrenStartIndex === -1 && childrenEndIndex === -1) { - return (
); + const updateSelectedImage = useCallback((image = null) => { + const imageInfo = image ? getRowById(metadata, image.id) : null; + if (!imageInfo) { + updateCurrentDirent(); + return; } + updateCurrentDirent({ + type: 'file', + name: image.name, + path: image.path, + file_tags: [] + }); + }, [metadata, updateCurrentDirent]); - childrenStartIndex = Math.max(childrenStartIndex, 0); - if (childrenEndIndex === -1) { - childrenEndIndex = children.length; + const handleClick = useCallback((event, image) => { + if (event.metaKey || event.ctrlKey) { + setSelectedImages(prev => + prev.includes(image) ? prev.filter(img => img !== image) : [...prev, image] + ); + updateSelectedImage(image); + } else if (event.shiftKey && selectedImages.length > 0) { + const lastSelected = selectedImages[selectedImages.length - 1]; + const start = imageItems.indexOf(lastSelected); + const end = imageItems.indexOf(image); + const range = imageItems.slice(Math.min(start, end), Math.max(start, end) + 1); + setSelectedImages(prev => Array.from(new Set([...prev, ...range]))); + updateSelectedImage(null); + } else { + setSelectedImages([image]); + updateSelectedImage(image); } - if (childrenEndIndex > 0) { - childrenEndIndex = childrenEndIndex - 1; + }, [imageItems, selectedImages, updateSelectedImage]); + + const handleDoubleClick = useCallback((event, image) => { + const index = imageItems.findIndex(item => item.id === image.id); + setImageIndex(index); + setIsImagePopupOpen(true); + }, [imageItems]); + + const handleRightClick = useCallback((event, image) => { + event.preventDefault(); + const index = imageItems.findIndex(item => item.id === image.id); + if (isNaN(index) || index === -1) return; + + setSelectedImages(prev => prev.length < 2 ? [image] : [...prev]); + }, [imageItems]); + + const moveToPrevImage = () => { + const imageItemsLength = imageItems.length; + setImageIndex((prevState) => (prevState + imageItemsLength - 1) % imageItemsLength); + }; + + const moveToNextImage = () => { + const imageItemsLength = imageItems.length; + setImageIndex((prevState) => (prevState + 1) % imageItemsLength); + }; + + const handleImageSelection = useCallback((selectedImages) => { + setSelectedImages(selectedImages); + }, []); + + const closeImagePopup = () => { + setIsImagePopupOpen(false); + }; + + const handleDownload = useCallback(() => { + if (!selectedImages.length) return; + if (selectedImages.length === 1) { + const image = selectedImages[0]; + let direntPath = image.path === '/' ? image.name : Utils.joinPath(image.path, image.name); + let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath }); + location.href = url; + return; } + if (!useGoFileserver) { + setIsZipDialogOpen(true); + return; + } + const dirents = selectedImages.map(image => { + const value = image.path === '/' ? image.name : `${image.path}/${image.name}`; + return value; + }); + metadataAPI.zipDownload(repoID, '/', dirents).then((res) => { + const zipToken = res.data['zip_token']; + location.href = `${fileServerRoot}zip/${zipToken}`; + }).catch(error => { + const errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }, [repoID, selectedImages]); - return ( -
- {mode !== GALLERY_DATE_MODE.ALL && childrenStartIndex === 0 && ( -
{name || gettext('Empty')}
- )} -
- {children.slice(childrenStartIndex, childrenEndIndex + 1).map((row) => { - return row.children.map((img) => { - const isSelected = selectedImageIds.includes(img.id); - return ( - onImageClick(e, img)} - onDoubleClick={(e) => onImageDoubleClick(e, img)} - onContextMenu={(e) => onImageRightClick(e, img)} - /> - ); - }); - })} -
-
- ); - }, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onImageRightClick]); + const handleDelete = useCallback(() => { + if (!selectedImages.length) return; + onDelete(selectedImages, () => { + setSelectedImages([]); + }); + }, [selectedImages, onDelete]); - if (!Array.isArray(groups) || groups.length === 0) { - return ; - } + const closeZipDialog = () => { + setIsZipDialogOpen(false); + }; + + const handleClickOutside = useCallback((event) => { + const className = getEventClassName(event); + const isClickInsideImage = className.includes('metadata-gallery-image-item') || className.includes('metadata-gallery-grid-image'); + + if (!isClickInsideImage && containerRef.current.contains(event.target)) { + handleImageSelection([]); + updateSelectedImage(); + } + }, [handleImageSelection, updateSelectedImage]); return ( -
- {groups.map((group) => { - return renderDisplayGroup(group); - })} -
+ <> +
+ {!isFirstLoading && ( + <> + + {isLoadingMore && +
+ +
+ } + + )} +
+ containerRef.current.getBoundingClientRect()} + getContainerRect={() => containerRef.current.getBoundingClientRect()} + onDownload={handleDownload} + onDelete={handleDelete} + /> + {isImagePopupOpen && ( + + + + )} + {isZipDialogOpen && ( + + image.path === '/' ? image.name : `${image.path}/${image.name}`)} + toggleDialog={closeZipDialog} + /> + + )} + ); }; -GalleryMain.propTypes = { - groups: PropTypes.arrayOf(PropTypes.shape({ - name: PropTypes.string.isRequired, - children: PropTypes.arrayOf(PropTypes.shape({ - top: PropTypes.number.isRequired, - children: PropTypes.arrayOf(PropTypes.shape({ - src: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - })).isRequired, - })).isRequired, - height: PropTypes.number.isRequired, - top: PropTypes.number.isRequired, - paddingTop: PropTypes.number.isRequired, - })), - overScan: PropTypes.shape({ - top: PropTypes.number.isRequired, - bottom: PropTypes.number.isRequired, - }).isRequired, - columns: PropTypes.number.isRequired, - size: PropTypes.number.isRequired, - gap: PropTypes.number.isRequired, - mode: PropTypes.string, - selectedImages: PropTypes.array.isRequired, - onImageSelect: PropTypes.func.isRequired, - onImageClick: PropTypes.func.isRequired, - onImageDoubleClick: PropTypes.func.isRequired, - onImageRightClick: PropTypes.func.isRequired, +Main.propTypes = { + isLoadingMore: PropTypes.bool, + metadata: PropTypes.object, + onDelete: PropTypes.func, + onLoadMore: PropTypes.func }; -export default GalleryMain; +export default Main; 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 3eb676522c..b94c6d38a7 100644 --- a/frontend/src/pages/lib-content-view/lib-content-view.js +++ b/frontend/src/pages/lib-content-view/lib-content-view.js @@ -23,7 +23,7 @@ import DeleteFolderDialog from '../../components/dialog/delete-folder-dialog'; import { EVENT_BUS_TYPE } from '../../components/common/event-bus-type'; import { PRIVATE_FILE_TYPE } from '../../constants'; import { MetadataProvider, CollaboratorsProvider } from '../../metadata/hooks'; -import { LIST_MODE, METADATA_MODE, FACE_RECOGNITION_MODE, DIRENT_DETAIL_MODE } from '../../components/dir-view-mode/constants'; +import { LIST_MODE, METADATA_MODE, DIRENT_DETAIL_MODE } from '../../components/dir-view-mode/constants'; import CurDirPath from '../../components/cur-dir-path'; import DirTool from '../../components/cur-dir-path/dir-tool'; import Detail from '../../components/dirent-detail'; @@ -547,19 +547,6 @@ class LibContentView extends React.Component { window.history.pushState({ url: url, path: '' }, '', url); }; - showFaceRecognition = (filePath, viewId) => { - 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, @@ -1891,10 +1878,6 @@ class LibContentView extends React.Component { if (node.path !== this.state.path) { this.showFileMetadata(node.path, node.view_id || '0000', node.view_type || VIEW_TYPE.TABLE); } - } 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; @@ -2032,7 +2015,7 @@ class LibContentView extends React.Component { isDirentSelected: false, isAllDirentSelected: false, }); - if (this.state.currentMode === METADATA_MODE || this.state.currentMode === FACE_RECOGNITION_MODE) { + if (this.state.currentMode === METADATA_MODE) { this.setState({ currentMode: cookie.load('seafile_view_mode') || LIST_MODE, }); @@ -2422,6 +2405,7 @@ class LibContentView extends React.Component { getMarkDownFileName={this.getMarkDownFileName} openMarkdownFile={this.openMarkdownFile} updateCurrentDirent={this.updateCurrentDirent} + closeDirentDetail={this.closeDirentDetail} /> :
{gettext('Folder does not exist.')}
diff --git a/media/favicons/face-recognition-view.png b/media/favicons/face-recognition-view.png new file mode 100644 index 0000000000..53192e902a Binary files /dev/null and b/media/favicons/face-recognition-view.png differ diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/api2/endpoints/metadata_manage.py index 7a29f4fdd9..5d9e9535ee 100644 --- a/seahub/api2/endpoints/metadata_manage.py +++ b/seahub/api2/endpoints/metadata_manage.py @@ -13,7 +13,8 @@ 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, init_faces, add_init_face_recognition_task, get_metadata_by_faces, extract_file_details + get_unmodifiable_columns, can_read_metadata, init_faces, add_init_face_recognition_task, \ + extract_file_details, get_someone_similar_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 @@ -831,7 +832,7 @@ class FacesRecords(APIView): def get(self, request, repo_id): start = request.GET.get('start', 0) - limit = request.GET.get('limit', 100) + limit = request.GET.get('limit', 1000) try: start = int(start) @@ -853,19 +854,18 @@ class FacesRecords(APIView): 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) + 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 METADATA_TABLE, FACES_TABLE + from seafevents.repo_metadata.utils import FACES_TABLE try: metadata = metadata_server_api.get_metadata() @@ -880,7 +880,7 @@ class FacesRecords(APIView): 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}' + sql = f'SELECT `{FACES_TABLE.columns.id.name}`, `{FACES_TABLE.columns.name.name}`, `{FACES_TABLE.columns.photo_links.name}`, `{FACES_TABLE.columns.vector.name}` 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) @@ -889,49 +889,60 @@ class FacesRecords(APIView): error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) - faces = query_result.get('results') + faces_records = query_result.get('results') + metadata_columns = query_result.get('metadata', []) + metadata_columns.append({ + 'key': '_similar_photo', + 'type': 'text', + 'name': '_similar_photo', + 'data': None, + }) + metadata_columns.append({ + 'key': '_is_someone', + 'type': 'checkbox', + 'name': '_is_someone', + 'data': None, + }) - if not faces: - error_msg = 'Records not found' - return api_error(status.HTTP_404_NOT_FOUND, error_msg) + if not faces_records: + return Response({ + 'metadata': metadata_columns, + 'results': [], + }) try: - query_result = get_metadata_by_faces(faces, metadata_server_api) + similar_result = get_someone_similar_faces(faces_records, 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 query_result: + return Response({ + 'metadata': metadata_columns, + 'results': [], + }) + + similar_result_dict = dict() + for row in similar_result: + similar_result_dict[row['_id']] = row + + for record in faces_records: + vector = record.get(FACES_TABLE.columns.vector.name, None) + record['_is_someone'] = bool(vector) + if FACES_TABLE.columns.vector.name in record: + del record[FACES_TABLE.columns.vector.name] + link_row_ids = [item['row_id'] for item in record.get(FACES_TABLE.columns.photo_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 - }) + link_row_id = link_row_ids[0] + if link_row_id in similar_result_dict: + record['_similar_photo'] = similar_result_dict[link_row_id] - 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}) + return Response({ + 'metadata': metadata_columns, + 'results': faces_records, + }) class FacesRecord(APIView): @@ -999,6 +1010,91 @@ class FacesRecord(APIView): return Response({'success': True}) +class PeoplePhotos(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request, repo_id, people_id): + start = request.GET.get('start', 0) + limit = request.GET.get('limit', 1000) + + 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) + + 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 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 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 `{FACES_TABLE.columns.photo_links.name}` FROM `{FACES_TABLE.name}` WHERE `{FACES_TABLE.columns.id.name}` = "{people_id}" 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_records = query_result.get('results') + + if not faces_records: + return Response({'metadata': [], 'results': []}) + + faces_record = faces_records[0] + + try: + record_ids = [item['row_id'] for item in faces_record.get(FACES_TABLE.columns.photo_links.name, [])] + selected_ids = record_ids[start:limit] + selected_ids_str = ', '.join(["'%s'" % id for id in selected_ids]) + + sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}`, `{METADATA_TABLE.columns.file_ctime.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ({selected_ids_str})' + someone_photos_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) + + return Response(someone_photos_result) + + class FaceRecognitionManage(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated, ) diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py index a152aa5bbc..b620064070 100644 --- a/seahub/repo_metadata/metadata_server_api.py +++ b/seahub/repo_metadata/metadata_server_api.py @@ -47,6 +47,18 @@ def list_metadata_view_records(repo_id, user, view, start=0, limit=1000): metadata_server_api = MetadataServerAPI(repo_id, user) columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns') sql = gen_view_data_sql(METADATA_TABLE, columns, view, start, limit, user) + + # Remove face-vectors from the query SQL because they are too large + query_fields_str = '' + for column in columns: + column_name = column.get('name') + if column_name == METADATA_TABLE.columns.face_vectors.name: + continue + column_name_str = '`%s`, ' % column_name + query_fields_str += column_name_str + query_fields_str = query_fields_str.strip(', ') + sql = sql.replace('*', query_fields_str) + response_results = metadata_server_api.query_rows(sql, []) return response_results @@ -70,7 +82,7 @@ class MetadataServerAPI: def gen_headers(self): payload = { - 'exp': int(time.time()) + 3600, + 'exp': int(time.time()) + 3600, 'base_id': self.base_id, 'user': self.user } @@ -88,7 +100,7 @@ class MetadataServerAPI: if response.status_code == 404: return {'success': True} return parse_response(response) - + def insert_rows(self, table_id, rows): url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/rows' data = { @@ -97,7 +109,7 @@ class MetadataServerAPI: } response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout) return parse_response(response) - + def update_rows(self, table_id, rows): url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/rows' data = { @@ -153,7 +165,7 @@ class MetadataServerAPI: } response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout) return parse_response(response) - + def delete_column(self, table_id, column_key): url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/columns' data = { @@ -162,7 +174,7 @@ class MetadataServerAPI: } response = requests.delete(url, json=data, headers=self.headers, timeout=self.timeout) return parse_response(response) - + def update_column(self, table_id, column): url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/columns' data = { diff --git a/seahub/repo_metadata/utils.py b/seahub/repo_metadata/utils.py index 52486ad708..1e5100e21e 100644 --- a/seahub/repo_metadata/utils.py +++ b/seahub/repo_metadata/utils.py @@ -29,24 +29,24 @@ def add_init_face_recognition_task(params): return json.loads(resp.content)['task_id'] -def get_metadata_by_faces(faces, metadata_server_api): +def get_someone_similar_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 (' + sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}` 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 = [] + link_row_id = link_row_ids[0] + 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 `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN (' + parameters = [] if parameters: sql = sql.rstrip(', ') + ');' diff --git a/seahub/urls.py b/seahub/urls.py index f2adf3ad67..041e3615ce 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -214,7 +214,7 @@ from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \ MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ - FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails + FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos from seahub.api2.endpoints.user_list import UserListView from seahub.api2.endpoints.seahub_io import SeahubIOStatus @@ -1060,6 +1060,7 @@ if settings.ENABLE_METADATA_MANAGEMENT: 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/people-photos/(?P.+)/$', PeoplePhotos.as_view(), name='api-v2.1-metadata-people-photos'), 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'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/extract-file-details/$', MetadataExtractFileDetails.as_view(), name='api-v2.1-metadata-extract-file-details'), ]