From cb73865b21b129a6af6f4c1282b13d19016cdafb Mon Sep 17 00:00:00 2001 From: Jerry Ren Date: Fri, 20 Dec 2024 17:59:47 +0800 Subject: [PATCH] feat(tag): support self link (#7225) --- frontend/src/tag/api.js | 18 ++ .../add-linked-tags.js | 113 +++++++++ .../popover/set-linked-tags-popover/index.css | 130 ++++++++++ .../popover/set-linked-tags-popover/index.js | 135 +++++++++++ .../set-linked-tags-popover/linked-tags.js | 37 +++ .../popover/set-linked-tags-popover/tags.js | 53 +++++ frontend/src/tag/constants/column/private.js | 2 + frontend/src/tag/context.js | 8 + frontend/src/tag/hooks/tags.js | 10 + frontend/src/tag/store/index.js | 27 +++ frontend/src/tag/store/operations/apply.js | 63 +++++ .../src/tag/store/operations/constants.js | 4 + frontend/src/tag/store/server-operator.js | 24 ++ frontend/src/tag/utils/cell/core.js | 13 + frontend/src/tag/utils/link.js | 27 +++ .../src/tag/views/all-tags/main/index.css | 50 ++-- frontend/src/tag/views/all-tags/main/index.js | 8 +- .../src/tag/views/all-tags/main/tag/index.css | 16 +- .../src/tag/views/all-tags/main/tag/index.js | 92 ++++++- .../all-tags/main/tag/tag-more-operation.js | 62 +++++ seahub/repo_metadata/apis.py | 225 +++++++++++++++++- seahub/repo_metadata/metadata_server_api.py | 31 ++- seahub/repo_metadata/urls.py | 3 +- seahub/repo_metadata/utils.py | 71 ++++-- 24 files changed, 1151 insertions(+), 71 deletions(-) create mode 100644 frontend/src/tag/components/popover/set-linked-tags-popover/add-linked-tags.js create mode 100644 frontend/src/tag/components/popover/set-linked-tags-popover/index.css create mode 100644 frontend/src/tag/components/popover/set-linked-tags-popover/index.js create mode 100644 frontend/src/tag/components/popover/set-linked-tags-popover/linked-tags.js create mode 100644 frontend/src/tag/components/popover/set-linked-tags-popover/tags.js create mode 100644 frontend/src/tag/utils/link.js create mode 100644 frontend/src/tag/views/all-tags/main/tag/tag-more-operation.js diff --git a/frontend/src/tag/api.js b/frontend/src/tag/api.js index 22fd9024d7..4d5897016e 100644 --- a/frontend/src/tag/api.js +++ b/frontend/src/tag/api.js @@ -105,6 +105,24 @@ class TagsManagerAPI { return this.req.put(url, params); }; + addTagLinks = (repoID, link_column_key, row_id_map) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags-links/'; + const params = { + link_column_key, + row_id_map, + }; + return this.req.post(url, params); + }; + + deleteTagLinks = (repoID, link_column_key, row_id_map) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags-links/'; + const params = { + link_column_key, + row_id_map, + }; + return this.req.delete(url, { data: params }); + }; + } const tagsAPI = new TagsManagerAPI(); diff --git a/frontend/src/tag/components/popover/set-linked-tags-popover/add-linked-tags.js b/frontend/src/tag/components/popover/set-linked-tags-popover/add-linked-tags.js new file mode 100644 index 0000000000..1e5ceb2045 --- /dev/null +++ b/frontend/src/tag/components/popover/set-linked-tags-popover/add-linked-tags.js @@ -0,0 +1,113 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { SearchInput } from '@seafile/sf-metadata-ui-component'; +import EmptyTip from '../../../../components/empty-tip'; +import Tags from './tags'; +import { KeyCodes } from '../../../../constants'; +import { gettext } from '../../../../utils/constants'; +import { getTagsByNameOrColor } from '../../../utils'; + +const getSelectableTags = (allTags, idTagSelectedMap) => { + if (!idTagSelectedMap) { + return allTags; + } + return allTags.filter((tag) => !idTagSelectedMap[tag._id]); +}; + +const initIdTagSelectedMap = (linkedTags) => { + let idTagSelectedMap = {}; + linkedTags.forEach((linkedTag) => { + idTagSelectedMap[linkedTag._id] = true; + }); + return idTagSelectedMap; +}; + +const AddLinkedTags = ({ allTags, linkedTags, switchToLinkedTagsPage, addLinkedTag, deleteLinedTag }) => { + const initialIdTagSelectedMap = initIdTagSelectedMap(linkedTags); + const [idTagSelectedMap, setIdSelectedMap] = useState(initialIdTagSelectedMap); + const [selectableTags, setSelectableTags] = useState([]); + const [searchValue, setSearchValue] = useState(''); + + const initialSelectableTagsRef = useRef(getSelectableTags(allTags, initialIdTagSelectedMap)); + + const selectTag = useCallback((tag) => { + let updatedIdTagSelectedMap = { ...idTagSelectedMap }; + if (updatedIdTagSelectedMap[tag._id]) { + delete updatedIdTagSelectedMap[tag._id]; + setIdSelectedMap(updatedIdTagSelectedMap); + deleteLinedTag(tag._id); + } else { + updatedIdTagSelectedMap[tag._id] = true; + setIdSelectedMap(updatedIdTagSelectedMap); + addLinkedTag(tag); + } + + }, [idTagSelectedMap, addLinkedTag, deleteLinedTag]); + + const onKeyDown = useCallback((event) => { + if ( + event.keyCode === KeyCodes.ChineseInputMethod || + event.keyCode === KeyCodes.Enter || + event.keyCode === KeyCodes.LeftArrow || + event.keyCode === KeyCodes.RightArrow + ) { + event.stopPropagation(); + } + }, []); + + const onChangeSearch = useCallback((newSearchValue) => { + if (searchValue === newSearchValue) return; + setSearchValue(newSearchValue); + }, [searchValue]); + + useEffect(() => { + let searchedTags = []; + if (!searchValue) { + searchedTags = [...initialSelectableTagsRef.current]; + } else { + searchedTags = getTagsByNameOrColor(initialSelectableTagsRef.current, searchValue); + } + setSelectableTags(searchedTags); + }, [searchValue, allTags]); + + return ( +
+
+
+ + {gettext('Link existing tags')} +
+
+
+
+ +
+ {selectableTags.length === 0 && ( + + )} + {selectableTags.length > 0 && ( +
+ +
+ )} +
+
+ ); +}; + +AddLinkedTags.propTypes = { + isParentTags: PropTypes.bool, + allTags: PropTypes.array, + linkedTags: PropTypes.array, + switchToLinkedTagsPage: PropTypes.func, + addLinkedTag: PropTypes.func, + deleteLinedTag: PropTypes.func, +}; + +export default AddLinkedTags; diff --git a/frontend/src/tag/components/popover/set-linked-tags-popover/index.css b/frontend/src/tag/components/popover/set-linked-tags-popover/index.css new file mode 100644 index 0000000000..836e0273bd --- /dev/null +++ b/frontend/src/tag/components/popover/set-linked-tags-popover/index.css @@ -0,0 +1,130 @@ +.sf-metadata-set-linked-tags-popover .popover { + max-width: unset; +} + +.sf-metadata-set-linked-tags-popover-selected, +.sf-metadata-set-linked-tags-popover-selector { + display: flex; + flex-direction: column; + height: 100%; +} + +.sf-metadata-set-linked-tags-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 46px; + border-bottom: 1px solid #e9ecef; +} + +.sf-metadata-set-linked-tags-popover-title { + display: inline-flex; + align-items: center; + height: 40px; + line-height: 40px; + padding: 0 20px; + font-size: 16px; + font-weight: 500; +} + +.sf-metadata-set-linked-tags-popover-selector .sf-metadata-set-linked-tags-popover-title { + margin-left: -3px; +} + +.sf-metadata-set-linked-tags-popover-body { + flex: 1; + height: calc(100% - 46px); +} + +.sf-metadata-set-linked-tags-popover-selectable-tags-wrapper { + height: calc(100% - 48px); +} + +.sf-metadata-editing-tags-list { + height: 100%; + padding: 10px 20px; + overflow-y: scroll; +} + +.sf-metadata-editing-tags-list.selectable:hover { + cursor: pointer; +} + +.sf-metadata-editing-tag:hover { + background: #f5f5f5; +} + +.sf-metadata-editing-tag-container { + display: flex; + align-items: center; + height: 30px; + width: 100%; + border-radius: 2px; + font-size: 13px; + color: #212529; +} + +.sf-metadata-set-linked-tags-popover-search-container { + height: 48px; + padding: 10px 20px 0; +} + +.sf-metadata-editing-tag-color-name { + display: flex; + align-items: center; + flex: 1; +} + +.sf-metadata-editing-tag-color-name .sf-metadata-tag-color { + height: 14px; + width: 14px; + border-radius: 50%; + flex-shrink: 0; +} + +.sf-metadata-editing-tag-color-name .sf-metadata-tag-name { + flex: 1; + margin-left: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sf-metadata-editing-tag-operation { + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; +} + +.sf-metadata-editing-tag-delete-icon { + font-size: 14px; +} + +.sf-metadata-editing-tag-delete:hover .sf-metadata-editing-tag-delete-icon { + fill: #555; +} + +.sf-metadata-set-linked-tags-popover-header-operation { + display: inline-flex; + width: 24px; + height: 24px; + margin-right: 3px; + align-items: center; + justify-content: center; +} + +.sf-metadata-set-linked-tags-popover-header-operation:hover { + border-radius: 3px; + background-color: #efefef; + cursor: pointer; +} + +.sf-metadata-set-linked-tags-popover-back-icon { + display: inline-block; + transform: rotate(180deg); + font-size: 14px; + color: #666; +} diff --git a/frontend/src/tag/components/popover/set-linked-tags-popover/index.js b/frontend/src/tag/components/popover/set-linked-tags-popover/index.js new file mode 100644 index 0000000000..f2f41a1c91 --- /dev/null +++ b/frontend/src/tag/components/popover/set-linked-tags-popover/index.js @@ -0,0 +1,135 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { UncontrolledPopover } from 'reactstrap'; +import isHotkey from 'is-hotkey'; +import LinkedTags from './linked-tags'; +import AddLinkedTags from './add-linked-tags'; +import { getRowsByIds } from '../../../../metadata/utils/table'; +import { useTags } from '../../../hooks'; +import { getEventClassName } from '../../../../metadata/utils/common'; + +import './index.css'; + +const POPOVER_WIDTH = 560; +const POPOVER_WINDOW_SAFE_SPACE = 30; +const POPOVER_MAX_HEIGHT = 520; +const POPOVER_MIN_HEIGHT = 300; + +const KEY_MODE_TYPE = { + LINKED_TAGS: 'linked_tags', + ADD_LINKED_TAGS: 'add_linked_tags', +}; + +const SetLinkedTagsPopover = ({ isParentTags, target, placement, tagLinks, allTags, hidePopover, addTagLinks, deleteTagLinks }) => { + const { tagsData } = useTags(); + const linkedRowsIds = Array.isArray(tagLinks) ? tagLinks.map((link) => link.row_id) : []; + const initialLinkedTags = getRowsByIds(tagsData, linkedRowsIds); + const [mode, setMode] = useState(KEY_MODE_TYPE.LINKED_TAGS); + const [linkedTags, setLinkedTags] = useState(initialLinkedTags); + + const popoverRef = useRef(null); + + const getPopoverInnerStyle = () => { + let style = { width: POPOVER_WIDTH }; + const windowHeight = window.innerHeight - POPOVER_WINDOW_SAFE_SPACE; + let maxHeight = POPOVER_MAX_HEIGHT; + if (windowHeight < maxHeight) { + maxHeight = windowHeight; + } + if (maxHeight < POPOVER_MIN_HEIGHT) { + maxHeight = POPOVER_MIN_HEIGHT; + } + style.height = maxHeight; + return style; + }; + + const onHidePopover = useCallback((event) => { + if (popoverRef.current && !getEventClassName(event).includes('popover') && !popoverRef.current.contains(event.target)) { + hidePopover(event); + event.preventDefault(); + event.stopPropagation(); + return false; + } + }, [hidePopover]); + + const onHotKey = useCallback((event) => { + if (isHotkey('esc', event)) { + event.preventDefault(); + hidePopover(); + } + }, [hidePopover]); + + useEffect(() => { + document.addEventListener('click', onHidePopover, true); + document.addEventListener('keydown', onHotKey); + return () => { + document.removeEventListener('click', onHidePopover, true); + document.removeEventListener('keydown', onHotKey); + }; + }, [onHidePopover, onHotKey]); + + const deleteLinedTag = useCallback((tagId) => { + let updatedLinkedTags = [...linkedTags]; + const deleteIndex = updatedLinkedTags.findIndex((tag) => tag._id === tagId); + if (deleteIndex < 0) return; + updatedLinkedTags.splice(deleteIndex, 1); + setLinkedTags(updatedLinkedTags); + deleteTagLinks(tagId); + }, [linkedTags, deleteTagLinks]); + + const addLinkedTag = useCallback((tag) => { + let updatedLinkedTags = [...linkedTags]; + updatedLinkedTags.push(tag); + setLinkedTags(updatedLinkedTags); + addTagLinks(tag); + }, [linkedTags, addTagLinks]); + + return ( + +
+ {mode === KEY_MODE_TYPE.LINKED_TAGS ? + setMode(KEY_MODE_TYPE.ADD_LINKED_TAGS)} + deleteLinedTag={deleteLinedTag} + /> : + setMode(KEY_MODE_TYPE.LINKED_TAGS)} + addLinkedTag={addLinkedTag} + deleteLinedTag={deleteLinedTag} + /> + } +
+
+ ); +}; + +SetLinkedTagsPopover.propTypes = { + isParentTags: PropTypes.bool, + placement: PropTypes.string, + target: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + tagLinks: PropTypes.array, + allTags: PropTypes.array, + hidePopover: PropTypes.func, + addTagLinks: PropTypes.func, + deleteTagLinks: PropTypes.func, +}; + +SetLinkedTagsPopover.defaultProps = { + placement: 'bottom-end', +}; + +export default SetLinkedTagsPopover; diff --git a/frontend/src/tag/components/popover/set-linked-tags-popover/linked-tags.js b/frontend/src/tag/components/popover/set-linked-tags-popover/linked-tags.js new file mode 100644 index 0000000000..c8fc78d9b3 --- /dev/null +++ b/frontend/src/tag/components/popover/set-linked-tags-popover/linked-tags.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'reactstrap'; +import EmptyTip from '../../../../components/empty-tip'; +import Tags from './tags'; +import { gettext } from '../../../../utils/constants'; + +const LinkedTags = ({ isParentTags, linkedTags, switchToAddTagsPage, deleteLinedTag }) => { + + return ( +
+
+
+ {isParentTags ? gettext('Parent tags') : gettext('Sub tags')} +
+ +
+
+ {linkedTags.length === 0 && ( + + )} + {linkedTags.length > 0 && ( + + )} +
+
+ ); +}; + +LinkedTags.propTypes = { + isParentTags: PropTypes.bool, + linkedTags: PropTypes.array, + switchToAddTagsPage: PropTypes.func, + deleteTag: PropTypes.func, +}; + +export default LinkedTags; diff --git a/frontend/src/tag/components/popover/set-linked-tags-popover/tags.js b/frontend/src/tag/components/popover/set-linked-tags-popover/tags.js new file mode 100644 index 0000000000..a3314406c3 --- /dev/null +++ b/frontend/src/tag/components/popover/set-linked-tags-popover/tags.js @@ -0,0 +1,53 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { getTagColor, getTagId, getTagName } from '../../../utils'; +import { debounce } from '../../../../metadata/utils/common'; + +const Tags = ({ tags, deletable, selectable, idTagSelectedMap, selectTag, deleteTag }) => { + + const clickTag = debounce((tag) => { + if (!selectable) return; + selectTag && selectTag(tag); + }, 200); + + const remove = useCallback((tagId) => { + if (!deletable) return; + deleteTag && deleteTag(tagId); + }, [deletable, deleteTag]); + + return ( +
+ {tags.map((tag) => { + const tagId = getTagId(tag); + const tagName = getTagName(tag); + const tagColor = getTagColor(tag); + return ( +
clickTag(tag)}> +
+
+
+
{tagName}
+
+
+ {deletable &&
remove(tagId)}>
} + {(selectable && idTagSelectedMap && idTagSelectedMap[tagId]) &&
} +
+
+
+ ); + })} +
+ ); +}; + +Tags.propTypes = { + tags: PropTypes.array, + deletable: PropTypes.bool, + selectable: PropTypes.bool, + selectTag: PropTypes.func, + deleteTag: PropTypes.func, +}; + +export default Tags; diff --git a/frontend/src/tag/constants/column/private.js b/frontend/src/tag/constants/column/private.js index d54fb03006..7b1cb4fa85 100644 --- a/frontend/src/tag/constants/column/private.js +++ b/frontend/src/tag/constants/column/private.js @@ -9,6 +9,8 @@ export const PRIVATE_COLUMN_KEY = { TAG_NAME: '_tag_name', TAG_COLOR: '_tag_color', TAG_FILE_LINKS: '_tag_file_links', + PARENT_LINKS: '_tag_parent_links', + SUB_LINKS: '_tag_sub_links', }; export const PRIVATE_COLUMN_KEYS = [ diff --git a/frontend/src/tag/context.js b/frontend/src/tag/context.js index 5a37dafc90..ab8e5b536c 100644 --- a/frontend/src/tag/context.js +++ b/frontend/src/tag/context.js @@ -109,6 +109,14 @@ class Context { return this.api.deleteTags(this.repoId, tags); }; + addTagLinks = (link_column_key, row_id_map) => { + return this.api.addTagLinks(this.repoId, link_column_key, row_id_map); + }; + + deleteTagLinks = (link_column_key, row_id_map) => { + return this.api.deleteTagLinks(this.repoId, link_column_key, row_id_map); + }; + } export default Context; diff --git a/frontend/src/tag/hooks/tags.js b/frontend/src/tag/hooks/tags.js index 52cfcecc0c..829ca4a5cb 100644 --- a/frontend/src/tag/hooks/tags.js +++ b/frontend/src/tag/hooks/tags.js @@ -186,6 +186,14 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. modifyLocalTags(tagIds, idTagUpdates, { [tagId]: originalRowUpdates }, { [tagId]: oldRowData }, { [tagId]: originalOldRowData }, { success_callback, fail_callback }); }, [tagsData, modifyLocalTags]); + const addTagLinks = useCallback((columnKey, tagId, otherTagsIds, { success_callback, fail_callback } = {}) => { + storeRef.current.addTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback); + }, []); + + const deleteTagLinks = useCallback((columnKey, tagId, otherTagsIds, { success_callback, fail_callback } = {}) => { + storeRef.current.deleteTagLinks(columnKey, tagId, otherTagsIds, success_callback, fail_callback); + }, []); + useEffect(() => { if (!handelSelectTag) return; if (isLoading) return; @@ -247,6 +255,8 @@ export const TagsProvider = ({ repoID, currentPath, selectTagsView, children, .. deleteTags, duplicateTag, updateTag, + addTagLinks, + deleteTagLinks, updateLocalTag, selectTag: handelSelectTag, }}> diff --git a/frontend/src/tag/store/index.js b/frontend/src/tag/store/index.js index 0f97cb5057..fa2bd45024 100644 --- a/frontend/src/tag/store/index.js +++ b/frontend/src/tag/store/index.js @@ -339,6 +339,33 @@ class Store { this.applyOperation(operation); } + addTagLinks(column_key, row_id, other_rows_ids, success_callback, fail_callback) { + const type = OPERATION_TYPE.ADD_TAG_LINKS; + const operation = this.createOperation({ + type, + repo_id: this.repoId, + column_key, + row_id, + other_rows_ids, + success_callback, + fail_callback, + }); + this.applyOperation(operation); + } + + deleteTagLinks(column_key, row_id, other_rows_ids, success_callback, fail_callback) { + const type = OPERATION_TYPE.DELETE_TAG_LINKS; + const operation = this.createOperation({ + type, + repo_id: this.repoId, + column_key, + row_id, + other_rows_ids, + success_callback, + fail_callback, + }); + this.applyOperation(operation); + } } export default Store; diff --git a/frontend/src/tag/store/operations/apply.js b/frontend/src/tag/store/operations/apply.js index c092b3e088..51e3b48ef1 100644 --- a/frontend/src/tag/store/operations/apply.js +++ b/frontend/src/tag/store/operations/apply.js @@ -4,6 +4,7 @@ import { UTC_FORMAT_DEFAULT } from '../../../metadata/constants'; import { OPERATION_TYPE } from './constants'; import { PRIVATE_COLUMN_KEY } from '../../constants'; import { username } from '../../../utils/constants'; +import { addRowLinks, removeRowLinks } from '../../utils/link'; dayjs.extend(utc); @@ -84,6 +85,68 @@ export default function apply(data, operation) { data.rows.push(insertRows); return data; } + case OPERATION_TYPE.ADD_TAG_LINKS: { + const { column_key, row_id, other_rows_ids } = operation; + if (column_key === PRIVATE_COLUMN_KEY.PARENT_LINKS) { + data.rows = data.rows.map((row) => { + const currentRowId = row._id; + if (currentRowId === row_id) { + // add parent tags to current tag + row = addRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, other_rows_ids); + } + if (other_rows_ids.includes(currentRowId)) { + // add current tag as sub tag to related tags + row = addRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, [row_id]); + } + return row; + }); + } else if (column_key === PRIVATE_COLUMN_KEY.SUB_LINKS) { + data.rows = data.rows.map((row) => { + const currentRowId = row._id; + if (currentRowId === row_id) { + // add sub tags to current tag + row = addRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, other_rows_ids); + } + if (other_rows_ids.includes(currentRowId)) { + // add current tag as parent tag to related tags + row = addRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, [row_id]); + } + return row; + }); + } + return data; + } + case OPERATION_TYPE.DELETE_TAG_LINKS: { + const { column_key, row_id, other_rows_ids } = operation; + if (column_key === PRIVATE_COLUMN_KEY.PARENT_LINKS) { + data.rows = data.rows.map((row) => { + const currentRowId = row._id; + if (currentRowId === row_id) { + // remove parent tags from current tag + row = removeRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, other_rows_ids); + } + if (other_rows_ids.includes(currentRowId)) { + // remove current tag as sub tag from related tags + row = removeRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, [row_id]); + } + return row; + }); + } else if (column_key === PRIVATE_COLUMN_KEY.SUB_LINKS) { + data.rows = data.rows.map((row) => { + const currentRowId = row._id; + if (currentRowId === row_id) { + // remove sub tags from current tag + row = removeRowLinks(row, PRIVATE_COLUMN_KEY.SUB_LINKS, other_rows_ids); + } + if (other_rows_ids.includes(currentRowId)) { + // remove current tag as parent tag from related tags + row = removeRowLinks(row, PRIVATE_COLUMN_KEY.PARENT_LINKS, [row_id]); + } + return row; + }); + } + return data; + } default: { return data; } diff --git a/frontend/src/tag/store/operations/constants.js b/frontend/src/tag/store/operations/constants.js index d624a0042d..0290ae6e30 100644 --- a/frontend/src/tag/store/operations/constants.js +++ b/frontend/src/tag/store/operations/constants.js @@ -4,6 +4,8 @@ export const OPERATION_TYPE = { DELETE_RECORDS: 'delete_records', RESTORE_RECORDS: 'restore_records', RELOAD_RECORDS: 'reload_records', + ADD_TAG_LINKS: 'add_tag_links', + DELETE_TAG_LINKS: 'delete_tag_links', MODIFY_LOCAL_RECORDS: 'modify_local_records', }; @@ -14,6 +16,8 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.DELETE_RECORDS]: ['repo_id', 'tag_ids', 'deleted_tags'], [OPERATION_TYPE.RESTORE_RECORDS]: ['repo_id', 'rows_data', 'original_rows', 'link_infos', 'upper_row_ids'], [OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'], + [OPERATION_TYPE.ADD_TAG_LINKS]: ['repo_id', 'column_key', 'row_id', 'other_rows_ids'], + [OPERATION_TYPE.DELETE_TAG_LINKS]: ['repo_id', 'column_key', 'row_id', 'other_rows_ids'], [OPERATION_TYPE.MODIFY_LOCAL_RECORDS]: ['repo_id', 'row_ids', 'id_row_updates', 'id_original_row_updates', 'id_old_row_data', 'id_original_old_row_data', 'is_copy_paste', 'is_rename', 'id_obj_id'], }; diff --git a/frontend/src/tag/store/server-operator.js b/frontend/src/tag/store/server-operator.js index f37b69c4c3..9559cee74a 100644 --- a/frontend/src/tag/store/server-operator.js +++ b/frontend/src/tag/store/server-operator.js @@ -51,6 +51,30 @@ class ServerOperator { }); break; } + case OPERATION_TYPE.ADD_TAG_LINKS: { + const { column_key, row_id, other_rows_ids } = operation; + const id_linked_rows_ids_map = { + [row_id]: other_rows_ids, + }; + this.context.addTagLinks(column_key, id_linked_rows_ids_map).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: gettext('Failed to add linked tags') }); + }); + break; + } + case OPERATION_TYPE.DELETE_TAG_LINKS: { + const { column_key, row_id, other_rows_ids } = operation; + const id_linked_rows_ids_map = { + [row_id]: other_rows_ids, + }; + this.context.deleteTagLinks(column_key, id_linked_rows_ids_map).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: gettext('Failed to delete linked tags') }); + }); + break; + } case OPERATION_TYPE.RESTORE_RECORDS: { const { repo_id, rows_data } = operation; if (!Array.isArray(rows_data) || rows_data.length === 0) { diff --git a/frontend/src/tag/utils/cell/core.js b/frontend/src/tag/utils/cell/core.js index f2e79e9ba4..d6664d85eb 100644 --- a/frontend/src/tag/utils/cell/core.js +++ b/frontend/src/tag/utils/cell/core.js @@ -24,6 +24,19 @@ export const getTagId = (tag) => { return tag ? tag[PRIVATE_COLUMN_KEY.ID] : ''; }; +export const getParentLinks = (tag) => { + return (tag && tag[PRIVATE_COLUMN_KEY.PARENT_LINKS]) || []; +}; + +export const getSubLinks = (tag) => { + return (tag && tag[PRIVATE_COLUMN_KEY.SUB_LINKS]) || []; +}; + +export const getSubTagsCount = (tag) => { + const subLinks = getSubLinks(tag); + return subLinks.length; +}; + export const getTagFilesCount = (tag) => { const links = tag ? tag[PRIVATE_COLUMN_KEY.TAG_FILE_LINKS] : []; if (Array.isArray(links)) return links.length; diff --git a/frontend/src/tag/utils/link.js b/frontend/src/tag/utils/link.js new file mode 100644 index 0000000000..360eab8695 --- /dev/null +++ b/frontend/src/tag/utils/link.js @@ -0,0 +1,27 @@ +export const addRowLinks = (row, key, other_rows_ids) => { + let updatedRow = row; + let updatedLinks = Array.isArray(updatedRow[key]) ? [...updatedRow[key]] : []; + other_rows_ids.forEach((otherRowId) => { + if (updatedLinks.findIndex((linked) => linked.row_id === otherRowId) < 0) { + updatedLinks.push({ row_id: otherRowId, display_value: otherRowId }); + } + }); + updatedRow[key] = updatedLinks; + return updatedRow; +}; + +export const removeRowLinks = (row, key, other_rows_ids) => { + if (!Array.isArray(row[key]) || row[key].length === 0) { + return row; + } + let updatedRow = row; + let updatedLinks = [...updatedRow[key]]; + other_rows_ids.forEach((otherRowId) => { + const deleteIndex = updatedLinks.findIndex((linked) => linked.row_id === otherRowId); + if (deleteIndex > -1) { + updatedLinks.splice(deleteIndex, 1); + } + }); + updatedRow[key] = updatedLinks; + return updatedRow; +}; diff --git a/frontend/src/tag/views/all-tags/main/index.css b/frontend/src/tag/views/all-tags/main/index.css index ea0be0fae5..6ff8c69c42 100644 --- a/frontend/src/tag/views/all-tags/main/index.css +++ b/frontend/src/tag/views/all-tags/main/index.css @@ -30,30 +30,36 @@ background-color: #f8f8f8; } -.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell:first-child { - width: calc((100% - 64px) * 0.7); - height: 100%; - padding-left: 10px; -} - -.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell:nth-child(2) { - width: calc((100% - 64px) * 0.3); - height: 100%; -} - -.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell:last-child { - width: 64px; - height: 100%; -} - .sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell { - font-size: 14px; + position: relative; height: 100%; + padding: 0 8px; + font-size: 14px; line-height: 40px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - padding: 0 8px; +} + +.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag { + width: calc((100% - 96px) * 0.3); + padding-left: 10px; +} + +.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-parent-tags, +.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-sub-tags-count, +.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag-files-count { + width: calc((100% - 96px) * 0.2); +} + +.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-operations-wrapper { + width: 96px; + height: 100%; +} + +.sf-metadata-tags-table .sf-metadata-tags-table-row .sf-metadata-tags-table-cell-parent-tags { + display: flex; + align-items: center; } .sf-metadata-tags-table .sf-metadata-tags-table-header .sf-metadata-tags-table-cell { @@ -62,3 +68,11 @@ line-height: 16px; padding: 10px 8px; } + +.sf-metadata-tags-table .sf-metadata-tags-table-cell .sf-metadata-tags-operation-pop-handler { + position: absolute; + left: 0; + top: 0; + width: 0; + height: 0; +} diff --git a/frontend/src/tag/views/all-tags/main/index.js b/frontend/src/tag/views/all-tags/main/index.js index d34fb0b513..4eb8320ce2 100644 --- a/frontend/src/tag/views/all-tags/main/index.js +++ b/frontend/src/tag/views/all-tags/main/index.js @@ -45,9 +45,11 @@ const Main = React.memo(({ context, tags, onChangeDisplayTag, onLoadMore }) => { return (
-
{gettext('Tag')}
-
{gettext('File count')}
-
+
{gettext('Tag')}
+
{gettext('Parent tags')}
+
{gettext('Sub tags count')}
+
{gettext('File count')}
+
{tags.map(tag => { const id = getTagId(tag); diff --git a/frontend/src/tag/views/all-tags/main/tag/index.css b/frontend/src/tag/views/all-tags/main/tag/index.css index 0228de6fbe..d3717f6f99 100644 --- a/frontend/src/tag/views/all-tags/main/tag/index.css +++ b/frontend/src/tag/views/all-tags/main/tag/index.css @@ -4,23 +4,24 @@ width: 100%; } +.sf-metadata-tags-table-row.freezed .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-actions, .sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-actions { display: flex; align-items: center; justify-content: center; } -.sf-metadata-tags-table-cell-tag { +.sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.sf-metadata-tags-table-cell-tag span:hover { +.sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag span:hover { cursor: pointer; } -.sf-metadata-tags-table-cell-tag .sf-metadata-tag-color { +.sf-metadata-tags-table-row .sf-metadata-tags-table-cell-tag .sf-metadata-tag-color { display: inline-block; height: 10px; width: 10px; @@ -28,7 +29,7 @@ margin-right: 8px; } -.sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action { +.sf-metadata-tags-table-row .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action { height: 20px; width: 20px; display: flex; @@ -36,6 +37,11 @@ justify-content: center; } -.sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action .op-icon { +.sf-metadata-tags-table-row .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action .sf-dropdown-toggle, +.sf-metadata-tags-table-row .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action .op-icon { margin-left: 0; } + +.sf-metadata-tags-table-cell-parent-tags .sf-metadata-ui.tags-formatter .sf-metadata-ui-tags-container .sf-metadata-ui-tag { + cursor: default; +} diff --git a/frontend/src/tag/views/all-tags/main/tag/index.js b/frontend/src/tag/views/all-tags/main/tag/index.js index 8b7ecccbc7..501b9a2323 100644 --- a/frontend/src/tag/views/all-tags/main/tag/index.js +++ b/frontend/src/tag/views/all-tags/main/tag/index.js @@ -1,22 +1,33 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import { getTagName, getTagColor, getTagFilesCount, getTagId } from '../../../../utils/cell/core'; -import { gettext } from '../../../../../utils/constants'; +import classnames from 'classnames'; +import { FileTagsFormatter } from '@seafile/sf-metadata-ui-component'; import EditTagDialog from '../../../../components/dialog/edit-tag-dialog'; import DeleteConfirmDialog from '../../../../../metadata/components/dialog/delete-confirm-dialog'; +import TagMoreOperation from './tag-more-operation'; +import SetLinkedTagsPopover from '../../../../components/popover/set-linked-tags-popover'; +import { getTagName, getTagColor, getTagFilesCount, getTagId, getParentLinks, getSubTagsCount, getSubLinks } from '../../../../utils/cell/core'; +import { gettext } from '../../../../../utils/constants'; import { useTags } from '../../../../hooks'; +import { PRIVATE_COLUMN_KEY } from '../../../../constants'; import './index.css'; const Tag = ({ tags, tag, context, onChangeDisplayTag }) => { + const { tagsData, updateTag, deleteTags, addTagLinks, deleteTagLinks } = useTags(); const tagId = getTagId(tag); const tagName = getTagName(tag); const tagColor = getTagColor(tag); + const parentLinks = getParentLinks(tag); + const subLinks = getSubLinks(tag); + const subTagsCount = getSubTagsCount(tag); const fileCount = getTagFilesCount(tag); const [isShowEditTagDialog, setShowEditTagDialog] = useState(false); const [isShowDeleteDialog, setShowDeleteDialog] = useState(false); + const [freeze, setFreeze] = useState(false); + const [editingColumnKey, setEditingColumnKey] = useState(null); - const { updateTag, deleteTags } = useTags(); + const operationsPopHandlerRef = useRef(null); const openEditTagDialog = useCallback(() => { setShowEditTagDialog(true); @@ -48,23 +59,69 @@ const Tag = ({ tags, tag, context, onChangeDisplayTag }) => { } }, [tagId, onChangeDisplayTag]); + const freezeItem = useCallback(() => { + setFreeze(true); + }, []); + + const unfreezeItem = useCallback(() => { + setFreeze(false); + }, []); + + const hideSetLinkedTagsPopover = useCallback(() => { + setEditingColumnKey(null); + }, []); + + const showParentTagsSetter = useCallback(() => { + setEditingColumnKey(PRIVATE_COLUMN_KEY.PARENT_LINKS); + }, []); + + const showSubTagsSetter = useCallback(() => { + setEditingColumnKey(PRIVATE_COLUMN_KEY.SUB_LINKS); + }, []); + + const getEditingTagLinks = useCallback(() => { + return editingColumnKey === PRIVATE_COLUMN_KEY.PARENT_LINKS ? parentLinks : subLinks; + }, [editingColumnKey, parentLinks, subLinks]); + + const handleAddTagLinks = useCallback((linkedTag) => { + const { _id: otherTagId } = linkedTag; + addTagLinks(editingColumnKey, tagId, [otherTagId]); + }, [editingColumnKey, tagId, addTagLinks]); + + const handleDeleteTagLinks = useCallback((otherTagId) => { + deleteTagLinks(editingColumnKey, tagId, [otherTagId]); + }, [editingColumnKey, tagId, deleteTagLinks]); + return ( <> -
+
- {tagName} + {tagName}
-
{fileCount}
-
+
+ +
+
{subTagsCount}
+
{fileCount}
+
+
{context.canModifyTag() && ( -
- -
+ <> + +
+ +
+ )} {context.checkCanDeleteTag() && ( -
+
)} @@ -77,6 +134,17 @@ const Tag = ({ tags, tag, context, onChangeDisplayTag }) => { {isShowDeleteDialog && ( )} + {editingColumnKey && ( + + )} ); }; diff --git a/frontend/src/tag/views/all-tags/main/tag/tag-more-operation.js b/frontend/src/tag/views/all-tags/main/tag/tag-more-operation.js new file mode 100644 index 0000000000..d47ce38329 --- /dev/null +++ b/frontend/src/tag/views/all-tags/main/tag/tag-more-operation.js @@ -0,0 +1,62 @@ +import React, { useCallback, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import ItemDropdownMenu from '../../../../../components/dropdown-menu/item-dropdown-menu'; +import { gettext } from '../../../../../utils/constants'; +import { isMobile } from '../../../../../utils/utils'; + +const KEY_MORE_OPERATION = { + SET_PARENT_TAGS: 'set_parent_tags', + SET_SUB_TAGS: 'set_sub_tags', +}; + +const TagMoreOperation = ({ freezeItem, unfreezeItem, showParentTagsSetter, showSubTagsSetter }) => { + + const operationMenus = useMemo(() => { + let menus = []; + menus.push( + { key: KEY_MORE_OPERATION.SET_PARENT_TAGS, value: gettext('Set parent tags') }, + { key: KEY_MORE_OPERATION.SET_SUB_TAGS, value: gettext('Set sub tags') }, + ); + return menus; + }, []); + + const clickMenu = useCallback((key) => { + switch (key) { + case KEY_MORE_OPERATION.SET_PARENT_TAGS: { + showParentTagsSetter(); + return; + } + case KEY_MORE_OPERATION.SET_SUB_TAGS: { + showSubTagsSetter(); + return; + } + default: { + return; + } + } + }, [showParentTagsSetter, showSubTagsSetter]); + + return ( +
+ operationMenus} + onMenuItemClick={clickMenu} + menuStyle={isMobile ? { zIndex: 1050 } : {}} + /> +
+ ); +}; + +TagMoreOperation.propTypes = { + freezeItem: PropTypes.func, + unfreezeItem: PropTypes.func, + showParentTagsSetter: PropTypes.func, + showSubTagsSetter: PropTypes.func, +}; + +export default TagMoreOperation; diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py index 4bed850be3..90a9e0081a 100644 --- a/seahub/repo_metadata/apis.py +++ b/seahub/repo_metadata/apis.py @@ -16,7 +16,7 @@ from seahub.views import check_folder_permission from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id, init_metadata, \ get_unmodifiable_columns, can_read_metadata, init_faces, \ extract_file_details, get_someone_similar_faces, remove_faces_table, FACES_SAVE_PATH, \ - init_tags, remove_tags_table, add_init_face_recognition_task, init_ocr, remove_ocr_column + init_tags, init_tag_self_link_columns, remove_tags_table, add_init_face_recognition_task, init_ocr, remove_ocr_column from seahub.repo_metadata.metadata_server_api import MetadataServerAPI, list_metadata_view_records from seahub.utils.timeutils import datetime_to_isoformat_timestr from seahub.utils.repo import is_repo_admin @@ -1936,6 +1936,225 @@ class MetadataTags(APIView): return Response({'success': True}) +class MetadataTagsLinks(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def post(self, request, repo_id): + link_column_key = request.data.get('link_column_key') + row_id_map = request.data.get('row_id_map') + + if not link_column_key: + return api_error(status.HTTP_400_BAD_REQUEST, 'link_column_key invalid') + + if not row_id_map: + return api_error(status.HTTP_400_BAD_REQUEST, 'row_id_map invalid') + + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled: + error_msg = f'The metadata module 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) + + try: + metadata = metadata_server_api.get_metadata() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + from seafevents.repo_metadata.constants import TAGS_TABLE + tables = metadata.get('tables', []) + tags_table_id = [table['id'] for table in tables if table['name'] == TAGS_TABLE.name] + tags_table_id = tags_table_id[0] if tags_table_id else None + if not tags_table_id: + return api_error(status.HTTP_404_NOT_FOUND, 'tags not be used') + + try: + columns_data = metadata_server_api.list_columns(tags_table_id) + columns = columns_data.get('columns', []) + + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + link_column = [column for column in columns if column['key'] == link_column_key and column['type'] == 'link'] + link_column = link_column[0] if link_column else None + if not link_column: + # init self link columns + if link_column_key == TAGS_TABLE.columns.parent_links.key or link_column_key == TAGS_TABLE.columns.sub_links.key: + try: + init_tag_self_link_columns(metadata_server_api, tags_table_id) + link_id = TAGS_TABLE.self_link_id; + is_linked_back = link_column_key == TAGS_TABLE.columns.sub_links.key if True else False + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + else: + return api_error(status.HTTP_400_BAD_REQUEST, 'link column %s not found' % link_column_key) + else: + link_column_data = link_column.get('data', {}) + link_id = link_column_data.get('link_id', '') + is_linked_back = link_column_data.get('is_linked_back', False) + + if not link_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'invalid link column') + + try: + metadata_server_api.insert_link(repo_id, link_id, tags_table_id, row_id_map, is_linked_back) + except Exception as e: + logger.exception(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + return Response({'success': True}) + + def put(self, request, repo_id): + link_column_key = request.data.get('link_column_key') + row_id_map = request.data.get('row_id_map') + + if not row_id_map: + return api_error(status.HTTP_400_BAD_REQUEST, 'row_id_map invalid') + + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled: + error_msg = f'The metadata module 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) + + try: + metadata = metadata_server_api.get_metadata() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + from seafevents.repo_metadata.constants import TAGS_TABLE + tables = metadata.get('tables', []) + tags_table_id = [table['id'] for table in tables if table['name'] == TAGS_TABLE.name] + tags_table_id = tags_table_id[0] if tags_table_id else None + if not tags_table_id: + return api_error(status.HTTP_404_NOT_FOUND, 'tags not be used') + + try: + columns_data = metadata_server_api.list_columns(tags_table_id) + columns = columns_data.get('columns', []) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + link_column = [column for column in columns if column['key'] == link_column_key and column['type'] == 'link'] + link_column = link_column[0] if link_column else None + if not link_column: + return api_error(status.HTTP_400_BAD_REQUEST, 'link column %s not found' % link_column_key) + + link_column_data = link_column.get('data', {}) + link_id = link_column_data.get('link_id', '') + is_linked_back = link_column_data.get('is_linked_back', False) + + if not link_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'invalid link column') + + try: + metadata_server_api.update_link(repo_id, link_id, tags_table_id, row_id_map, is_linked_back) + except Exception as e: + logger.exception(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + return Response({'success': True}) + + def delete(self, request, repo_id): + link_column_key = request.data.get('link_column_key') + row_id_map = request.data.get('row_id_map') + + if not link_column_key: + return api_error(status.HTTP_400_BAD_REQUEST, 'link_id invalid') + + if not row_id_map: + return api_error(status.HTTP_400_BAD_REQUEST, 'row_id_map invalid') + + metadata = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not metadata or not metadata.enabled: + error_msg = f'The metadata module 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) + + try: + metadata = metadata_server_api.get_metadata() + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + from seafevents.repo_metadata.constants import TAGS_TABLE + tables = metadata.get('tables', []) + tags_table_id = [table['id'] for table in tables if table['name'] == TAGS_TABLE.name] + tags_table_id = tags_table_id[0] if tags_table_id else None + if not tags_table_id: + return api_error(status.HTTP_404_NOT_FOUND, 'tags not be used') + + try: + columns_data = metadata_server_api.list_columns(tags_table_id) + columns = columns_data.get('columns', []) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + link_column = [column for column in columns if column['key'] == link_column_key and column['type'] == 'link'] + link_column = link_column[0] if link_column else None + if not link_column: + return api_error(status.HTTP_400_BAD_REQUEST, 'link column %s not found' % link_column_key) + + link_column_data = link_column.get('data', {}) + link_id = link_column_data.get('link_id', '') + is_linked_back = link_column_data.get('is_linked_back', False) + + if not link_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'invalid link column') + + try: + metadata_server_api.delete_link(repo_id, link_id, tags_table_id, row_id_map, is_linked_back) + except Exception as e: + logger.exception(e) + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') + + return Response({'success': True}) + + class MetadataFileTags(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated,) @@ -2013,9 +2232,9 @@ class MetadataFileTags(APIView): try: if not current_tags: - metadata_server_api.insert_link(repo_id, TAGS_TABLE.link_id, METADATA_TABLE.id, { record_id: tags }) + metadata_server_api.insert_link(repo_id, TAGS_TABLE.file_link_id, METADATA_TABLE.id, { record_id: tags }) else: - metadata_server_api.update_link(repo_id, TAGS_TABLE.link_id, METADATA_TABLE.id, { record_id: tags }) + metadata_server_api.update_link(repo_id, TAGS_TABLE.file_link_id, METADATA_TABLE.id, { record_id: tags }) success_records.append(record_id) except Exception as e: failed_records.append(record_id) diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py index b31dc4c9fe..4c4ab39089 100644 --- a/seahub/repo_metadata/metadata_server_api.py +++ b/seahub/repo_metadata/metadata_server_api.py @@ -169,6 +169,18 @@ class MetadataServerAPI: response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout) return parse_response(response) + def add_link_columns(self, link_id, table_id, other_table_id, table_column, other_table_column): + url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/link-columns' + data = { + 'link_id': link_id, + 'table_id': table_id, + 'other_table_id': other_table_id, + 'table_column': table_column, + 'other_table_column': other_table_column, + } + response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout) + return parse_response(response) + def delete_column(self, table_id, column_key, permanently=False): url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/columns' data = { @@ -211,22 +223,35 @@ class MetadataServerAPI: # link - def insert_link(self, base_id, link_id, table_id, row_id_map): + def insert_link(self, base_id, link_id, table_id, row_id_map, is_linked_back=False): url = f'{METADATA_SERVER_URL}/api/v1/base/{base_id}/links' data = { 'link_id': link_id, 'table_id': table_id, - 'row_id_map': row_id_map + 'is_linked_back': is_linked_back, + 'row_id_map': row_id_map, } response = requests.post(url, json=data, headers=self.headers, timeout=self.timeout) return parse_response(response) - def update_link(self, base_id, link_id, table_id, row_id_map): + def update_link(self, base_id, link_id, table_id, row_id_map, is_linked_back=False): url = f'{METADATA_SERVER_URL}/api/v1/base/{base_id}/links' data = { 'link_id': link_id, 'table_id': table_id, + 'is_linked_back': is_linked_back, 'row_id_map': row_id_map } response = requests.put(url, json=data, headers=self.headers, timeout=self.timeout) return parse_response(response) + + def delete_link(self, base_id, link_id, table_id, row_id_map, is_linked_back=False): + url = f'{METADATA_SERVER_URL}/api/v1/base/{base_id}/links' + data = { + 'link_id': link_id, + 'table_id': table_id, + 'is_linked_back': is_linked_back, + 'row_id_map': row_id_map + } + response = requests.delete(url, json=data, headers=self.headers, timeout=self.timeout) + return parse_response(response) diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py index 64d8b949c7..e96c7d5a06 100644 --- a/seahub/repo_metadata/urls.py +++ b/seahub/repo_metadata/urls.py @@ -2,7 +2,7 @@ from django.urls import re_path from .apis import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \ MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ - MetadataFileTags, MetadataTagFiles, MetadataDetailsSettingsView, MetadataOCRManageView + MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataDetailsSettingsView, MetadataOCRManageView urlpatterns = [ re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'), @@ -34,6 +34,7 @@ urlpatterns = [ # tags api re_path(r'^tags-status/$', MetadataTagsStatusManage.as_view(), name='api-v2.1-metadata-tags-status'), re_path(r'^tags/$', MetadataTags.as_view(), name='api-v2.1-metadata-tags'), + re_path(r'^tags-links/$', MetadataTagsLinks.as_view(), name='api-v2.1-metadata-tags-links'), re_path(r'^file-tags/$', MetadataFileTags.as_view(), name='api-v2.1-metadata-file-tags'), re_path(r'^tag-files/(?P.+)/$', MetadataTagFiles.as_view(), name='api-v2.1-metadata-tag-files'), ] diff --git a/seahub/repo_metadata/utils.py b/seahub/repo_metadata/utils.py index e14f189ddd..2f90f63e4c 100644 --- a/seahub/repo_metadata/utils.py +++ b/seahub/repo_metadata/utils.py @@ -177,52 +177,71 @@ def remove_faces_table(metadata_server_api): # tag -def get_tag_link_column(table_id): - from seafevents.repo_metadata.constants import METADATA_TABLE, TAGS_TABLE - columns = [ - METADATA_TABLE.columns.tags.to_dict({ - 'link_id': TAGS_TABLE.link_id, - 'table_id': METADATA_TABLE.id, - 'other_table_id': table_id, - 'display_column_key': TAGS_TABLE.columns.name.key, - }), - ] - - return columns - - def get_tag_columns(table_id): - from seafevents.repo_metadata.constants import METADATA_TABLE, TAGS_TABLE + from seafevents.repo_metadata.constants import TAGS_TABLE columns = [ TAGS_TABLE.columns.name.to_dict(), TAGS_TABLE.columns.color.to_dict(), - TAGS_TABLE.columns.file_links.to_dict({ - 'link_id': TAGS_TABLE.link_id, - 'table_id': METADATA_TABLE.id, - 'other_table_id': table_id, - 'display_column_key': METADATA_TABLE.columns.id.key, - }), ] return columns -def init_tags(metadata_server_api): +def init_tag_file_links_column(metadata_server_api, tag_table_id): from seafevents.repo_metadata.constants import METADATA_TABLE, TAGS_TABLE + file_link_id = TAGS_TABLE.file_link_id + table_id = METADATA_TABLE.id + other_table_id = tag_table_id + table_column = { + 'key': METADATA_TABLE.columns.tags.key, + 'name': METADATA_TABLE.columns.tags.name, + 'display_column_key': TAGS_TABLE.columns.name.name, + } + other_table_column = { + 'key': TAGS_TABLE.columns.file_links.key, + 'name': TAGS_TABLE.columns.file_links.name, + 'display_column_key': TAGS_TABLE.columns.id.key, + } + metadata_server_api.add_link_columns(file_link_id, table_id, other_table_id, table_column, other_table_column) + +def init_tag_self_link_columns(metadata_server_api, tag_table_id): + from seafevents.repo_metadata.constants import TAGS_TABLE + link_id = TAGS_TABLE.self_link_id + table_id = tag_table_id + other_table_id = tag_table_id + + # as parent tags which is_linked_back is false + table_column = { + 'key': TAGS_TABLE.columns.parent_links.key, + 'name': TAGS_TABLE.columns.parent_links.name, + 'display_column_key': TAGS_TABLE.columns.id.key, + } + + # as sub tags which is_linked_back is true + other_table_column = { + 'key': TAGS_TABLE.columns.sub_links.key, + 'name': TAGS_TABLE.columns.sub_links.name, + 'display_column_key': TAGS_TABLE.columns.id.key, + } + metadata_server_api.add_link_columns(link_id, table_id, other_table_id, table_column, other_table_column) + + +def init_tags(metadata_server_api): + from seafevents.repo_metadata.constants import TAGS_TABLE + remove_tags_table(metadata_server_api) resp = metadata_server_api.create_table(TAGS_TABLE.name) table_id = resp['id'] - # init link column - link_column = get_tag_link_column(table_id) - metadata_server_api.add_columns(METADATA_TABLE.id, link_column) - # init columns tag_columns = get_tag_columns(table_id) metadata_server_api.add_columns(table_id, tag_columns) + # init link columns + init_tag_file_links_column(metadata_server_api, table_id) + init_tag_self_link_columns(metadata_server_api, table_id) def remove_tags_table(metadata_server_api): from seafevents.repo_metadata.constants import METADATA_TABLE, TAGS_TABLE