diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js index 41dfe919f5..8c9f5419b0 100644 --- a/frontend/src/metadata/api.js +++ b/frontend/src/metadata/api.js @@ -222,6 +222,15 @@ class MetadataManagerAPI { return this.req.post(url, params); }; + imageTags = (repoID, filePath) => { + const url = this.server + '/api/v2.1/ai/image-tags/'; + const params = { + path: filePath, + repo_id: repoID, + }; + return this.req.post(url, params); + }; + extractFileDetails = (repoID, objIds) => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/extract-file-details/'; const params = { diff --git a/frontend/src/metadata/components/dialog/image-tags-dialog/index.css b/frontend/src/metadata/components/dialog/image-tags-dialog/index.css new file mode 100644 index 0000000000..a13633e426 --- /dev/null +++ b/frontend/src/metadata/components/dialog/image-tags-dialog/index.css @@ -0,0 +1,27 @@ +.sf-metadata-auto-image-tags .modal-body { + min-height: 160px; +} + +.sf-metadata-auto-image-tags .auto-image-tags-container { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.sf-metadata-auto-image-tags .auto-image-tag { + height: 28px; + padding: 0 8px; + border: 1px solid #dedede; + border-radius: 4px; + margin-right: 8px; + line-height: 26px; + cursor: pointer; +} + +.sf-metadata-auto-image-tags .auto-image-tag.selected { + border-color: #FF9800; +} + +.sf-metadata-auto-image-tags .auto-image-tag.selected::after { + content: (''); +} diff --git a/frontend/src/metadata/components/dialog/image-tags-dialog/index.js b/frontend/src/metadata/components/dialog/image-tags-dialog/index.js new file mode 100644 index 0000000000..16f2b48107 --- /dev/null +++ b/frontend/src/metadata/components/dialog/image-tags-dialog/index.js @@ -0,0 +1,162 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; +import { gettext } from '../../../../utils/constants'; +import { Utils } from '../../../../utils/utils'; +import { getFileNameFromRecord, getParentDirFromRecord, getTagsFromRecord, getRecordIdFromRecord +} from '../../../utils/cell'; +import toaster from '../../../../components/toast'; +import classNames from 'classnames'; +import { getTagByName, getTagId } from '../../../../tag/utils'; +import { PRIVATE_COLUMN_KEY as TAGS_PRIVATE_COLUMN_KEY } from '../../../../tag/constants'; +import { SELECT_OPTION_COLORS } from '../../../constants'; +import { useTags } from '../../../../tag/hooks'; + +import './index.css'; + +const ImageTagsDialog = ({ record, onToggle, onSubmit }) => { + + const [isLoading, setLoading] = useState(true); + const [isSubmitting, setSubmitting] = useState(false); + const [imageTags, setImageTags] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + + const fileName = useMemo(() => getFileNameFromRecord(record), [record]); + + const { tagsData, addTags } = useTags(); + + useEffect(() => { + let path = ''; + if (Utils.imageCheck(fileName) && window.sfMetadataContext.canModifyRow(record)) { + const parentDir = getParentDirFromRecord(record); + path = Utils.joinPath(parentDir, fileName); + } + if (path === '') { + setLoading(false); + return; + } + window.sfMetadataContext.imageTags(path).then(res => { + const tags = res.data.tags; + setImageTags(tags); + setLoading(false); + }).catch(error => { + const errorMessage = gettext('Failed to generate image tags'); + toaster.danger(errorMessage); + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSelectImageTag = useCallback((tagName) => { + let newSelectedTags = selectedTags.slice(0); + const tagNameIndex = selectedTags.findIndex(i => i === tagName); + if (tagNameIndex === -1) { + newSelectedTags.push(tagName); + } else { + newSelectedTags = newSelectedTags.filter(i => i !== tagName); + } + setSelectedTags(newSelectedTags); + }, [selectedTags]); + + const handelSubmit = useCallback(() => { + setSubmitting(true); + if (selectedTags.length === 0) { + onToggle(); + return; + } + + let { newTags, exitTagIds } = selectedTags.reduce((cur, pre) => { + const tag = getTagByName(tagsData, pre); + if (tag) { + cur.exitTagIds.push(getTagId(tag)); + } else { + cur.newTags.push(pre); + } + return cur; + }, { newTags: [], exitTagIds: [] }); + + newTags = newTags.map(tagName => { + const defaultOptions = SELECT_OPTION_COLORS.slice(0, 24); + const defaultOption = defaultOptions[Math.floor(Math.random() * defaultOptions.length)]; + return { [TAGS_PRIVATE_COLUMN_KEY.TAG_NAME]: tagName, [TAGS_PRIVATE_COLUMN_KEY.TAG_COLOR]: defaultOption.COLOR }; + }); + const recordId = getRecordIdFromRecord(record); + let value = getTagsFromRecord(record); + value = value ? value.map(item => item.row_id) : []; + + if (newTags.length > 0) { + addTags(newTags, { + success_callback: (operation) => { + const newTagIds = operation.tags?.map(tag => getTagId(tag)); + let newValue = [...value, ...newTagIds]; + exitTagIds.forEach(id => { + if (!newValue.includes(id)) { + newValue.push(id); + } + }); + onSubmit([{ record_id: recordId, tags: newValue, old_tags: value }]); + onToggle(); + }, + fail_callback: (error) => { + setSubmitting(false); + }, + }); + return; + } + let newValue = [...value]; + exitTagIds.forEach(id => { + if (!newValue.includes(id)) { + newValue.push(id); + } + }); + if (newValue.length !== value.length) { + onSubmit([{ record_id: recordId, tags: newValue, old_tags: value }]); + } + onToggle(); + }, [selectedTags, onSubmit, onToggle, record, addTags, tagsData]); + + return ( + onToggle()} className="sf-metadata-auto-image-tags"> + onToggle()}>{fileName + gettext('\'s tags')} + + {isLoading ? ( + + ) : ( +
+ {imageTags.length > 0 ? ( + <> + {imageTags.map((tagName, index) => { + const isSelected = selectedTags.includes(tagName); + return ( +
onSelectImageTag(tagName)} + > + {tagName} +
+ ); + })} + + ) : ( +
{gettext('No tags')}
+ )} +
+ )} +
+ + + + +
+ ); +}; + +ImageTagsDialog.propTypes = { + record: PropTypes.object, + onToggle: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default ImageTagsDialog; diff --git a/frontend/src/metadata/context.js b/frontend/src/metadata/context.js index 45be75a445..e8cfaa8067 100644 --- a/frontend/src/metadata/context.js +++ b/frontend/src/metadata/context.js @@ -242,6 +242,11 @@ class Context { return this.metadataAPI.imageCaption(repoID, filePath, lang); }; + imageTags = (filePath) => { + const repoID = this.settings['repoID']; + return this.metadataAPI.imageTags(repoID, filePath); + }; + extractFileDetails = (objIds) => { const repoID = this.settings['repoID']; return this.metadataAPI.extractFileDetails(repoID, objIds); diff --git a/frontend/src/metadata/views/table/context-menu/index.js b/frontend/src/metadata/views/table/context-menu/index.js index 95cc99f3d7..2625bf8acf 100644 --- a/frontend/src/metadata/views/table/context-menu/index.js +++ b/frontend/src/metadata/views/table/context-menu/index.js @@ -10,6 +10,7 @@ import { EVENT_BUS_TYPE, EVENT_BUS_TYPE as METADATA_EVENT_BUS_TYPE, PRIVATE_COLU import { getFileNameFromRecord, getParentDirFromRecord, getFileObjIdFromRecord, getRecordIdFromRecord, } from '../../../utils/cell'; +import ImageTagsDialog from '../../../components/dialog/image-tags-dialog'; import './index.css'; @@ -20,6 +21,7 @@ const OPERATION = { OPEN_IN_NEW_TAB: 'open-new-tab', GENERATE_DESCRIPTION: 'generate-description', IMAGE_CAPTION: 'image-caption', + IMAGE_TAGS: 'image-tags', DELETE_RECORD: 'delete-record', DELETE_RECORDS: 'delete-records', RENAME_FILE: 'rename-file', @@ -30,11 +32,12 @@ const OPERATION = { const ContextMenu = (props) => { const { isGroupView, selectedRange, selectedPosition, recordMetrics, recordGetterByIndex, onClearSelected, onCopySelected, updateRecords, - getTableContentRect, getTableCanvasContainerRect, deleteRecords, toggleDeleteFolderDialog, selectNone, + getTableContentRect, getTableCanvasContainerRect, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileTags, } = props; const menuRef = useRef(null); const [visible, setVisible] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); + const [imageTagsRecord, setImageTagsRecord] = useState(null); const { metadata } = useMetadataView(); @@ -57,6 +60,7 @@ const ContextMenu = (props) => { const isReadonly = permission === 'r'; const { columns } = metadata; const descriptionColumn = getColumnByKey(columns, PRIVATE_COLUMN_KEY.FILE_DESCRIPTION); + const tagsColumn = getColumnByKey(columns, PRIVATE_COLUMN_KEY.TAGS); let list = []; // handle selected multiple cells @@ -139,6 +143,10 @@ const ContextMenu = (props) => { list.push({ value: OPERATION.FILE_DETAIL, label: gettext('Extract file detail'), record: record }); } + if (tagsColumn && canModifyRow && Utils.imageCheck(fileName)) { + list.push({ value: OPERATION.IMAGE_TAGS, label: gettext('Generate image tags'), record: record }); + } + // handle delete folder/file if (canDeleteRow) { list.push({ value: OPERATION.DELETE_RECORD, label: isFolder ? gettext('Delete folder') : gettext('Delete file'), record }); @@ -244,6 +252,10 @@ const ContextMenu = (props) => { }); }, [updateRecords]); + const toggleImageTagsRecord = useCallback((record = null) => { + setImageTagsRecord(record); + }, []); + const updateFileDetails = useCallback((records) => { const recordObjIds = records.map(record => getFileObjIdFromRecord(record)); if (recordObjIds.length > 50) { @@ -313,6 +325,12 @@ const ContextMenu = (props) => { imageCaption(record); break; } + case OPERATION.IMAGE_TAGS: { + const { record } = option; + if (!record) break; + toggleImageTagsRecord(record); + break; + } case OPERATION.DELETE_RECORD: { const { record } = option; if (!record || !record._id || !deleteRecords) break; @@ -356,7 +374,7 @@ const ContextMenu = (props) => { } } setVisible(false); - }, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption, selectNone, deleteRecords, toggleDeleteFolderDialog, updateFileDetails]); + }, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleImageTagsRecord]); const getMenuPosition = useCallback((x = 0, y = 0) => { let menuStyles = { @@ -418,25 +436,36 @@ const ContextMenu = (props) => { }; }, [visible, handleHide]); - if (!visible) return null; - if (options.length === 0) return null; + const renderMenu = useCallback(() => { + if (!visible) return null; + if (options.length === 0) return null; + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); + }, [visible, options, position, handleOptionClick]); return ( -
- {options.map((option, index) => ( - - ))} -
+ <> + {renderMenu()} + {imageTagsRecord && ( + + )} + + ); }; diff --git a/frontend/src/metadata/views/table/masks/interaction-masks/index.js b/frontend/src/metadata/views/table/masks/interaction-masks/index.js index a03f99ec50..444b14cae7 100644 --- a/frontend/src/metadata/views/table/masks/interaction-masks/index.js +++ b/frontend/src/metadata/views/table/masks/interaction-masks/index.js @@ -1121,7 +1121,8 @@ class InteractionMasks extends React.Component { onClearSelected: this.handleSelectCellsDelete, onCopySelected: this.onCopySelected, getTableContentRect: this.props.getTableContentRect, - getTableCanvasContainerRect: this.props.getTableCanvasContainerRect + getTableCanvasContainerRect: this.props.getTableCanvasContainerRect, + updateFileTags: this.props.updateFileTags, })} ); diff --git a/frontend/src/tag/hooks/tags.js b/frontend/src/tag/hooks/tags.js index 6c945468a4..52cfcecc0c 100644 --- a/frontend/src/tag/hooks/tags.js +++ b/frontend/src/tag/hooks/tags.js @@ -119,6 +119,10 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. return storeRef.current.addTags([row], callback); }, []); + const addTags = useCallback((rows, callback) => { + return storeRef.current.addTags(rows, callback); + }, []); + const modifyTags = useCallback((tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback }) => { storeRef.current.modifyTags(tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback }); }, [storeRef]); @@ -238,6 +242,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. updateCurrentDirent: params.updateCurrentDirent, closeDirentDetail: params.closeDirentDetail, addTag, + addTags, modifyTags, deleteTags, duplicateTag, diff --git a/frontend/src/tag/utils/index.js b/frontend/src/tag/utils/index.js index 638548397a..dc71cc61db 100644 --- a/frontend/src/tag/utils/index.js +++ b/frontend/src/tag/utils/index.js @@ -1,3 +1,4 @@ export * from './cell'; +export * from './row'; export * from './validate'; export * from './favicon'; diff --git a/frontend/src/tag/utils/row/core.js b/frontend/src/tag/utils/row/core.js new file mode 100644 index 0000000000..952da6b1de --- /dev/null +++ b/frontend/src/tag/utils/row/core.js @@ -0,0 +1,6 @@ +import { getTagName } from '../cell'; + +export const getTagByName = (tagsData, tagName) => { + if (!tagsData || !tagName) return null; + return tagsData.rows.find((tag) => getTagName(tag) === tagName); +}; diff --git a/frontend/src/tag/utils/row/index.js b/frontend/src/tag/utils/row/index.js new file mode 100644 index 0000000000..4b0e041376 --- /dev/null +++ b/frontend/src/tag/utils/row/index.js @@ -0,0 +1 @@ +export * from './core'; diff --git a/seahub/ai/apis.py b/seahub/ai/apis.py index b162550806..b95f267139 100644 --- a/seahub/ai/apis.py +++ b/seahub/ai/apis.py @@ -14,7 +14,7 @@ from seahub.api2.throttling import UserRateThrottle from seahub.api2.authentication import TokenAuthentication from seahub.utils import get_file_type_and_ext, IMAGE from seahub.views import check_folder_permission -from seahub.ai.utils import image_caption, verify_ai_config, generate_summary +from seahub.ai.utils import image_caption, verify_ai_config, generate_summary, image_tags logger = logging.getLogger(__name__) @@ -137,3 +137,59 @@ class GenerateSummary(APIView): return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) return Response(resp_json, resp.status_code) + + +class ImageTags(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def post(self, request): + if not verify_ai_config(): + return api_error(status.HTTP_400_BAD_REQUEST, 'AI server not configured') + + repo_id = request.data.get('repo_id') + path = request.data.get('path') + + if not repo_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'repo_id invalid') + if not path: + return api_error(status.HTTP_400_BAD_REQUEST, 'path invalid') + + 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) + + permission = check_folder_permission(request, repo_id, os.path.dirname(path)) + if not permission: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + try: + file_id = seafile_api.get_file_id_by_path(repo_id, path) + except SearpcError as e: + logger.error(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + if not file_id: + return api_error(status.HTTP_404_NOT_FOUND, f"File {path} not found") + + token = seafile_api.get_fileserver_access_token(repo_id, file_id, 'download', request.user.username, use_onetime=True) + if not token: + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + params = { + 'path': path, + 'download_token': token + } + + try: + resp = image_tags(params) + resp_json = resp.json() + except Exception as e: + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response(resp_json, resp.status_code) diff --git a/seahub/ai/utils.py b/seahub/ai/utils.py index 5fb5061b13..b7472e2f20 100644 --- a/seahub/ai/utils.py +++ b/seahub/ai/utils.py @@ -33,3 +33,10 @@ def generate_summary(params): url = urljoin(SEAFILE_AI_SERVER_URL, '/api/v1/generate-summary') resp = requests.post(url, json=params, headers=headers, timeout=30) return resp + + +def image_tags(params): + headers = gen_headers() + url = urljoin(SEAFILE_AI_SERVER_URL, '/api/v1/image-tags/') + resp = requests.post(url, json=params, headers=headers, timeout=30) + return resp diff --git a/seahub/urls.py b/seahub/urls.py index 12000ee4a0..e0e2c4666a 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -2,7 +2,7 @@ from django.urls import include, path, re_path from django.views.generic import TemplateView -from seahub.ai.apis import ImageCaption, GenerateSummary +from seahub.ai.apis import ImageCaption, GenerateSummary, ImageTags from seahub.api2.endpoints.share_link_auth import ShareLinkUserAuthView, ShareLinkEmailAuthView from seahub.api2.endpoints.internal_api import InternalUserListView, InternalCheckShareLinkAccess, \ InternalCheckFileOperationAccess @@ -1054,5 +1054,6 @@ if getattr(settings, 'ENABLE_METADATA_MANAGEMENT', False): # ai API urlpatterns += [ re_path(r'^api/v2.1/ai/image-caption/$', ImageCaption.as_view(), name='api-v2.1-image-caption'), + re_path(r'^api/v2.1/ai/image-tags/$', ImageTags.as_view(), name='api-v2.1-image-tags'), re_path(r'^api/v2.1/ai/generate-summary/$', GenerateSummary.as_view(), name='api-v2.1-generate-summary'), ]