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