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'),
]