mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-17 15:53:28 +00:00
image tags (#7124)
* image tags * update * update --------- Co-authored-by: zheng.shen <zheng.shen@seafile.com>
This commit is contained in:
@@ -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 = {
|
||||
|
@@ -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: ('');
|
||||
}
|
@@ -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 (
|
||||
<Modal isOpen={true} toggle={() => onToggle()} className="sf-metadata-auto-image-tags">
|
||||
<ModalHeader toggle={() => onToggle()}>{fileName + gettext('\'s tags')}</ModalHeader>
|
||||
<ModalBody>
|
||||
{isLoading ? (
|
||||
<CenteredLoading />
|
||||
) : (
|
||||
<div className="auto-image-tags-container">
|
||||
{imageTags.length > 0 ? (
|
||||
<>
|
||||
{imageTags.map((tagName, index) => {
|
||||
const isSelected = selectedTags.includes(tagName);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('auto-image-tag', { 'selected': isSelected })}
|
||||
onClick={() => onSelectImageTag(tagName)}
|
||||
>
|
||||
{tagName}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-tip">{gettext('No tags')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="secondary" onClick={() => onToggle()}>{gettext('Cancel')}</Button>
|
||||
<Button color="primary" disabled={isLoading || isSubmitting} onClick={handelSubmit}>{gettext('Submit')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
ImageTagsDialog.propTypes = {
|
||||
record: PropTypes.object,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ImageTagsDialog;
|
@@ -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);
|
||||
|
@@ -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,9 +436,9 @@ const ContextMenu = (props) => {
|
||||
};
|
||||
}, [visible, handleHide]);
|
||||
|
||||
const renderMenu = useCallback(() => {
|
||||
if (!visible) return null;
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
@@ -438,6 +456,17 @@ const ContextMenu = (props) => {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [visible, options, position, handleOptionClick]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderMenu()}
|
||||
{imageTagsRecord && (
|
||||
<ImageTagsDialog record={imageTagsRecord} onToggle={toggleImageTagsRecord} onSubmit={updateFileTags} />
|
||||
)}
|
||||
</>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
ContextMenu.propTypes = {
|
||||
|
@@ -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,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
@@ -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,
|
||||
|
@@ -1,3 +1,4 @@
|
||||
export * from './cell';
|
||||
export * from './row';
|
||||
export * from './validate';
|
||||
export * from './favicon';
|
||||
|
6
frontend/src/tag/utils/row/core.js
Normal file
6
frontend/src/tag/utils/row/core.js
Normal file
@@ -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);
|
||||
};
|
1
frontend/src/tag/utils/row/index.js
Normal file
1
frontend/src/tag/utils/row/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './core';
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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'),
|
||||
]
|
||||
|
Reference in New Issue
Block a user