From e8af55a1a46f74428d296a7373e396f78d69f9d5 Mon Sep 17 00:00:00 2001 From: awu0403 <76416779+awu0403@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:59:31 +0800 Subject: [PATCH] Export/Import tags (#7888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * export tags * remove child links * update * optimize code and update import tags loading * optimize code * optimize var --------- Co-authored-by: 孙永强 <11704063+s-yongqiang@user.noreply.gitee.com> --- .../src/components/cur-dir-path/dir-path.js | 2 +- .../components/dialog/import-tags-dialog.js | 41 ++++ .../sf-table/constants/context-menu.js | 1 + .../sys-admin/statistic/statistic-metrics.js | 2 +- frontend/src/tag/api.js | 14 ++ .../all-tags-operation-toolbar/index.js | 39 +++- .../src/tag/components/tag-view-name/index.js | 5 +- frontend/src/tag/context.js | 13 ++ frontend/src/tag/hooks/tags.js | 5 +- frontend/src/tag/store/index.js | 8 +- .../src/tag/store/operations/constants.js | 3 +- frontend/src/tag/store/server-operator.js | 33 ++- .../tags-table/context-menu-options.js | 26 +++ .../tag/views/all-tags/tags-table/index.js | 22 +- seahub/repo_metadata/apis.py | 205 ++++++++++++++++++ seahub/repo_metadata/urls.py | 4 +- 16 files changed, 408 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/dialog/import-tags-dialog.js diff --git a/frontend/src/components/cur-dir-path/dir-path.js b/frontend/src/components/cur-dir-path/dir-path.js index 5b638d1256..d10fb7dcba 100644 --- a/frontend/src/components/cur-dir-path/dir-path.js +++ b/frontend/src/components/cur-dir-path/dir-path.js @@ -161,7 +161,7 @@ class DirPath extends React.Component { / {gettext('Tags')} / - + {children && ( <> / diff --git a/frontend/src/components/dialog/import-tags-dialog.js b/frontend/src/components/dialog/import-tags-dialog.js new file mode 100644 index 0000000000..6daa8bd100 --- /dev/null +++ b/frontend/src/components/dialog/import-tags-dialog.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalBody } from 'reactstrap'; +import { gettext } from '../../utils/constants'; +import SeahubModalHeader from '@/components/common/seahub-modal-header'; +import Loading from '../loading'; + +import '../../css/seahub-io-dialog.css'; + +const propTypes = { + toggleDialog: PropTypes.func.isRequired, +}; + +class ImportTagsDialog extends React.Component { + constructor(props) { + super(props); + } + + toggle = () => { + this.props.toggleDialog(); + }; + + + render() { + return ( + + {gettext('Import tags')} + + <> + +
{gettext('Importing tags...')}
+ +
+
+ ); + } +} + +ImportTagsDialog.propTypes = propTypes; + +export default ImportTagsDialog; diff --git a/frontend/src/components/sf-table/constants/context-menu.js b/frontend/src/components/sf-table/constants/context-menu.js index 1a4167b7c2..6e35729349 100644 --- a/frontend/src/components/sf-table/constants/context-menu.js +++ b/frontend/src/components/sf-table/constants/context-menu.js @@ -6,6 +6,7 @@ export const OPERATION = { NEW_SUB_TAG: 'new_sub_tag', MERGE_TAGS: 'merge_tags', ADD_CHILD_TAGS: 'add_child_tags', + EXPORT_TAGS: 'export_tags', }; export const POPUP_EDITOR_OPERATION_KEYS = [OPERATION.SET_SUB_TAGS, OPERATION.ADD_CHILD_TAGS]; diff --git a/frontend/src/pages/sys-admin/statistic/statistic-metrics.js b/frontend/src/pages/sys-admin/statistic/statistic-metrics.js index 6922c96595..f5211a5d56 100644 --- a/frontend/src/pages/sys-admin/statistic/statistic-metrics.js +++ b/frontend/src/pages/sys-admin/statistic/statistic-metrics.js @@ -230,7 +230,7 @@ const style = ` padding: 0; } - .loading-tip { + .metrics-container .loading-tip { margin: 100px auto; text-align: center; } diff --git a/frontend/src/tag/api.js b/frontend/src/tag/api.js index b55cbc4938..09c2f51a2c 100644 --- a/frontend/src/tag/api.js +++ b/frontend/src/tag/api.js @@ -143,6 +143,20 @@ class TagsManagerAPI { return this.req.post(url); }; + exportTags = (repoID, tagsIds) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/export-tags/'; + const params = { + tags_ids: tagsIds, + }; + return this.req.post(url, params); + }; + + importTags = (repoID, file) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/import-tags/'; + const formData = new FormData(); + formData.append('file', file); + return this._sendPostRequest(url, formData); + }; } const tagsAPI = new TagsManagerAPI(); diff --git a/frontend/src/tag/components/tag-view-name/all-tags-operation-toolbar/index.js b/frontend/src/tag/components/tag-view-name/all-tags-operation-toolbar/index.js index 9ad77954d1..417bde4367 100644 --- a/frontend/src/tag/components/tag-view-name/all-tags-operation-toolbar/index.js +++ b/frontend/src/tag/components/tag-view-name/all-tags-operation-toolbar/index.js @@ -5,14 +5,18 @@ import EditTagDialog from '../../dialog/edit-tag-dialog'; import { isEnter, isSpace } from '../../../../utils/hotkey'; import { gettext } from '../../../../utils/constants'; import { useTags } from '../../../hooks'; +import tagsAPI from '../../../api'; +import ImportTagsDialog from '../../../../components/dialog/import-tags-dialog'; +import toaster from '../../../../components/toast'; +import { Utils } from '../../../../utils/utils'; import './index.css'; -const AllTagsOperationToolbar = () => { +const AllTagsOperationToolbar = ({ repoID }) => { const [isMenuOpen, setMenuOpen] = useState(false); const [isShowEditTagDialog, setShowEditTagDialog] = useState(false); - - const { tagsData, addTag } = useTags(); + const [isShowImportLoadingDialog, setShowImportLoadingDialog] = useState(false); + const { tagsData, addTag, reloadTags } = useTags(); const tags = useMemo(() => { if (!tagsData) return []; @@ -41,6 +45,28 @@ const AllTagsOperationToolbar = () => { addTag(tag, callback); }, [addTag]); + const handleImportTags = useCallback(() => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.json'; + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + setShowImportLoadingDialog(true); + tagsAPI.importTags(repoID, file).then(res => { + toaster.success(gettext('Successfully imported tags.')); + setTimeout(() => { + reloadTags(true); + }, 10); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg || gettext('Failed to import tags')); + }).finally(() => { + setShowImportLoadingDialog(false); + }); + }; + fileInput.click(); + }, [reloadTags, repoID]); + return ( <>
@@ -62,12 +88,19 @@ const AllTagsOperationToolbar = () => { {gettext('New tag')} + + + {gettext('Import tags')} +
{isShowEditTagDialog && ( )} + {isShowImportLoadingDialog && ( + setShowImportLoadingDialog(false)} /> + )} ); }; diff --git a/frontend/src/tag/components/tag-view-name/index.js b/frontend/src/tag/components/tag-view-name/index.js index 2a631d06a2..59f5dae51b 100644 --- a/frontend/src/tag/components/tag-view-name/index.js +++ b/frontend/src/tag/components/tag-view-name/index.js @@ -8,7 +8,7 @@ import { gettext } from '../../../utils/constants'; import AllTagsOperationToolbar from './all-tags-operation-toolbar'; import { EVENT_BUS_TYPE } from '../../../metadata/constants'; -const TagViewName = ({ id, canSelectAllTags }) => { +const TagViewName = ({ id, canSelectAllTags, repoID }) => { const { tagsData, context } = useTags(); const selectAllTags = () => { @@ -31,7 +31,7 @@ const TagViewName = ({ id, canSelectAllTags }) => { ); } return ( - + ); } const tag = getRowById(tagsData, id); @@ -43,6 +43,7 @@ const TagViewName = ({ id, canSelectAllTags }) => { TagViewName.propTypes = { id: PropTypes.string, + repoID: PropTypes.string, canSelectAllTags: PropTypes.bool, }; diff --git a/frontend/src/tag/context.js b/frontend/src/tag/context.js index 6c1e2a17a5..47106bbe3d 100644 --- a/frontend/src/tag/context.js +++ b/frontend/src/tag/context.js @@ -98,6 +98,11 @@ class Context { return true; }; + checkCanExportTags = () => { + if (this.permission === 'r') return false; + return true; + }; + canModifyTags = () => { if (this.permission === 'r') return false; return true; @@ -131,6 +136,14 @@ class Context { mergeTags = (target_tag_id, merged_tags_ids) => { return this.api.mergeTags(this.repoId, target_tag_id, merged_tags_ids); }; + + exportTags = (tagsIds) => { + return this.api.exportTags(this.repoId, tagsIds); + }; + + importTags = (file) => { + return this.api.importTags(this.repoId, file); + }; } export default Context; diff --git a/frontend/src/tag/hooks/tags.js b/frontend/src/tag/hooks/tags.js index 197b0b8058..99cba343df 100644 --- a/frontend/src/tag/hooks/tags.js +++ b/frontend/src/tag/hooks/tags.js @@ -42,9 +42,9 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, tagsChangedC setTagsData(data); }, []); - const reloadTags = useCallback(() => { + const reloadTags = useCallback((force = false) => { setReloading(true); - storeRef.current.reload(PER_LOAD_NUMBER).then(() => { + storeRef.current.reload(PER_LOAD_NUMBER, force).then(() => { setTagsData(storeRef.current.data); setReloading(false); }).catch(error => { @@ -326,6 +326,7 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, tagsChangedC modifyColumnWidth, modifyLocalFileTags, modifyTagsSort, + reloadTags, }}> {children} diff --git a/frontend/src/tag/store/index.js b/frontend/src/tag/store/index.js index 3515691a58..74a00cc91a 100644 --- a/frontend/src/tag/store/index.js +++ b/frontend/src/tag/store/index.js @@ -66,9 +66,13 @@ class Store { await this.loadTagsData(limit); } - async reload(limit = PER_LOAD_NUMBER) { + async reload(limit = PER_LOAD_NUMBER, force = false) { const currentTime = new Date(); - if (dayjs(currentTime).diff(this.loadTime, 'hours') > 1) { + if (force) { + this.loadTime = currentTime; + this.startIndex = 0; + await this.loadTagsData(limit); + } else if (dayjs(currentTime).diff(this.loadTime, 'hours') > 1) { this.loadTime = currentTime; this.startIndex = 0; await this.loadTagsData(limit); diff --git a/frontend/src/tag/store/operations/constants.js b/frontend/src/tag/store/operations/constants.js index 21ab094b45..d3857925d1 100644 --- a/frontend/src/tag/store/operations/constants.js +++ b/frontend/src/tag/store/operations/constants.js @@ -9,7 +9,7 @@ export const OPERATION_TYPE = { DELETE_TAG_LINKS: 'delete_tag_links', DELETE_TAGS_LINKS: 'delete_tags_links', MERGE_TAGS: 'merge_tags', - + EXPORT_TAGS: 'export_tags', MODIFY_LOCAL_RECORDS: 'modify_local_records', MODIFY_LOCAL_FILE_TAGS: 'modify_local_file_tags', @@ -32,6 +32,7 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.MODIFY_LOCAL_FILE_TAGS]: ['file_id', 'tags_ids'], [OPERATION_TYPE.MODIFY_COLUMN_WIDTH]: ['column_key', 'new_width', 'old_width'], [OPERATION_TYPE.MODIFY_TAGS_SORT]: ['sort'], + [OPERATION_TYPE.EXPORT_TAGS]: ['repo_id', 'tags_ids'], }; export const UNDO_OPERATION_TYPE = [ diff --git a/frontend/src/tag/store/server-operator.js b/frontend/src/tag/store/server-operator.js index f26b56ddd3..59bea94442 100644 --- a/frontend/src/tag/store/server-operator.js +++ b/frontend/src/tag/store/server-operator.js @@ -3,6 +3,7 @@ import { OPERATION_TYPE } from './operations'; import { getColumnByKey } from '../../metadata/utils/column'; import ObjectUtils from '../../utils/object'; import { PRIVATE_COLUMN_KEY } from '../constants'; +import tagsAPI from '../api'; const MAX_LOAD_RECORDS = 100; @@ -115,6 +116,37 @@ class ServerOperator { }); break; } + case OPERATION_TYPE.EXPORT_TAGS: { + const { repo_id, tags_ids } = operation; + tagsAPI.exportTags(repo_id, tags_ids).then((res) => { + let fileName; + if (res.data && Array.isArray(res.data)) { + if (res.data.length === 1) { + fileName = `${res.data[0]._tag_name}.json`; + } else { + const now = new Date(); + const dateStr = now.toISOString().split('T')[0]; + fileName = `tags-export-${dateStr}.json`; + } + } else { + fileName = 'tags.json'; + } + + const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + callback({ operation }); + }).catch((error) => { + callback({ error: gettext('Failed to export tags') }); + }); + break; + } case OPERATION_TYPE.RESTORE_RECORDS: { const { repo_id, rows_data } = operation; if (!Array.isArray(rows_data) || rows_data.length === 0) { @@ -194,7 +226,6 @@ class ServerOperator { }).catch (error => { // for debug // eslint-disable-next-line no-console - console.log(error); this.asyncReloadRecords(restRowsIds, repoId, relatedColumnKeyMap, callback); }); } diff --git a/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js b/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js index deb9f62fa6..ad6c54e787 100644 --- a/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js +++ b/frontend/src/tag/views/all-tags/tags-table/context-menu-options.js @@ -27,6 +27,7 @@ export const createContextMenuOptions = ({ onMergeTags, }) => { const canDeleteTag = context.checkCanDeleteTag(); + const canExportTags = context.checkCanExportTags(); const canAddTag = context.canAddTag(); const eventBus = EventBus.getInstance(); @@ -58,6 +59,10 @@ export const createContextMenuOptions = ({ eventBus.dispatch(EVENT_BUS_TYPE.OPEN_EDITOR, null, option.value); break; } + case OPERATION.EXPORT_TAGS: { + eventBus.dispatch(EVENT_BUS_TYPE.EXPORT_TAGS, option.tagsIds); + break; + } default: { break; } @@ -78,6 +83,13 @@ export const createContextMenuOptions = ({ tagsIds.push(tag._id); } } + if (canExportTags && tagsIds.length > 0) { + options.push({ + label: gettext('Export tags'), + value: OPERATION.EXPORT_TAGS, + tagsIds, + }); + } if (canDeleteTag && tagsIds.length > 0) { if (tagsIds.length === 1) { options.push({ @@ -125,6 +137,13 @@ export const createContextMenuOptions = ({ tagsIds, }); } + if (canExportTags && tagsIds.length > 0) { + options.push({ + label: gettext('Export tags'), + value: OPERATION.EXPORT_TAGS, + tagsIds, + }); + } return options; } @@ -169,6 +188,13 @@ export const createContextMenuOptions = ({ } ); } + if (isNameColumn && canExportTags) { + options.push({ + label: gettext('Export tags'), + value: OPERATION.EXPORT_TAGS, + tagsIds: [tag._id], + }); + } return options; }; diff --git a/frontend/src/tag/views/all-tags/tags-table/index.js b/frontend/src/tag/views/all-tags/tags-table/index.js index 955eaa7db7..e01339614c 100644 --- a/frontend/src/tag/views/all-tags/tags-table/index.js +++ b/frontend/src/tag/views/all-tags/tags-table/index.js @@ -17,6 +17,9 @@ import { isNumber } from '../../../../utils/number'; import { getTreeNodeByKey, getTreeNodeId } from '../../../../components/sf-table/utils/tree'; import { getRowById } from '../../../../components/sf-table/utils/table'; import { getParentLinks } from '../../../utils/cell'; +import ServerOperator from '../../../store/server-operator'; +import toaster from '../../../../components/toast'; +import { OPERATION_TYPE } from '../../../store/operations/constants'; import './index.css'; @@ -269,18 +272,35 @@ const TagsTable = ({ }, 0); }, [eventBus, toggleShowDirentToolbar]); + const onExportTags = useCallback((tagsIds) => { + const operation = { + op_type: OPERATION_TYPE.EXPORT_TAGS, + repo_id: context.repoId, + tags_ids: tagsIds + }; + const serverOperator = new ServerOperator(context); + serverOperator.applyOperation(operation, null, ({ error }) => { + if (error) { + toaster.danger(error); + } + }); + }, [context]); + useEffect(() => { const unsubscribeUpdateSearchResult = eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_SEARCH_RESULT, updateSearchResult); const unsubscribeDeleteTags = eventBus.subscribe(EVENT_BUS_TYPE.DELETE_TAGS, onDeleteTags); const unsubscribeMergeTags = eventBus.subscribe(EVENT_BUS_TYPE.MERGE_TAGS, onMergeTags); const unsubscribeNewSubTag = eventBus.subscribe(EVENT_BUS_TYPE.NEW_SUB_TAG, onNewSubTag); + const unsubscribeExportTags = eventBus.subscribe(EVENT_BUS_TYPE.EXPORT_TAGS, onExportTags); + return () => { unsubscribeUpdateSearchResult(); unsubscribeDeleteTags(); unsubscribeMergeTags(); unsubscribeNewSubTag(); + unsubscribeExportTags(); }; - }, [eventBus, updateSearchResult, onDeleteTags, onMergeTags, onNewSubTag, updateSelectedTagIds]); + }, [eventBus, updateSearchResult, onDeleteTags, onMergeTags, onNewSubTag, onExportTags]); return ( <> diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py index 1b73641ab9..a5b84e4545 100644 --- a/seahub/repo_metadata/apis.py +++ b/seahub/repo_metadata/apis.py @@ -9,6 +9,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from rest_framework.views import APIView +from django.http import HttpResponse from seahub.api2.utils import api_error from seahub.api2.throttling import UserRateThrottle from seahub.api2.authentication import TokenAuthentication @@ -2964,3 +2965,207 @@ class MetadataMigrateTags(APIView): error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) return Response({'success': True}) + + +class MetadataExportTags(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def post(self, request, repo_id): + tags_ids = request.data.get('tags_ids', None) + if not tags_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'tags_ids invalid') + + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled or not metadata.tags_enabled: + error_msg = f'The tags 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.constants import TAGS_TABLE + + + export_data = [] + tags_ids_str = ', '.join([f'"{id}"' for id in tags_ids]) + sql = f'SELECT * FROM {TAGS_TABLE.name} WHERE `{TAGS_TABLE.columns.id.name}` in ({tags_ids_str})' + try: + query_result = metadata_server_api.query_rows(sql).get('results') + for tag in query_result: + tag_parent_links = tag.get(TAGS_TABLE.columns.parent_links.key, []) + tag_sub_links = tag.get(TAGS_TABLE.columns.sub_links.key, []) + export_data.append({ + '_id': tag.get(TAGS_TABLE.columns.id.name, ''), + '_tag_name': tag.get(TAGS_TABLE.columns.name.name, ''), + '_tag_color': tag.get(TAGS_TABLE.columns.color.name, ''), + '_tag_parent_links': [link_info.get('row_id', '') for link_info in tag_parent_links], + '_tag_sub_links': [link_info.get('row_id', '') for link_info in tag_sub_links], + }) + + response = HttpResponse( + json.dumps(export_data, ensure_ascii=False), + content_type='application/json' + ) + response['Content-Disposition'] = 'attachment; filename="tags.json"' + return response + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + +class MetadataImportTags(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def _handle_tag_links(self, new_tags, existing_tags, exist_tags_id_map, imported_existing_tags, resp, tags_table): + exist_tags_ids = [tag.get(tags_table.columns.id.name, '') for tag in existing_tags] + all_tags = new_tags + imported_existing_tags + + tags_id_map = {} + imported_tags_ids = [tag_data.get(tags_table.columns.id.name, '') for tag_data in all_tags] + for index, tag in enumerate(new_tags): + old_tag_id = tag.get(tags_table.columns.id.name, '') + tag[tags_table.columns.id.name] = resp.get('row_ids', [])[index] + tags_id_map[old_tag_id] = tag.get(tags_table.columns.id.name, '') + tags_id_map.update(exist_tags_id_map) + + processed_tags = [] # remove some non-existent tag ids + for tag in new_tags: + child_tags_ids = tag.get(tags_table.columns.sub_links.key, []) + new_child_tags_ids = list(set(child_tags_ids) & set(imported_tags_ids)) + tag[tags_table.columns.sub_links.key] = new_child_tags_ids + processed_tags.append(tag) + for tag in imported_existing_tags: + child_tags_ids = tag.get(tags_table.columns.sub_links.key, []) + new_child_tags_ids = list(set(child_tags_ids) & set(imported_tags_ids)) + tag[tags_table.columns.sub_links.key] = new_child_tags_ids + # Update the imported tag ID to an existing tag ID on the server + tag[tags_table.columns.id.name] = tags_id_map[tag.get(tags_table.columns.id.name, '')] + processed_tags.append(tag) + + child_links_map = {} + # old child links -> new child links and remove exist tags + for tag in processed_tags: + tag_id = tag.get(tags_table.columns.id.name, '') + old_child_links = tag.get(tags_table.columns.sub_links.key, []) + new_child_links = [tags_id_map[link] for link in old_child_links if link in tags_id_map] + formatted_child_links = list(set(new_child_links) - set(exist_tags_ids)) + if formatted_child_links: + child_links_map[tag_id] = formatted_child_links + + return child_links_map + + def _get_existing_tags(self, metadata_server_api, tag_names, tags_table): + tag_names_str = ', '.join([f'"{tag_name}"' for tag_name in tag_names]) + sql = f'SELECT * FROM {tags_table.name} WHERE `{tags_table.columns.name.name}` in ({tag_names_str})' + + exist_rows = metadata_server_api.query_rows(sql) + existing_tags = exist_rows.get('results', []) + + for item in existing_tags: + tag_sub_links = item.get('_tag_sub_links', []) + if tag_sub_links: + sub_links = [] + for link in tag_sub_links: + sub_links.append(link['row_id']) + item['_tag_sub_links'] = sub_links + + return existing_tags + + def _classify_tags(self, file_content, existing_tags, tags_table): + new_tags = [] + imported_existing_tags = [] + existing_id_map = {} + + if existing_tags: + existing_tag_names = [tag.get(tags_table.columns.name.name, '') for tag in existing_tags] + processed_names = set() + + for tag_data in file_content: + tag_name = tag_data.get(tags_table.columns.name.name, '') + + if tag_name in existing_tag_names and tag_name not in processed_names: + idx = existing_tag_names.index(tag_name) + imported_existing_tags.append(tag_data) + existing_id_map[tag_data.get(tags_table.columns.id.name, '')] = ( + existing_tags[idx].get(tags_table.columns.id.name, '') + ) + elif tag_name not in processed_names: + new_tags.append(tag_data) + processed_names.add(tag_name) + else: + new_tags = file_content + + return new_tags, imported_existing_tags, existing_id_map + + def post(self, request, repo_id): + file = request.FILES.get('file', None) + if not file: + return api_error(status.HTTP_400_BAD_REQUEST, 'file invalid') + + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled or not metadata.tags_enabled: + error_msg = f'The tags 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 = f'Library {repo_id} not found.' + 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.constants import TAGS_TABLE + try: + tags_table = get_table_by_name(metadata_server_api, TAGS_TABLE.name) + if not tags_table: + return api_error(status.HTTP_404_NOT_FOUND, 'tags table not found') + tags_table_id = tags_table['id'] + file_content = json.loads(file.read().decode('utf-8')) + tag_names = [tag.get(TAGS_TABLE.columns.name.name, '') for tag in file_content] + if not tag_names: + return Response({'success': True}) + + existing_tags = self._get_existing_tags(metadata_server_api, tag_names, TAGS_TABLE) + new_tags, imported_existing_tags, existing_id_map = self._classify_tags( + file_content, existing_tags, TAGS_TABLE + ) + + if new_tags: + create_tags_data = [ + { + TAGS_TABLE.columns.name.name: tag.get(TAGS_TABLE.columns.name.name, ''), + TAGS_TABLE.columns.color.name: tag.get(TAGS_TABLE.columns.color.name, '') + } + for tag in new_tags + ] + resp = metadata_server_api.insert_rows(tags_table_id, create_tags_data) + else: + return Response({'success': True}) + + # child links map structure: {tag_id: [child_tag_id1, child_tag_id2], ....} + child_links_map = self._handle_tag_links( + new_tags, existing_tags, existing_id_map, + imported_existing_tags, resp, TAGS_TABLE + ) + + if child_links_map: + metadata_server_api.insert_link(TAGS_TABLE.self_link_id, tags_table_id, child_links_map, True) + except Exception as e: + logger.exception(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + return Response({'success': True}) diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py index 6ffde33f25..979802026e 100644 --- a/seahub/repo_metadata/urls.py +++ b/seahub/repo_metadata/urls.py @@ -3,7 +3,7 @@ from .apis import MetadataRecognizeFaces, MetadataRecords, MetadataManage, Metad MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \ - MetadataOCRManageView, PeopleCoverPhoto, MetadataMigrateTags + MetadataOCRManageView, PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags urlpatterns = [ re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'), @@ -44,4 +44,6 @@ urlpatterns = [ re_path(r'^merge-tags/$', MetadataMergeTags.as_view(), name='api-v2.1-metadata-merge-tags'), re_path(r'^tags-files/$', MetadataTagsFiles.as_view(), name='api-v2.1-metadata-tags-files'), re_path(r'^migrate-tags/$', MetadataMigrateTags.as_view(), name='api-v2.1-metadata-migrate-tags'), + re_path(r'^export-tags/$', MetadataExportTags.as_view(), name='api-v2.1-metadata-export-tags'), + re_path(r'^import-tags/$', MetadataImportTags.as_view(), name='api-v2.1-metadata-import-tags'), ]