-
- {this.state.currentRepoInfo.status === 'read-only' &&
-
- {gettext('This library has been set to read-only by admin and cannot be updated.')}
-
- }
-
-
- {isDirentSelected ?
-
- :
-
+
+
+
+
+
+
+ {this.state.currentRepoInfo.status === 'read-only' &&
+
+ {gettext('This library has been set to read-only by admin and cannot be updated.')}
+
}
-
- {isDesktop &&
-
-
+
+
+ {isDirentSelected ?
+
+ :
+
+ }
+
+ {isDesktop &&
+
+
+
+ }
- }
-
-
- {this.state.pathExist ?
-
+ {this.state.pathExist ?
+
+ :
+ {gettext('Folder does not exist.')}
+ }
+ {this.state.isDirentDetailShow && (
+
+ )}
+
+
+ {canUpload && this.state.pathExist && !this.state.isViewFile && this.state.currentMode !== METADATA_MODE && (
+ this.uploader = uploader}
+ dragAndDrop={true}
path={this.state.path}
repoID={this.props.repoID}
- currentRepoInfo={this.state.currentRepoInfo}
- isGroupOwnedRepo={this.state.isGroupOwnedRepo}
- userPerm={userPerm}
- enableDirPrivateShare={enableDirPrivateShare}
- isTreeDataLoading={this.state.isTreeDataLoading}
- treeData={this.state.treeData}
- currentNode={this.state.currentNode}
- onNodeClick={this.onTreeNodeClick}
- onNodeCollapse={this.onTreeNodeCollapse}
- onNodeExpanded={this.onTreeNodeExpanded}
- onAddFolderNode={this.onAddFolder}
- onAddFileNode={this.onAddFile}
- onRenameNode={this.onRenameTreeNode}
- onDeleteNode={this.onDeleteTreeNode}
- isViewFile={this.state.isViewFile}
- isFileLoading={this.state.isFileLoading}
- filePermission={this.state.filePermission}
- content={this.state.content}
- viewId={this.state.viewId}
- lastModified={this.state.lastModified}
- latestContributor={this.state.latestContributor}
- onLinkClick={this.onLinkClick}
- isRepoInfoBarShow={isRepoInfoBarShow}
- repoTags={this.state.repoTags}
- usedRepoTags={this.state.usedRepoTags}
- updateUsedRepoTags={this.updateUsedRepoTags}
- isDirentListLoading={this.state.isDirentListLoading}
- direntList={direntItemsList}
- fullDirentList={this.state.direntList}
- sortBy={this.state.sortBy}
- sortOrder={this.state.sortOrder}
- sortItems={this.sortItems}
- onAddFolder={this.onAddFolder}
- onAddFile={this.onAddFile}
- onItemClick={this.onItemClick}
- onItemSelected={this.onDirentSelected}
- onItemDelete={this.onMainPanelItemDelete}
- onItemRename={this.onMainPanelItemRename}
- deleteFilesCallback={this.deleteItemsAjaxCallback}
- renameFileCallback={this.renameItemAjaxCallback}
- onItemMove={this.onMoveItem}
- onItemCopy={this.onCopyItem}
- onItemConvert={this.onConvertItem}
- onDirentClick={this.onDirentClick}
- updateDirent={this.updateDirent}
- isAllItemSelected={this.state.isAllDirentSelected}
- onAllItemSelected={this.onAllDirentSelected}
- selectedDirentList={this.state.selectedDirentList}
- onSelectedDirentListUpdate={this.onSelectedDirentListUpdate}
- onItemsMove={this.onMoveItems}
- onItemsCopy={this.onCopyItems}
- onItemsDelete={this.onDeleteItems}
- onFileTagChanged={this.onFileTagChanged}
- showDirentDetail={this.showDirentDetail}
- onItemsScroll={this.onItemsScroll}
- eventBus={this.props.eventBus}
- updateCurrentDirent={this.updateCurrentDirent}
- closeDirentDetail={this.closeDirentDetail}
- />
- :
- {gettext('Folder does not exist.')}
- }
- {this.state.isDirentDetailShow && (
-
)}
-
- {canUpload && this.state.pathExist && !this.state.isViewFile && this.state.currentMode !== METADATA_MODE && (
-
this.uploader = uploader}
- dragAndDrop={true}
- path={this.state.path}
- repoID={this.props.repoID}
- direntList={this.state.direntList}
- onFileUploadSuccess={this.onFileUploadSuccess}
- isCustomPermission={isCustomPermission}
- />
- )}
-
- {isCopyMoveProgressDialogShow && (
-
- )}
- {isDeleteFolderDialogOpen && (
-
- )}
-
-
-
-
-
+ {isCopyMoveProgressDialogShow && (
+
+ )}
+ {isDeleteFolderDialogOpen && (
+
+ )}
+
+
+
+
+
+
+
);
}
}
diff --git a/frontend/src/tag/api.js b/frontend/src/tag/api.js
new file mode 100644
index 0000000000..06200b1d53
--- /dev/null
+++ b/frontend/src/tag/api.js
@@ -0,0 +1,81 @@
+import cookie from 'react-cookies';
+import { siteRoot } from '../utils/constants';
+import { MetadataManagerAPI } from '../metadata';
+
+class TagsManagerAPI extends MetadataManagerAPI {
+
+ getTagsStatus = (repoID) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags-status/';
+ return this.req.get(url);
+ };
+
+ openTags = (repoID) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags-status/';
+ return this.req.put(url);
+ };
+
+ closeTags = (repoID) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags-status/';
+ return this.req.delete(url);
+ };
+
+ getTags = (repoID) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags/';
+ return this.req.get(url);
+ };
+
+ modifyTags = (repoID, data) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags/';
+ const params = {
+ tags_data: data,
+ };
+ return this.req.put(url, params);
+ };
+
+ deleteTags = (repoID, tagIds) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags/';
+ const params = {
+ tag_ids: tagIds,
+ };
+ return this.req.delete(url, { data: params });
+ };
+
+ addTags = (repoID, tags) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tags/';
+ const params = {
+ tags_data: tags
+ };
+ return this.req.post(url, params);
+ };
+
+ getTagFiles = (repoID, tagID) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/tag-files/' + tagID + '/';
+ return this.req.get(url);
+ };
+
+ // file tag
+ addFileTags = (repoID, recordId, tagIds) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/file-tags/';
+ const params = {
+ record_id: recordId,
+ tags: tagIds,
+ };
+ return this.req.post(url, params);
+ };
+
+ updateFileTags = (repoID, recordId, tagIds) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/file-tags/';
+ const params = {
+ record_id: recordId,
+ tags: tagIds,
+ };
+ return this.req.put(url, params);
+ };
+
+}
+
+const tagsAPI = new TagsManagerAPI();
+const xcsrfHeaders = cookie.load('sfcsrftoken');
+tagsAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders });
+
+export default tagsAPI;
diff --git a/frontend/src/tag/components/dialog/edit-tag-dialog/index.css b/frontend/src/tag/components/dialog/edit-tag-dialog/index.css
new file mode 100644
index 0000000000..fa81d71d3b
--- /dev/null
+++ b/frontend/src/tag/components/dialog/edit-tag-dialog/index.css
@@ -0,0 +1,33 @@
+.sf-metadata-tags-edit-dialog {
+ width: 446px;
+}
+
+.sf-metadata-tags-edit-dialog .sf-metadata-edit-tag-color-input {
+ position: absolute;
+ z-index: -1;
+ opacity: 0;
+}
+
+.sf-metadata-tags-edit-dialog .col-auto {
+ padding-left: 3px;
+}
+
+.sf-metadata-tags-edit-dialog .sf-metadata-edit-tag-color-container {
+ width: 1.75rem;
+ height: 1.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 3px;
+ border: 1px solid rgba(0, 40, 100, .12);
+ color: #fff;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, .05);
+}
+
+.sf-metadata-tags-edit-dialog .sf-metadata-edit-tag-color-container::before {
+ display: none;
+}
+
+.sf-metadata-tags-edit-dialog .sf-metadata-edit-tag-color-container:not(.selected) .sf-metadata-icon-check-mark {
+ display: none;
+}
diff --git a/frontend/src/tag/components/dialog/edit-tag-dialog/index.js b/frontend/src/tag/components/dialog/edit-tag-dialog/index.js
new file mode 100644
index 0000000000..58bfbfcb2e
--- /dev/null
+++ b/frontend/src/tag/components/dialog/edit-tag-dialog/index.js
@@ -0,0 +1,125 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+import { Modal, ModalHeader, ModalBody, ModalFooter, Form, FormGroup, Input, Button, Alert, Label } from 'reactstrap';
+import classnames from 'classnames';
+import { IconBtn } from '@seafile/sf-metadata-ui-component';
+import { gettext } from '../../../../utils/constants';
+import { getTagColor, getTagId, getTagName } from '../../../utils/cell/core';
+import { SELECT_OPTION_COLORS } from '../../../../metadata/constants';
+import { isEnter } from '../../../../metadata/utils/hotkey';
+import { isValidTagName } from '../../../utils';
+import { PRIVATE_COLUMN_KEY } from '../../../constants';
+import toaster from '../../../../components/toast';
+
+import './index.css';
+
+const EditTagDialog = ({ tags, tag, title, onSubmit, onToggle }) => {
+ const [name, setName] = useState(getTagName(tag));
+ const [color, setColor] = useState(getTagColor(tag) || SELECT_OPTION_COLORS[0].COLOR);
+ const [errMessage, setErrorMessage] = useState('');
+ const [isSubmitting, setSubmitting] = useState(false);
+
+ const otherTagsName = useMemo(() => {
+ const tagId = getTagId(tag);
+ return tags.filter(tagItem => getTagId(tagItem) !== tagId).map(tagItem => getTagName(tagItem));
+ }, [tags, tag]);
+
+ const handleSubmit = useCallback(() => {
+ setSubmitting(true);
+ const { isValid, message } = isValidTagName(name, otherTagsName);
+ if (!isValid) {
+ setErrorMessage(message);
+ setSubmitting(false);
+ return;
+ }
+ onSubmit({ [PRIVATE_COLUMN_KEY.TAG_COLOR]: color, [PRIVATE_COLUMN_KEY.TAG_NAME]: name }, {
+ success_callback: () => {
+ onToggle();
+ },
+ fail_callback: (error) => {
+ toaster.danger(error);
+ setSubmitting(false);
+ }
+ });
+ }, [name, color, otherTagsName, onToggle, onSubmit]);
+
+ const handleKeyDown = useCallback((event) => {
+ if (isEnter(event)) {
+ handleSubmit();
+ event.preventDefault();
+ }
+ }, [handleSubmit]);
+
+ const handleChange = useCallback((event) => {
+ const newValue = event.target.value;
+ if (newValue === name) return;
+ setName(newValue);
+ }, [name]);
+
+ const handelColorChange = useCallback((event) => {
+ event.stopPropagation();
+ event.nativeEvent.stopImmediatePropagation();
+ const newColor = event.target.value;
+ if (newColor === color) return;
+ setColor(newColor);
+ }, [color]);
+
+ return (
+
+ {title}
+
+
+
+ {errMessage && {errMessage}}
+
+
+
+
+
+
+ );
+};
+
+EditTagDialog.propTypes = {
+ tags: PropTypes.array,
+ tag: PropTypes.object,
+ title: PropTypes.string,
+ onSubmit: PropTypes.func,
+ onToggle: PropTypes.func,
+};
+
+export default EditTagDialog;
diff --git a/frontend/src/tag/components/tag-view-name.js b/frontend/src/tag/components/tag-view-name.js
new file mode 100644
index 0000000000..df916972a2
--- /dev/null
+++ b/frontend/src/tag/components/tag-view-name.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useTags } from '../hooks';
+import { getRowById } from '../../metadata/utils/table';
+import { getTagName } from '../utils';
+import { TAG_MANAGEMENT_ID } from '../constants';
+import { gettext } from '../../utils/constants';
+
+const TagViewName = ({ id }) => {
+ const { tagsData } = useTags();
+ if (!id) return null;
+ if (id === TAG_MANAGEMENT_ID) return gettext('Tags management');
+ const tag = getRowById(tagsData, id);
+ if (!tag) return null;
+ return (<>{getTagName(tag)}>);
+};
+
+TagViewName.propTypes = {
+ id: PropTypes.string,
+};
+
+export default TagViewName;
diff --git a/frontend/src/tag/constants/column/index.js b/frontend/src/tag/constants/column/index.js
new file mode 100644
index 0000000000..dd0e78c87d
--- /dev/null
+++ b/frontend/src/tag/constants/column/index.js
@@ -0,0 +1 @@
+export * from './private';
diff --git a/frontend/src/tag/constants/column/private.js b/frontend/src/tag/constants/column/private.js
new file mode 100644
index 0000000000..d54fb03006
--- /dev/null
+++ b/frontend/src/tag/constants/column/private.js
@@ -0,0 +1,29 @@
+export const PRIVATE_COLUMN_KEY = {
+ ID: '_id',
+
+ // base key
+ CTIME: '_ctime',
+ MTIME: '_mtime',
+ CREATOR: '_creator',
+ LAST_MODIFIER: '_last_modifier',
+ TAG_NAME: '_tag_name',
+ TAG_COLOR: '_tag_color',
+ TAG_FILE_LINKS: '_tag_file_links',
+};
+
+export const PRIVATE_COLUMN_KEYS = [
+ PRIVATE_COLUMN_KEY.ID,
+ PRIVATE_COLUMN_KEY.CTIME,
+ PRIVATE_COLUMN_KEY.MTIME,
+ PRIVATE_COLUMN_KEY.CREATOR,
+ PRIVATE_COLUMN_KEY.LAST_MODIFIER,
+ PRIVATE_COLUMN_KEY.TAG_NAME,
+ PRIVATE_COLUMN_KEY.TAG_COLOR,
+ PRIVATE_COLUMN_KEY.TAG_FILE_LINKS,
+];
+
+export const EDITABLE_PRIVATE_COLUMN_KEYS = [
+ PRIVATE_COLUMN_KEY.TAG_NAME,
+ PRIVATE_COLUMN_KEY.TAG_COLOR,
+ PRIVATE_COLUMN_KEY.TAG_FILE_LINKS,
+];
diff --git a/frontend/src/tag/constants/common.js b/frontend/src/tag/constants/common.js
new file mode 100644
index 0000000000..22b147af7e
--- /dev/null
+++ b/frontend/src/tag/constants/common.js
@@ -0,0 +1 @@
+export const TAG_MANAGEMENT_ID = '__tag_management';
diff --git a/frontend/src/tag/constants/index.js b/frontend/src/tag/constants/index.js
new file mode 100644
index 0000000000..a7189b9bd5
--- /dev/null
+++ b/frontend/src/tag/constants/index.js
@@ -0,0 +1,2 @@
+export * from './column';
+export * from './common';
diff --git a/frontend/src/tag/context.js b/frontend/src/tag/context.js
new file mode 100644
index 0000000000..b4e5a421e6
--- /dev/null
+++ b/frontend/src/tag/context.js
@@ -0,0 +1,114 @@
+import tagsAPI from './api';
+import LocalStorage from '../metadata/utils/local-storage';
+import EventBus from '../components/common/event-bus';
+import { username, lang } from '../utils/constants';
+
+class Context {
+
+ constructor() {
+ this.settings = { lang };
+ this.repoId = '';
+ this.api = null;
+ this.localStorage = null;
+ this.eventBus = null;
+ this.hasInit = false;
+ this.permission = 'r';
+ }
+
+ async init(settings) {
+ if (this.hasInit) return;
+
+ // init settings
+ this.settings = { ...this.settings, ...settings };
+
+ // init api
+ const { repoInfo } = this.settings;
+ this.api = tagsAPI;
+
+ // init localStorage
+ const { repoID } = this.settings;
+ const localStorageName = `sf-metadata-tags-${repoID}`;
+ this.localStorage = new LocalStorage(localStorageName);
+
+ this.repoId = repoID;
+
+ const eventBus = new EventBus();
+ this.eventBus = eventBus;
+
+ this.permission = repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw';
+
+ this.hasInit = true;
+ }
+
+ destroy = () => {
+ this.settings = {};
+ this.repoId = '';
+ this.api = null;
+ this.localStorage = null;
+ this.eventBus = null;
+ this.hasInit = false;
+ this.permission = 'r';
+ };
+
+ getSetting = (key) => {
+ if (this.settings[key] === false) return this.settings[key];
+ return this.settings[key] || '';
+ };
+
+ setSetting = (key, value) => {
+ this.settings[key] = value;
+ };
+
+ getUsername = () => {
+ return username;
+ };
+
+ getPermission = () => {
+ return this.permission;
+ };
+
+ canModify = () => {
+ if (this.permission === 'r') return false;
+ return true;
+ };
+
+ canAddTag = () => {
+ if (this.permission === 'r') return false;
+ return true;
+ };
+
+ canModifyTag = (tag) => {
+ if (this.permission === 'r') return false;
+ return true;
+ };
+
+ checkCanDeleteTag = () => {
+ if (this.permission === 'r') return false;
+ return true;
+ };
+
+ canModifyTags = () => {
+ if (this.permission === 'r') return false;
+ return true;
+ };
+
+ // tags
+ getTags = () => {
+ return this.api.getTags(this.repoId);
+ };
+
+ addTags = (tags = []) => {
+ return this.api.addTags(this.repoId, tags);
+ };
+
+ modifyTags = (tags = []) => {
+ return this.api.modifyTags(this.repoId, tags);
+ };
+
+ deleteTags = (tags = []) => {
+ return this.api.deleteTags(this.repoId, tags);
+ };
+
+}
+
+export default Context;
diff --git a/frontend/src/tag/hooks/index.js b/frontend/src/tag/hooks/index.js
new file mode 100644
index 0000000000..2505854505
--- /dev/null
+++ b/frontend/src/tag/hooks/index.js
@@ -0,0 +1,2 @@
+export { TagsProvider, useTags } from './tags';
+export { TagViewProvider, useTagView } from './tag-view';
diff --git a/frontend/src/tag/hooks/tag-view.js b/frontend/src/tag/hooks/tag-view.js
new file mode 100644
index 0000000000..aba5b2ca2f
--- /dev/null
+++ b/frontend/src/tag/hooks/tag-view.js
@@ -0,0 +1,64 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Utils } from '../../utils/utils';
+import tagsAPI from '../api';
+import { useTags } from './tags';
+import { PRIVATE_COLUMN_KEY } from '../constants';
+import { getRecordIdFromRecord } from '../../metadata/utils/cell';
+
+// This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc.
+const TagViewContext = React.createContext(null);
+
+export const TagViewProvider = ({ repoID, tagID, children, ...params }) => {
+ const [isLoading, setLoading] = useState(true);
+ const [tagFiles, setTagFiles] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const { updateLocalTag } = useTags();
+
+ useEffect(() => {
+ setLoading(true);
+ tagsAPI.getTagFiles(repoID, tagID).then(res => {
+ const rows = res.data?.results || [];
+ setTagFiles({ columns: res.data?.metadata || [], rows: res.data?.results || [] });
+ updateLocalTag(tagID, {
+ [PRIVATE_COLUMN_KEY.TAG_FILE_LINKS]: rows.map(r => {
+ const recordId = getRecordIdFromRecord(r);
+ return {
+ row_id: recordId,
+ display_value: recordId
+ };
+ })
+ });
+ setLoading(false);
+ }).catch(error => {
+ const errorMessage = Utils.getErrorMsg(error);
+ setErrorMessage(errorMessage);
+ setLoading(false);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [repoID, tagID]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useTagView = () => {
+ const context = useContext(TagViewContext);
+ if (!context) {
+ throw new Error('\'TagViewContext\' is null');
+ }
+ return context;
+};
diff --git a/frontend/src/tag/hooks/tags.js b/frontend/src/tag/hooks/tags.js
new file mode 100644
index 0000000000..639329e700
--- /dev/null
+++ b/frontend/src/tag/hooks/tags.js
@@ -0,0 +1,211 @@
+import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
+import { Utils } from '../../utils/utils';
+import toaster from '../../components/toast';
+import { useMetadataStatus } from '../../hooks';
+import { PRIVATE_FILE_TYPE } from '../../constants';
+import { getTagColor, getTagId, getTagName, getCellValueByColumn } from '../utils/cell/core';
+import Context from '../context';
+import Store from '../store';
+import { PER_LOAD_NUMBER, EVENT_BUS_TYPE } from '../../metadata/constants';
+import { getRowById } from '../../metadata/utils/table';
+import { gettext } from '../../utils/constants';
+import { PRIVATE_COLUMN_KEY } from '../constants';
+import { getColumnOriginName } from '../../metadata/utils/column';
+
+// This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc.
+const TagsContext = React.createContext(null);
+
+export const TagsProvider = ({ repoID, selectTagsView, children, ...params }) => {
+
+ const [isLoading, setLoading] = useState(true);
+ const [tagsData, setTagsData] = useState(null);
+
+ const storeRef = useRef(null);
+ const contextRef = useRef(null);
+
+ const { enableMetadata, enableTags } = useMetadataStatus();
+
+ const tagsChanged = useCallback(() => {
+ setTagsData(storeRef.current.data);
+ }, []);
+
+ const handleTableError = useCallback((error) => {
+ toaster.danger(error.error);
+ }, []);
+
+ const updateTags = useCallback((data) => {
+ setTagsData(data);
+ }, []);
+
+ const reloadTags = useCallback(() => {
+ setLoading(true);
+ storeRef.current.reload(PER_LOAD_NUMBER).then(() => {
+ setTagsData(storeRef.current.data);
+ setLoading(false);
+ }).catch(error => {
+ const errorMsg = Utils.getErrorMsg(error);
+ toaster.danger(errorMsg);
+ setLoading(false);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (enableMetadata && enableTags) {
+ setLoading(true);
+ // init context
+ contextRef.current = new Context();
+ contextRef.current.init({ ...params, repoID });
+ window.sfTagsDataContext = contextRef.current;
+ storeRef.current = new Store({ context: contextRef.current, repoId: repoID });
+ window.sfTagsDataStore = storeRef.current;
+ storeRef.current.initStartIndex();
+ storeRef.current.load(PER_LOAD_NUMBER).then(() => {
+ setTagsData(storeRef.current.data);
+ setLoading(false);
+ }).catch(error => {
+ const errorMsg = Utils.getErrorMsg(error);
+ toaster.danger(errorMsg);
+ });
+ const eventBus = contextRef.current.eventBus;
+ const unsubscribeServerTagsChanged = eventBus.subscribe(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED, tagsChanged);
+ const unsubscribeTagsChanged = eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_TABLE_CHANGED, tagsChanged);
+ const unsubscribeHandleTableError = eventBus.subscribe(EVENT_BUS_TYPE.TABLE_ERROR, handleTableError);
+ const unsubscribeUpdateRows = eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_TABLE_ROWS, updateTags);
+ const unsubscribeReloadData = eventBus.subscribe(EVENT_BUS_TYPE.RELOAD_DATA, reloadTags);
+ return () => {
+ if (window.sfTagsDataContext) {
+ window.sfTagsDataContext.destroy();
+ }
+ storeRef.current.destroy();
+ unsubscribeServerTagsChanged();
+ unsubscribeTagsChanged();
+ unsubscribeHandleTableError();
+ unsubscribeUpdateRows();
+ unsubscribeReloadData();
+ };
+ }
+ setTagsData(null);
+ setLoading(false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [repoID, enableMetadata, enableTags]);
+
+ const handelSelectTag = useCallback((tag, isSelected) => {
+ if (isSelected) return;
+ const id = getTagId(tag);
+ const node = {
+ children: [],
+ path: '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + id,
+ isExpanded: false,
+ isLoaded: true,
+ isPreload: true,
+ object: {
+ file_tags: [],
+ id: id,
+ type: PRIVATE_FILE_TYPE.TAGS_PROPERTIES,
+ isDir: () => false,
+ },
+ parentNode: {},
+ key: repoID,
+ tag_id: id,
+ };
+ selectTagsView(node);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [repoID, selectTagsView]);
+
+ const addTag = useCallback((row, callback) => {
+ return storeRef.current.addTags([row], callback);
+ }, []);
+
+ const modifyTags = useCallback((tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback }) => {
+ storeRef.current.modifyTags(tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback });
+ }, [storeRef]);
+
+ const modifyLocalTags = useCallback((tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback }) => {
+ storeRef.current.modifyLocalTags(tagIds, idTagUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, { success_callback, fail_callback });
+ }, [storeRef]);
+
+ const deleteTags = useCallback((tagIds) => {
+ storeRef.current.deleteTags(tagIds);
+ }, [storeRef]);
+
+ const duplicateTag = useCallback((tagId) => {
+ const tag = getRowById(tagsData, tagId);
+ if (!tag) return;
+ const newTag = {
+ [PRIVATE_COLUMN_KEY.TAG_NAME]: `${getTagName(tag)}(${gettext('copy')})`,
+ [PRIVATE_COLUMN_KEY.TAG_COLOR]: getTagColor(tag),
+ };
+ addTag(newTag, {
+ success_callback: (operation) => {
+ const copiedTag = operation.tags[0];
+ handelSelectTag(copiedTag);
+ }
+ });
+ }, [tagsData, addTag, handelSelectTag]);
+
+ const updateTag = useCallback((tagId, update, { success_callback, fail_callback } = { }) => {
+ const tag = getRowById(tagsData, tagId);
+ const tagIds = [tagId];
+ const idTagUpdates = { [tagId]: update };
+ let originalRowUpdates = {};
+ let oldRowData = {};
+ let originalOldRowData = {};
+ Object.keys(update).forEach(key => {
+ const column = tagsData.key_column_map[key];
+ const columnName = getColumnOriginName(column);
+ originalRowUpdates[key] = update[key];
+ oldRowData[key] = getCellValueByColumn(tag, column);
+ originalOldRowData[columnName] = getCellValueByColumn(tag, column);
+ });
+
+ modifyTags(tagIds, idTagUpdates, { [tagId]: originalRowUpdates }, { [tagId]: oldRowData }, { [tagId]: originalOldRowData }, { success_callback, fail_callback });
+ }, [tagsData, modifyTags]);
+
+ const updateLocalTag = useCallback((tagId, update, { success_callback, fail_callback } = { }) => {
+ const tag = getRowById(tagsData, tagId);
+ const tagIds = [tagId];
+ const idTagUpdates = { [tagId]: update };
+ let originalRowUpdates = {};
+ let oldRowData = {};
+ let originalOldRowData = {};
+ Object.keys(update).forEach(key => {
+ const column = tagsData.key_column_map[key];
+ const columnName = getColumnOriginName(column);
+ originalRowUpdates[key] = update[key];
+ oldRowData[key] = getCellValueByColumn(tag, column);
+ originalOldRowData[columnName] = getCellValueByColumn(tag, column);
+ });
+
+ modifyLocalTags(tagIds, idTagUpdates, { [tagId]: originalRowUpdates }, { [tagId]: oldRowData }, { [tagId]: originalOldRowData }, { success_callback, fail_callback });
+ }, [tagsData, modifyLocalTags]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useTags = () => {
+ const context = useContext(TagsContext);
+ if (!context) {
+ throw new Error('\'TagsContext\' is null');
+ }
+ return context;
+};
diff --git a/frontend/src/tag/index.js b/frontend/src/tag/index.js
new file mode 100644
index 0000000000..fdc849bd8e
--- /dev/null
+++ b/frontend/src/tag/index.js
@@ -0,0 +1,2 @@
+export { default as TagsTreeView } from './tags-tree-view';
+export { default as TagsView } from './views';
diff --git a/frontend/src/tag/model/tagsData.js b/frontend/src/tag/model/tagsData.js
new file mode 100644
index 0000000000..7662576431
--- /dev/null
+++ b/frontend/src/tag/model/tagsData.js
@@ -0,0 +1,23 @@
+import Column from '../../metadata/model/column';
+
+class TagsData {
+ constructor(object) {
+ const columns = object.columns || [];
+ this.columns = columns.map(column => new Column(column));
+ this.key_column_map = {};
+ this.columns.forEach(column => {
+ this.key_column_map[column.key] = column;
+ });
+
+ this.rows = object.rows || [];
+ this.id_row_map = {};
+ this.row_ids = [];
+ this.rows.forEach(record => {
+ this.row_ids.push(record._id);
+ this.id_row_map[record._id] = record;
+ });
+ }
+
+}
+
+export default TagsData;
diff --git a/frontend/src/tag/store/data-processor.js b/frontend/src/tag/store/data-processor.js
new file mode 100644
index 0000000000..153bfe0c02
--- /dev/null
+++ b/frontend/src/tag/store/data-processor.js
@@ -0,0 +1,161 @@
+import { isTableRows } from '../../metadata/utils/row';
+import { getColumnByKey } from '../../metadata/utils/column';
+import { getGroupRows } from '../../metadata/utils/group';
+import { getRowsByIds } from '../../metadata/utils/table';
+import { OPERATION_TYPE } from './operations';
+
+// const DEFAULT_COMPUTER_PROPERTIES_CONTROLLER = {
+// isUpdateSummaries: true,
+// isUpdateColumnColors: true,
+// };
+
+// generate formula_rows
+// get rendered rows depend on filters/sorts etc.
+class DataProcessor {
+
+ static getGroupedRows(table, rows, groupbys, { collaborators }) {
+ const tableRows = isTableRows(rows) ? rows : getRowsByIds(table, rows);
+ const groups = getGroupRows(table, tableRows, groupbys, { collaborators });
+ return groups;
+ }
+
+ static updateSummaries(table, rows) {
+ // const tableRows = isTableRows(rows) ? rows : getRowsByIds(table, rows);
+ // todo
+ }
+
+ static hasRelatedGroupby(groupbys, updatedColumnKeyMap) {
+ return groupbys.some(groupby => updatedColumnKeyMap[groupby.column_key]);
+ }
+
+ static deleteGroupRows(groups, idDeletedRecordMap) {
+ groups.forEach(group => {
+ const { subgroups, row_ids } = group;
+ if (Array.isArray(subgroups) && subgroups.length > 0) {
+ this.deleteGroupRows(subgroups, idDeletedRecordMap);
+ } else if (row_ids) {
+ group.row_ids = row_ids.filter(rowId => !idDeletedRecordMap[rowId]);
+ }
+ });
+ }
+
+ static deleteEmptyGroups = (groups) => {
+ return groups.filter(group => {
+ const { subgroups, row_ids } = group;
+ if (subgroups && subgroups.length > 0) {
+ const validSubGroups = this.deleteEmptyGroups(subgroups);
+ if (validSubGroups.length === 0) {
+ return false;
+ }
+ return true;
+ }
+ if (!row_ids || row_ids.length === 0) {
+ return false;
+ }
+ return true;
+ });
+ };
+
+ static run(table, { collaborators }) {
+ // todo
+ }
+
+ static updateDataWithModifyRecords(table, relatedColumnKeyMap, rowIds, { collaborators }) {
+ // todo
+ }
+
+ static updatePageDataWithDeleteRecords(deletedRowsIds, table) {
+ // todo
+ }
+
+ static handleReloadedRecords(table, reloadedRecords, relatedColumnKeyMap) {
+ const idReloadedRecordMap = reloadedRecords.reduce((map, record) => {
+ map[record._id] = record;
+ return map;
+ }, {});
+ table.rows.forEach((row, index) => {
+ const rowId = row._id;
+ const reloadedRecord = idReloadedRecordMap[rowId];
+ const newRecord = Object.assign({}, table.rows[index], reloadedRecord);
+ if (reloadedRecord) {
+ table.rows[index] = newRecord;
+ table.id_row_map[rowId] = newRecord;
+ }
+ });
+
+ this.updateDataWithModifyRecords();
+ this.updateSummaries();
+ }
+
+ static handleNotExistRecords(table, idRecordNotExistMap) {
+ let notExistRecords = [];
+ let existRecords = [];
+ table.rows.forEach((record) => {
+ const recordId = record._id;
+ if (idRecordNotExistMap[recordId]) {
+ notExistRecords.push(record);
+ delete table.id_row_map[recordId];
+ } else {
+ existRecords.push(record);
+ }
+ });
+ table.rows = table.rows.filter((record) => !idRecordNotExistMap[record._id]);
+
+ this.updateSummaries();
+ }
+
+ static syncOperationOnData(table, operation, { collaborators }) {
+ switch (operation.op_type) {
+ case OPERATION_TYPE.MODIFY_RECORDS: {
+ const { id_original_row_updates, row_ids } = operation;
+ let relatedColumnKeyMap = {};
+ let relatedColumnKeys = [];
+ row_ids.forEach(rowId => {
+ const id_original_row_update = id_original_row_updates[rowId];
+ if (id_original_row_update) {
+ relatedColumnKeys.push(...Object.keys(id_original_row_update));
+ }
+ });
+ relatedColumnKeys.forEach(columnKey => {
+ if (!relatedColumnKeyMap[columnKey]) {
+ const column = getColumnByKey(table.columns, columnKey);
+ if (column) {
+ relatedColumnKeyMap[columnKey] = true;
+ }
+ }
+ });
+ this.updateDataWithModifyRecords(table, relatedColumnKeyMap, row_ids, { collaborators });
+ this.updateSummaries();
+ break;
+ }
+ case OPERATION_TYPE.MODIFY_RECORD_VIA_BUTTON: {
+ const { original_updates } = operation;
+ const relatedColumnKeyMap = {};
+ for (let columnKey in original_updates) {
+ const column = getColumnByKey(table.columns, columnKey);
+ if (column) {
+ relatedColumnKeyMap[columnKey] = true;
+ }
+ }
+ this.updateDataWithModifyRecords();
+ this.updateSummaries();
+ break;
+ }
+ case OPERATION_TYPE.DELETE_RECORDS: {
+ const { rows_ids } = operation;
+ this.updatePageDataWithDeleteRecords(rows_ids, table);
+ this.updateSummaries();
+ break;
+ }
+ case OPERATION_TYPE.RESTORE_RECORDS: {
+ // todo
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ }
+}
+
+export default DataProcessor;
diff --git a/frontend/src/tag/store/index.js b/frontend/src/tag/store/index.js
new file mode 100644
index 0000000000..0f97cb5057
--- /dev/null
+++ b/frontend/src/tag/store/index.js
@@ -0,0 +1,344 @@
+import deepCopy from 'deep-copy';
+import dayjs from 'dayjs';
+import { getRowById, getRowsByIds } from '../../metadata/utils/table';
+import {
+ Operation, LOCAL_APPLY_OPERATION_TYPE, NEED_APPLY_AFTER_SERVER_OPERATION, OPERATION_TYPE, UNDO_OPERATION_TYPE,
+} from './operations';
+import { EVENT_BUS_TYPE, PER_LOAD_NUMBER } from '../../metadata/constants';
+import DataProcessor from './data-processor';
+import ServerOperator from './server-operator';
+import LocalOperator from './local-operator';
+import TagsData from '../model/tagsData';
+
+class Store {
+
+ constructor(props) {
+ this.repoId = props.repoId;
+ this.data = null;
+ this.context = props.context;
+ this.startIndex = 0;
+ this.redos = [];
+ this.undos = [];
+ this.pendingOperations = [];
+ this.isSendingOperation = false;
+ this.isReadonly = false;
+ this.serverOperator = new ServerOperator(this.context);
+ this.localOperator = new LocalOperator();
+ }
+
+ destroy = () => {
+ this.loadTime = '';
+ this.data = null;
+ this.startIndex = 0;
+ this.redos = [];
+ this.undos = [];
+ this.pendingOperations = [];
+ this.isSendingOperation = false;
+ };
+
+ initStartIndex = () => {
+ this.startIndex = 0;
+ };
+
+ async loadTagsData(limit) {
+ const res = await this.context.getTags({ start: this.startIndex, limit });
+ const rows = res?.data?.results || [];
+ let data = new TagsData({ rows, columns: res?.data?.metadata });
+ const loadedCount = rows.length;
+ data.hasMore = loadedCount === limit;
+ this.data = data;
+ this.startIndex += loadedCount;
+ DataProcessor.run(this.data, { collaborators: [] });
+ }
+
+ async load(limit = PER_LOAD_NUMBER) {
+ this.loadTime = new Date();
+ await this.loadTagsData(limit);
+ }
+
+ async reload(limit = PER_LOAD_NUMBER) {
+ const currentTime = new Date();
+ if (dayjs(currentTime).diff(this.loadTime, 'hours') > 1) {
+ this.loadTime = currentTime;
+ this.startIndex = 0;
+ await this.loadTagsData(limit);
+ }
+ }
+
+ async loadMore(limit) {
+ if (!this.data) return;
+ const res = await this.context.getTags({ start: this.startIndex, limit });
+ const rows = res?.data?.results || [];
+ if (!Array.isArray(rows) || rows.length === 0) {
+ this.hasMore = false;
+ return;
+ }
+
+ this.data.rows.push(...rows);
+ rows.forEach(record => {
+ this.data.row_ids.push(record._id);
+ this.data.id_row_map[record._id] = record;
+ });
+ const loadedCount = rows.length;
+ this.data.hasMore = loadedCount === limit;
+ this.data.recordsCount = this.data.row_ids.length;
+ this.startIndex = this.startIndex + loadedCount;
+ DataProcessor.run(this.data, { collaborators: [] });
+ this.context.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_TABLE_CHANGED);
+ }
+
+ async updateRowData(newRowId) {
+ const res = await this.context.getRowsByIds(this.repoId, [newRowId]);
+ if (!res || !res.data) {
+ return;
+ }
+ const newRow = res.data.results[0];
+ const rowIndex = this.data.rows.findIndex(row => row._id === newRowId);
+ this.data.id_row_map[newRowId] = newRow;
+ this.data.rows[rowIndex] = newRow;
+ DataProcessor.run(this.data, { collaborators: [] });
+ }
+
+ createOperation(op) {
+ return new Operation(op);
+ }
+
+ applyOperation(operation, undoRedoHandler = { handleUndo: true }) {
+ const { op_type } = operation;
+
+ if (!NEED_APPLY_AFTER_SERVER_OPERATION.includes(op_type)) {
+ this.handleUndoRedos(undoRedoHandler, operation);
+ this.data = deepCopy(operation.apply(this.data));
+ this.syncOperationOnData(operation);
+ this.context.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_TABLE_CHANGED);
+ }
+
+ if (LOCAL_APPLY_OPERATION_TYPE.includes(op_type)) {
+ this.localOperator.applyOperation(operation);
+ return;
+ }
+
+ this.addPendingOperations(operation, undoRedoHandler);
+ }
+
+ addPendingOperations(operation, undoRedoHandler) {
+ this.pendingOperations.push(operation);
+ this.startSendOperation(undoRedoHandler);
+ }
+
+ startSendOperation(undoRedoHandler) {
+ if (this.isSendingOperation || this.pendingOperations.length === 0) {
+ return;
+ }
+ this.isSendingOperation = true;
+ this.context.eventBus.dispatch(EVENT_BUS_TYPE.SAVING);
+ this.sendNextOperation(undoRedoHandler);
+ }
+
+ sendNextOperation(undoRedoHandler) {
+ if (this.pendingOperations.length === 0) {
+ this.isSendingOperation = false;
+ this.context.eventBus.dispatch(EVENT_BUS_TYPE.SAVED);
+ return;
+ }
+ const operation = this.pendingOperations.shift();
+ this.serverOperator.applyOperation(operation, this.data, this.sendOperationCallback.bind(this, undoRedoHandler));
+ }
+
+ sendOperationCallback = (undoRedoHandler, { operation, error }) => {
+ if (error) {
+ this.context.eventBus.dispatch(EVENT_BUS_TYPE.TABLE_ERROR, { error });
+ operation && operation.fail_callback && operation.fail_callback(error);
+ this.sendNextOperation(undoRedoHandler);
+ return;
+ }
+
+ const isAfterServerOperation = NEED_APPLY_AFTER_SERVER_OPERATION.includes(operation.op_type);
+ if (isAfterServerOperation) {
+ this.handleUndoRedos(undoRedoHandler, operation);
+ this.data = deepCopy(operation.apply(this.data));
+ this.syncOperationOnData(operation);
+ }
+
+ if (isAfterServerOperation) {
+ this.context.eventBus.dispatch(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED);
+ }
+ operation.success_callback && operation.success_callback(operation);
+
+ // need reload records if has related formula columns
+ this.serverOperator.handleReloadRecords(this.data, operation, ({ reloadedRecords, idRecordNotExistMap, relatedColumnKeyMap }) => {
+ if (reloadedRecords.length > 0) {
+ DataProcessor.handleReloadedRecords(this.data, reloadedRecords, relatedColumnKeyMap);
+ }
+ if (Object.keys(idRecordNotExistMap).length > 0) {
+ DataProcessor.handleNotExistRecords(this.data, idRecordNotExistMap);
+ }
+ this.context.eventBus.dispatch(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED);
+ });
+
+ this.sendNextOperation(undoRedoHandler);
+ };
+
+ handleUndoRedos(undoRedoHandler, operation) {
+ const { handleUndo, asyncUndoRedo } = undoRedoHandler;
+ if (handleUndo) {
+ if (this.redos.length > 0) {
+ this.redos = [];
+ }
+ if (this.undos.length > 10) {
+ this.undos = this.undos.slice(-10);
+ }
+ if (UNDO_OPERATION_TYPE.includes(operation.op_type)) {
+ this.undos.push(operation);
+ }
+ }
+ asyncUndoRedo && asyncUndoRedo(operation);
+ }
+
+ undoOperation() {
+ if (this.isReadonly || this.undos.length === 0) return;
+ const lastOperation = this.undos.pop();
+ const lastInvertOperation = lastOperation.invert();
+ if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastInvertOperation.op_type)) {
+ this.applyOperation(lastInvertOperation, { handleUndo: false, asyncUndoRedo: (operation) => {
+ if (operation.op_type === OPERATION_TYPE.INSERT_RECORD) {
+ lastOperation.row_id = operation.row_data._id;
+ }
+ this.redos.push(lastOperation);
+ } });
+ return;
+ }
+ this.redos.push(lastOperation);
+ this.applyOperation(lastInvertOperation, { handleUndo: false });
+ }
+
+ redoOperation() {
+ if (this.isReadonly || this.redos.length === 0) return;
+ let lastOperation = this.redos.pop();
+ if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastOperation.op_type)) {
+ this.applyOperation(lastOperation, { handleUndo: false, asyncUndoRedo: (operation) => {
+ if (operation.op_type === OPERATION_TYPE.INSERT_RECORD) {
+ lastOperation = operation;
+ }
+ this.undos.push(lastOperation);
+ } });
+ return;
+ }
+ this.undos.push(lastOperation);
+ this.applyOperation(lastOperation, { handleUndo: false });
+ }
+
+ syncOperationOnData(operation) {
+ DataProcessor.syncOperationOnData(this.data, operation, { collaborators: [] });
+ }
+
+ addTags(tags, { fail_callback, success_callback } = {}) {
+ const type = OPERATION_TYPE.ADD_RECORDS;
+ const operation = this.createOperation({
+ type,
+ repo_id: this.repoId,
+ rows: tags,
+ fail_callback,
+ success_callback,
+ });
+ this.applyOperation(operation);
+ }
+
+ modifyTags(row_ids, id_row_updates, id_original_row_updates, id_old_row_data, id_original_old_row_data, { fail_callback, success_callback }) {
+ const originalRows = getRowsByIds(this.data, row_ids);
+ let valid_row_ids = [];
+ let valid_id_row_updates = {};
+ let valid_id_original_row_updates = {};
+ let valid_id_old_row_data = {};
+ let valid_id_original_old_row_data = {};
+ originalRows.forEach(row => {
+ if (row && this.context.canModifyTag(row)) {
+ const rowId = row._id;
+ valid_row_ids.push(rowId);
+ valid_id_row_updates[rowId] = id_row_updates[rowId];
+ valid_id_original_row_updates[rowId] = id_original_row_updates[rowId];
+ valid_id_old_row_data[rowId] = id_old_row_data[rowId];
+ valid_id_original_old_row_data[rowId] = id_original_old_row_data[rowId];
+ }
+ });
+
+ const type = OPERATION_TYPE.MODIFY_RECORDS;
+ const operation = this.createOperation({
+ type,
+ repo_id: this.repoId,
+ row_ids: valid_row_ids,
+ id_row_updates: valid_id_row_updates,
+ id_original_row_updates: valid_id_original_row_updates,
+ id_old_row_data: valid_id_old_row_data,
+ id_original_old_row_data: valid_id_original_old_row_data,
+ fail_callback,
+ success_callback,
+ });
+ this.applyOperation(operation);
+ }
+
+ modifyLocalTags(row_ids, id_row_updates, id_original_row_updates, id_old_row_data, id_original_old_row_data, { fail_callback, success_callback }) {
+ const originalRows = getRowsByIds(this.data, row_ids);
+ let valid_row_ids = [];
+ let valid_id_row_updates = {};
+ let valid_id_original_row_updates = {};
+ let valid_id_old_row_data = {};
+ let valid_id_original_old_row_data = {};
+ originalRows.forEach(row => {
+ const rowId = row._id;
+ valid_row_ids.push(rowId);
+ valid_id_row_updates[rowId] = id_row_updates[rowId];
+ valid_id_original_row_updates[rowId] = id_original_row_updates[rowId];
+ valid_id_old_row_data[rowId] = id_old_row_data[rowId];
+ valid_id_original_old_row_data[rowId] = id_original_old_row_data[rowId];
+ });
+
+ const type = OPERATION_TYPE.MODIFY_LOCAL_RECORDS;
+ const operation = this.createOperation({
+ type,
+ repo_id: this.repoId,
+ row_ids: valid_row_ids,
+ id_row_updates: valid_id_row_updates,
+ id_original_row_updates: valid_id_original_row_updates,
+ id_old_row_data: valid_id_old_row_data,
+ id_original_old_row_data: valid_id_original_old_row_data,
+ fail_callback,
+ success_callback,
+ });
+ this.applyOperation(operation);
+ }
+
+ deleteTags(tag_ids, { fail_callback, success_callback } = {}) {
+ const type = OPERATION_TYPE.DELETE_RECORDS;
+ if (!Array.isArray(tag_ids) || tag_ids.length === 0) return;
+ const validTagIds = Array.isArray(tag_ids) ? tag_ids.filter((tagId) => {
+ const tag = getRowById(this.data, tagId);
+ return tag && this.context.canModifyTag(tag);
+ }) : [];
+
+ const deletedTags = validTagIds.map((tagId) => getRowById(this.data, tagId));
+
+ const operation = this.createOperation({
+ type,
+ repo_id: this.repoId,
+ tag_ids: validTagIds,
+ deleted_tags: deletedTags,
+ fail_callback,
+ success_callback,
+ });
+ this.applyOperation(operation);
+ }
+
+ reloadRecords(row_ids) {
+ const type = OPERATION_TYPE.RELOAD_RECORDS;
+ const operation = this.createOperation({
+ type,
+ repo_id: this.repoId,
+ row_ids,
+ });
+ this.applyOperation(operation);
+ }
+
+}
+
+export default Store;
diff --git a/frontend/src/tag/store/local-operator.js b/frontend/src/tag/store/local-operator.js
new file mode 100644
index 0000000000..482365a239
--- /dev/null
+++ b/frontend/src/tag/store/local-operator.js
@@ -0,0 +1,15 @@
+class LocalOperator {
+
+ applyOperation(operation) {
+ const { op_type } = operation;
+
+ switch (op_type) {
+ default: {
+ break;
+ }
+ }
+ }
+
+}
+
+export default LocalOperator;
diff --git a/frontend/src/tag/store/operations/apply.js b/frontend/src/tag/store/operations/apply.js
new file mode 100644
index 0000000000..ac17908f8b
--- /dev/null
+++ b/frontend/src/tag/store/operations/apply.js
@@ -0,0 +1,85 @@
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import { UTC_FORMAT_DEFAULT } from '../../../metadata/constants';
+import { OPERATION_TYPE } from './constants';
+import { PRIVATE_COLUMN_KEY } from '../../constants';
+import { username } from '../../../utils/constants';
+
+dayjs.extend(utc);
+
+export default function apply(data, operation) {
+ const { op_type } = operation;
+
+ switch (op_type) {
+ case OPERATION_TYPE.ADD_RECORDS: {
+ const { tags } = operation;
+ const { rows } = data;
+ const updatedRows = [...rows, ...tags];
+ tags.forEach(tag => {
+ const id = tag[PRIVATE_COLUMN_KEY.ID];
+ data.id_row_map[id] = tag;
+ data.row_ids.push(id);
+ });
+ data.rows = updatedRows;
+ return data;
+ }
+ case OPERATION_TYPE.MODIFY_RECORDS:
+ case OPERATION_TYPE.MODIFY_LOCAL_RECORDS: {
+ const { id_original_row_updates, id_row_updates } = operation;
+ const { rows } = data;
+ const modifyTime = dayjs().utc().format(UTC_FORMAT_DEFAULT);
+ const modifier = username;
+ let updatedRows = [...rows];
+
+ rows.forEach((row, index) => {
+ const { _id: rowId } = row;
+ const originalRowUpdates = id_original_row_updates[rowId];
+ const rowUpdates = id_row_updates[rowId];
+ if (rowUpdates || originalRowUpdates) {
+ const updatedRow = Object.assign({}, row, rowUpdates, originalRowUpdates, {
+ '_mtime': modifyTime,
+ '_last_modifier': modifier,
+ });
+ updatedRows[index] = updatedRow;
+ data.id_row_map[rowId] = updatedRow;
+ }
+ });
+
+ data.rows = updatedRows;
+ return data;
+ }
+ case OPERATION_TYPE.DELETE_RECORDS: {
+ const { tag_ids } = operation;
+ const idNeedDeletedMap = tag_ids.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {});
+ data.rows = data.rows.filter((row) => !idNeedDeletedMap[row._id]);
+
+ // delete rows in id_row_map
+ tag_ids.forEach(rowId => {
+ delete data.id_row_map[rowId];
+ });
+
+ return data;
+ }
+ case OPERATION_TYPE.RESTORE_RECORDS: {
+ const { original_rows } = operation;
+ const currentTime = dayjs().utc().format(UTC_FORMAT_DEFAULT);
+ let insertRows = [];
+ original_rows.forEach(row => {
+ const insertRow = {
+ ...row,
+ _ctime: currentTime,
+ _mtime: currentTime,
+ _creator: username,
+ _last_modifier: username,
+ };
+ insertRows.push(insertRow);
+ data.id_row_map[row._id] = insertRow;
+ });
+ data.rows.push(insertRows);
+ return data;
+ }
+ default: {
+ return data;
+ }
+ }
+}
diff --git a/frontend/src/tag/store/operations/constants.js b/frontend/src/tag/store/operations/constants.js
new file mode 100644
index 0000000000..d624a0042d
--- /dev/null
+++ b/frontend/src/tag/store/operations/constants.js
@@ -0,0 +1,36 @@
+export const OPERATION_TYPE = {
+ ADD_RECORDS: 'add_records',
+ MODIFY_RECORDS: 'modify_records',
+ DELETE_RECORDS: 'delete_records',
+ RESTORE_RECORDS: 'restore_records',
+ RELOAD_RECORDS: 'reload_records',
+
+ MODIFY_LOCAL_RECORDS: 'modify_local_records',
+};
+
+export const OPERATION_ATTRIBUTES = {
+ [OPERATION_TYPE.ADD_RECORDS]: ['repo_id', 'rows', 'tags'],
+ [OPERATION_TYPE.MODIFY_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'],
+ [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.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'],
+};
+
+export const UNDO_OPERATION_TYPE = [
+ // OPERATION_TYPE.MODIFY_RECORDS,
+ // OPERATION_TYPE.RESTORE_RECORDS,
+];
+
+// only apply operation on the local
+export const LOCAL_APPLY_OPERATION_TYPE = [
+ OPERATION_TYPE.MODIFY_LOCAL_RECORDS,
+];
+
+// apply operation after exec operation on the server
+export const NEED_APPLY_AFTER_SERVER_OPERATION = [
+ OPERATION_TYPE.ADD_RECORDS,
+];
+
+export const VIEW_OPERATION = [
+];
diff --git a/frontend/src/tag/store/operations/index.js b/frontend/src/tag/store/operations/index.js
new file mode 100644
index 0000000000..9519e4fb31
--- /dev/null
+++ b/frontend/src/tag/store/operations/index.js
@@ -0,0 +1,17 @@
+import apply from './apply';
+import invert from './invert';
+import Operation from './model';
+
+export {
+ OPERATION_TYPE,
+ OPERATION_ATTRIBUTES,
+ UNDO_OPERATION_TYPE,
+ LOCAL_APPLY_OPERATION_TYPE,
+ NEED_APPLY_AFTER_SERVER_OPERATION,
+} from './constants';
+
+export {
+ apply,
+ invert,
+ Operation,
+};
diff --git a/frontend/src/tag/store/operations/invert.js b/frontend/src/tag/store/operations/invert.js
new file mode 100644
index 0000000000..7152d2affc
--- /dev/null
+++ b/frontend/src/tag/store/operations/invert.js
@@ -0,0 +1,45 @@
+import deepCopy from 'deep-copy';
+import Operation from './model';
+import { OPERATION_TYPE } from './constants';
+
+function createOperation(op) {
+ return new Operation(op);
+}
+
+export default function invert(operation) {
+ const { op_type } = operation.clone();
+ switch (op_type) {
+ case OPERATION_TYPE.MODIFY_RECORDS: {
+ const {
+ page_id, is_copy_paste, row_ids, id_row_updates, id_original_row_updates,
+ id_old_row_data, id_original_old_row_data,
+ } = operation;
+ return createOperation({
+ type: OPERATION_TYPE.MODIFY_RECORDS,
+ page_id,
+ is_copy_paste,
+ row_ids: deepCopy(row_ids),
+ id_row_updates: deepCopy(id_old_row_data),
+ id_original_row_updates: deepCopy(id_original_old_row_data),
+ id_old_row_data: deepCopy(id_row_updates),
+ id_original_old_row_data: deepCopy(id_original_row_updates),
+ });
+ }
+ case OPERATION_TYPE.RESTORE_RECORDS: {
+ const { page_id, rows_data, original_rows, link_infos, upper_row_ids, } = operation;
+ const row_ids = rows_data.map(recordData => recordData._id);
+ return createOperation({
+ type: OPERATION_TYPE.DELETE_RECORDS,
+ page_id,
+ row_ids,
+ deleted_rows: deepCopy(rows_data),
+ original_deleted_rows: deepCopy(original_rows),
+ deleted_link_infos: deepCopy(link_infos),
+ upper_row_ids: deepCopy(upper_row_ids),
+ });
+ }
+ default: {
+ break;
+ }
+ }
+}
diff --git a/frontend/src/tag/store/operations/model.js b/frontend/src/tag/store/operations/model.js
new file mode 100644
index 0000000000..e3696ca9ba
--- /dev/null
+++ b/frontend/src/tag/store/operations/model.js
@@ -0,0 +1,37 @@
+import deepCopy from 'deep-copy';
+import { OPERATION_ATTRIBUTES } from './constants';
+import apply from './apply';
+import invert from './invert';
+
+class Operation {
+
+ constructor(operation) {
+ const newOperation = deepCopy(operation);
+ const type = newOperation.type || newOperation.op_type;
+ const attributes = OPERATION_ATTRIBUTES[type];
+ this.op_type = type;
+ attributes.forEach((param) => {
+ this[param] = newOperation[param];
+ });
+ this.success_callback = newOperation.success_callback;
+ this.fail_callback = newOperation.fail_callback;
+ }
+
+ clone() {
+ return new Operation(this);
+ }
+
+ apply(pageData) {
+ return apply(pageData, this);
+ }
+
+ invert() {
+ return invert(this);
+ }
+
+ set(key, value) {
+ this[key] = value;
+ }
+}
+
+export default Operation;
diff --git a/frontend/src/tag/store/server-operator.js b/frontend/src/tag/store/server-operator.js
new file mode 100644
index 0000000000..f37b69c4c3
--- /dev/null
+++ b/frontend/src/tag/store/server-operator.js
@@ -0,0 +1,237 @@
+import { gettext } from '../../utils/constants';
+import { OPERATION_TYPE } from './operations';
+import { getColumnByKey } from '../../metadata/utils/column';
+import ObjectUtils from '../../metadata/utils/object-utils';
+
+const MAX_LOAD_RECORDS = 100;
+
+class ServerOperator {
+
+ constructor(context) {
+ this.context = context;
+ }
+
+ applyOperation(operation, data, callback) {
+ const { op_type } = operation;
+
+ switch (op_type) {
+ case OPERATION_TYPE.ADD_RECORDS: {
+ const { rows } = operation;
+ this.context.addTags(rows).then(res => {
+ const tags = res?.data?.tags || [];
+ operation.tags = tags;
+ callback({ operation });
+ }).catch(error => {
+ callback({ error: gettext('Failed to add tags') });
+ });
+ break;
+ }
+ case OPERATION_TYPE.MODIFY_RECORDS: {
+ const { row_ids, id_row_updates } = operation;
+ const recordsData = row_ids.map(rowId => {
+ return { tag_id: rowId, tag: id_row_updates[rowId] };
+ }).filter(tagData => tagData.tag && !ObjectUtils.isEmpty(tagData.tag));
+ if (recordsData.length === 0) {
+ callback({ operation });
+ break;
+ }
+ this.context.modifyTags(recordsData).then(res => {
+ callback({ operation });
+ }).catch(error => {
+ callback({ error: gettext('Failed to modify tags') });
+ });
+ break;
+ }
+ case OPERATION_TYPE.DELETE_RECORDS: {
+ const { tag_ids } = operation;
+ this.context.deleteTags(tag_ids).then(res => {
+ callback({ operation });
+ }).catch(error => {
+ callback({ error: gettext('Failed to delete tags') });
+ });
+ break;
+ }
+ case OPERATION_TYPE.RESTORE_RECORDS: {
+ const { repo_id, rows_data } = operation;
+ if (!Array.isArray(rows_data) || rows_data.length === 0) {
+ callback({ error: gettext('Failed to restore tags') });
+ break;
+ }
+ window.sfMetadataContext.restoreRows(repo_id, rows_data).then(res => {
+ callback({ operation });
+ }).catch(error => {
+ callback({ error: gettext('Failed to restore tags') });
+ });
+ break;
+ }
+ case OPERATION_TYPE.RELOAD_RECORDS: {
+ callback({ operation });
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ }
+
+ checkReloadRecordsOperation = (operation) => {
+ const { op_type } = operation;
+ switch (op_type) {
+ case OPERATION_TYPE.RELOAD_RECORDS: {
+ return true;
+ }
+ default: {
+ return false;
+ }
+ }
+ };
+
+ handleReloadRecords(table, operation, callback) {
+ const { repo_id: repoId } = operation;
+ const { relatedColumnKeyMap } = this.getOperationRelatedColumns(table, operation);
+ const isReloadRecordsOp = this.checkReloadRecordsOperation(operation);
+ if (!isReloadRecordsOp) return;
+
+ const rowsIds = this.getOperatedRowsIds(operation);
+ this.asyncReloadRecords(rowsIds, repoId, relatedColumnKeyMap, callback);
+ }
+
+ asyncReloadRecords(rowsIds, repoId, relatedColumnKeyMap, callback) {
+ if (!Array.isArray(rowsIds) || rowsIds.length === 0) return;
+ const restRowsIds = [...rowsIds];
+ const currentRowsIds = restRowsIds.splice(0, MAX_LOAD_RECORDS);
+
+ window.sfMetadataContext.getRowsByIds(repoId, currentRowsIds).then(res => {
+ if (!res || !res.data || !res.data.results) {
+ this.asyncReloadRecords(restRowsIds, repoId, relatedColumnKeyMap, callback);
+ return;
+ }
+ const fetchedRecords = res.data.results;
+ let reloadedRecords = [];
+ let idRecordLoadedMap = {};
+ let idRecordNotExistMap = {};
+ if (fetchedRecords.length > 0) {
+ fetchedRecords.forEach((record) => {
+ reloadedRecords.push(record);
+ idRecordLoadedMap[record._id] = true;
+ });
+ }
+ currentRowsIds.forEach((recordId) => {
+ if (!idRecordLoadedMap[recordId]) {
+ idRecordNotExistMap[recordId] = true;
+ }
+ });
+ callback({
+ reloadedRecords,
+ idRecordNotExistMap,
+ relatedColumnKeyMap,
+ });
+ this.asyncReloadRecords(restRowsIds, repoId, relatedColumnKeyMap, callback);
+ }).catch (error => {
+ // for debug
+ // eslint-disable-next-line no-console
+ console.log(error);
+ this.asyncReloadRecords(restRowsIds, repoId, relatedColumnKeyMap, callback);
+ });
+ }
+
+ getOperationRelatedColumns(table, operation) {
+ const { op_type } = operation;
+ let relatedColumnKeys;
+ switch (op_type) {
+ case OPERATION_TYPE.MODIFY_RECORDS: {
+ const { id_original_row_updates } = operation;
+ relatedColumnKeys = this.getRelatedColumnKeysFromRecordUpdates(id_original_row_updates);
+ break;
+ }
+ case OPERATION_TYPE.RELOAD_RECORDS: {
+ let relatedColumnKeyMap = {};
+ table.columns.forEach(column => {
+ const { key } = column;
+ relatedColumnKeyMap[key] = true;
+ });
+ return {
+ relatedColumnKeyMap,
+ relatedColumns: table.columns,
+ };
+ }
+ case OPERATION_TYPE.MODIFY_RECORD_VIA_BUTTON: {
+ const { row_id, original_updates } = operation;
+ relatedColumnKeys = this.getRelatedColumnKeysFromRecordUpdates({ [row_id]: original_updates });
+ break;
+ }
+ default: {
+ relatedColumnKeys = [];
+ break;
+ }
+ }
+ return this.getRelatedColumns(relatedColumnKeys, table);
+ }
+
+ getOperatedRowsIds(operation) {
+ const { op_type } = operation;
+ switch (op_type) {
+ case OPERATION_TYPE.MODIFY_RECORDS:
+ case OPERATION_TYPE.RELOAD_RECORDS: {
+ const { row_ids } = operation;
+ return Array.isArray(row_ids) ? [...row_ids] : [];
+ }
+ case OPERATION_TYPE.MODIFY_RECORD_VIA_BUTTON: {
+ const { row_id } = operation;
+ return row_id ? [row_id] : [];
+ }
+ default: {
+ return [];
+ }
+ }
+ }
+
+ /**
+ * @param {array} relatedColumnKeys
+ * @param {object} pageData
+ * @param {object} table
+ * @returns relatedColumnKeyMap, relatedFormulaColumnKeyMap, relatedColumns, relatedFormulaColumns
+ */
+ getRelatedColumns(relatedColumnKeys, table) {
+ if (!relatedColumnKeys || relatedColumnKeys.length === 0) {
+ return {
+ relatedColumnKeyMap: {},
+ relatedColumns: [],
+ };
+ }
+ let relatedColumnKeyMap = {};
+ let relatedColumns = [];
+ relatedColumnKeys.forEach(columnKey => {
+ if (!relatedColumnKeyMap[columnKey]) {
+ const column = getColumnByKey(table.columns, columnKey);
+ if (column) {
+ relatedColumnKeyMap[columnKey] = true;
+ relatedColumns.push(column);
+ }
+ }
+ });
+ return {
+ relatedColumnKeyMap,
+ relatedColumns,
+ };
+ }
+
+ /**
+ * @param {object} recordUpdates: { [record._id]: { [column.key]: '', ... }, ... }
+ * @returns related column keys: [ column.key, ... ]
+ */
+ getRelatedColumnKeysFromRecordUpdates(recordUpdates) {
+ if (!recordUpdates) return [];
+ const rowIds = Object.keys(recordUpdates);
+ return rowIds.reduce((keys, rowId) => {
+ const recordData = recordUpdates[rowId];
+ if (recordData) {
+ keys.push(...Object.keys(recordData));
+ }
+ return keys;
+ }, []);
+ }
+
+}
+
+export default ServerOperator;
diff --git a/frontend/src/tag/tags-tree-view/index.js b/frontend/src/tag/tags-tree-view/index.js
new file mode 100644
index 0000000000..2f5f7d309c
--- /dev/null
+++ b/frontend/src/tag/tags-tree-view/index.js
@@ -0,0 +1,131 @@
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { useTags } from '../hooks';
+import Tag from './tag';
+import { getTagId, getTagName } from '../utils';
+import { PRIVATE_FILE_TYPE } from '../../constants';
+import { gettext, mediaUrl } from '../../utils/constants';
+import { getRowById } from '../../metadata/utils/table';
+import TagsManagement from './tags-management';
+import { PRIVATE_COLUMN_KEY, TAG_MANAGEMENT_ID } from '../constants';
+
+const updateFavicon = () => {
+ const favicon = document.getElementById('favicon');
+ if (favicon) {
+ favicon.href = `${mediaUrl}favicons/favicon.png`;
+ }
+};
+
+const TagsTreeView = ({ userPerm, currentPath }) => {
+ const originalTitle = useRef('');
+
+ // const {} = { }
+ const { tagsData, selectTag, deleteTags, duplicateTag, updateTag } = useTags();
+
+ const tags = useMemo(() => {
+ if (!tagsData) return [];
+ return tagsData.rows;
+ }, [tagsData]);
+
+ const canUpdate = useMemo(() => {
+ if (userPerm !== 'rw' && userPerm !== 'admin') return false;
+ return true;
+ }, [userPerm]);
+
+ const deleteTag = useCallback((tagId, isSelected) => {
+ if (isSelected) {
+ const currentTagIndex = tagsData.row_ids.indexOf(tagId);
+ const lastTagId = tagsData.row_ids[currentTagIndex - 1];
+ const lastTag = getRowById(tagsData, lastTagId);
+ selectTag(lastTag);
+ }
+ deleteTags([tagId]);
+ }, [tagsData, deleteTags, selectTag]);
+
+ useEffect(() => {
+ originalTitle.current = document.title;
+ }, []);
+
+ useEffect(() => {
+ const { origin, pathname, search } = window.location;
+ const urlParams = new URLSearchParams(search);
+ const tagId = urlParams.get('tag');
+ if (tagId) {
+ if (tagId === TAG_MANAGEMENT_ID) {
+ if (!canUpdate) return;
+ selectTag({ [PRIVATE_COLUMN_KEY.ID]: TAG_MANAGEMENT_ID });
+ return;
+ }
+
+ const lastOpenedTag = getRowById(tagsData, tagId);
+ if (lastOpenedTag) {
+ selectTag(lastOpenedTag);
+ const lastOpenedTagName = getTagName(lastOpenedTag);
+ document.title = `${lastOpenedTagName} - Seafile`;
+ updateFavicon();
+ return;
+ }
+ const url = `${origin}${pathname}`;
+ window.history.pushState({ url: url, path: '' }, '', url);
+ }
+ updateFavicon();
+ document.title = originalTitle.current;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!currentPath.includes('/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/')) return;
+ const currentTagId = currentPath.split('/').pop();
+ if (currentTagId === TAG_MANAGEMENT_ID) {
+ if (!canUpdate) return;
+ document.title = `${gettext('Tags management')} - Seafile`;
+ return;
+ }
+ const currentTag = getRowById(tagsData, currentTagId);
+ if (currentTag) {
+ const tagName = getTagName(currentTag);
+ document.title = `${tagName} - Seafile`;
+ updateFavicon('default');
+ return;
+ }
+ document.title = originalTitle;
+ updateFavicon('default');
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentPath, tagsData]);
+
+ return (
+
+
+
+ {tags.map(tag => {
+ const id = getTagId(tag);
+ const tagPath = '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + id;
+ const isSelected = currentPath === tagPath;
+ return (
+ selectTag(tag, isSelected)}
+ onDelete={() => deleteTag(id, isSelected)}
+ onCopy={() => duplicateTag(id)}
+ onUpdateTag={updateTag}
+ />
+ );
+ })}
+ {canUpdate && ()}
+
+
+
+ );
+
+};
+
+TagsTreeView.propTypes = {
+ userPerm: PropTypes.string,
+ currentPath: PropTypes.string,
+};
+
+export default TagsTreeView;
diff --git a/frontend/src/tag/tags-tree-view/tag/index.css b/frontend/src/tag/tags-tree-view/tag/index.css
new file mode 100644
index 0000000000..4e8773c22f
--- /dev/null
+++ b/frontend/src/tag/tags-tree-view/tag/index.css
@@ -0,0 +1,24 @@
+.tag-tree-node .tag-tree-node-color {
+ height: 12px;
+ width: 12px;
+ border-radius: 50%;
+ transform: translateY(2px);
+}
+
+.tag-tree-node .tag-tree-node-text {
+ display: flex;
+ align-items: center;
+}
+
+.tag-tree-node .tag-tree-node-text .tag-tree-node-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tag-tree-node .tag-tree-node-text .tag-tree-node-count {
+ color: #666;
+ font-size: 14px;
+ margin-left: 8px;
+ margin-right: 8px;
+}
diff --git a/frontend/src/tag/tags-tree-view/tag/index.js b/frontend/src/tag/tags-tree-view/tag/index.js
new file mode 100644
index 0000000000..27bf8b21df
--- /dev/null
+++ b/frontend/src/tag/tags-tree-view/tag/index.js
@@ -0,0 +1,230 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { Input } from 'reactstrap';
+import { gettext } from '../../../utils/constants';
+import ItemDropdownMenu from '../../../components/dropdown-menu/item-dropdown-menu';
+import { isMobile } from '../../../utils/utils';
+import { getTagColor, getTagName, getTagId, getTagFilesCount, isValidTagName } from '../../utils';
+import { isEnter } from '../../../metadata/utils/hotkey';
+import toaster from '../../../components/toast';
+import { PRIVATE_COLUMN_KEY } from '../../constants';
+
+import './index.css';
+
+const Tag = ({
+ userPerm,
+ isSelected,
+ tag,
+ tags,
+ onClick,
+ onDelete,
+ onCopy,
+ onUpdateTag,
+}) => {
+ const tagName = useMemo(() => getTagName(tag), [tag]);
+ const tagColor = useMemo(() => getTagColor(tag), [tag]);
+ const tagId = useMemo(() => getTagId(tag), [tag]);
+ const tagCount = useMemo(() => getTagFilesCount(tag), [tag]);
+ const [highlight, setHighlight] = useState(false);
+ const [freeze, setFreeze] = useState(false);
+ const [isRenaming, setRenaming] = useState(false);
+ const [inputValue, setInputValue] = useState('');
+
+ const inputRef = useRef(null);
+
+ const otherTagsName = useMemo(() => {
+ return tags.filter(tagItem => getTagId(tagItem) !== tagId).map(tagItem => getTagName(tagItem));
+ }, [tags, tagId]);
+
+ const canUpdate = useMemo(() => {
+ if (userPerm !== 'rw' && userPerm !== 'admin') return false;
+ return true;
+ }, [userPerm]);
+
+ const operations = useMemo(() => {
+ if (!canUpdate) return [];
+ const value = [
+ { key: 'rename', value: gettext('Rename') },
+ { key: 'duplicate', value: gettext('Duplicate') },
+ { key: 'delete', value: gettext('Delete') }
+ ];
+ return value;
+ }, [canUpdate]);
+
+ const onMouseEnter = useCallback(() => {
+ if (freeze) return;
+ setHighlight(true);
+ }, [freeze]);
+
+ const onMouseOver = useCallback(() => {
+ if (freeze) return;
+ setHighlight(true);
+ }, [freeze]);
+
+ const onMouseLeave = useCallback(() => {
+ if (freeze) return;
+ setHighlight(false);
+ }, [freeze]);
+
+ const freezeItem = useCallback(() => {
+ setFreeze(true);
+ }, []);
+
+ const unfreezeItem = useCallback(() => {
+ setFreeze(false);
+ setHighlight(false);
+ }, []);
+
+ const operationClick = useCallback((operationKey) => {
+ switch (operationKey) {
+ case 'rename': {
+ setInputValue(tagName);
+ setRenaming(true);
+ return;
+ }
+ case 'duplicate': {
+ onCopy();
+ return;
+ }
+ case 'delete': {
+ onDelete();
+ return;
+ }
+ default: {
+ return;
+ }
+ }
+ }, [tagName, onDelete, onCopy]);
+
+ const renameTag = useCallback((name, failCallback) => {
+ onUpdateTag(tagId, { [PRIVATE_COLUMN_KEY.TAG_NAME]: name }, {
+ success_callback: () => {
+ setRenaming(false);
+ if (!isSelected) return;
+ document.title = `${name} - Seafile`;
+ },
+ fail_callback: (error) => {
+ failCallback(error);
+ if (!isSelected) return;
+ document.title = `${tagName} - Seafile`;
+ }
+ });
+ }, [onUpdateTag, isSelected, tagId, tagName]);
+
+ const handleSubmit = useCallback((event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const { isValid, message } = isValidTagName(inputValue, otherTagsName);
+ if (!isValid) {
+ toaster.danger(message);
+ return;
+ }
+ if (message === tagName) {
+ setRenaming(false);
+ return;
+ }
+ renameTag(message);
+ }, [tagName, inputValue, otherTagsName, renameTag]);
+
+ const onChange = useCallback((e) => {
+ setInputValue(e.target.value);
+ }, []);
+
+ const onKeyDown = useCallback((event) => {
+ if (isEnter(event)) {
+ handleSubmit(event);
+ unfreezeItem();
+ }
+ }, [handleSubmit, unfreezeItem]);
+
+ const onInputClick = useCallback((event) => {
+ event.stopPropagation();
+ event.nativeEvent.stopImmediatePropagation();
+ }, []);
+
+ useEffect(() => {
+ if (isRenaming && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isRenaming]);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (inputRef.current && !inputRef.current.contains(event.target)) {
+ handleSubmit(event);
+ }
+ };
+
+ if (isRenaming) {
+ document.addEventListener('mousedown', handleClickOutside);
+ } else {
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isRenaming, handleSubmit]);
+
+ return (
+ <>
+
onClick(tag)}
+ >
+
+
+ {isRenaming ? (
+ setRenaming(false)}
+ onClick={onInputClick}
+ onKeyDown={onKeyDown}
+ />
+ ) : (
+ <>{tagName}>
+ )}
+
+
{` (${tagCount})`}
+
+
+
+ {highlight && operations.length > 0 && (
+ operations}
+ onMenuItemClick={operationClick}
+ menuStyle={isMobile ? { zIndex: 1050 } : {}}
+ />
+ )}
+
+
+ >
+ );
+};
+
+Tag.propTypes = {
+ canDelete: PropTypes.bool,
+ isSelected: PropTypes.bool,
+ tag: PropTypes.object,
+ onClick: PropTypes.func,
+};
+
+export default Tag;
diff --git a/frontend/src/tag/tags-tree-view/tags-management/index.css b/frontend/src/tag/tags-tree-view/tags-management/index.css
new file mode 100644
index 0000000000..4ce35ecb15
--- /dev/null
+++ b/frontend/src/tag/tags-tree-view/tags-management/index.css
@@ -0,0 +1,13 @@
+.tag-management-tree-node-inner:hover {
+ background-color: #f0f0f0;
+ border-radius: 0.25rem;
+}
+
+.tag-management-tree-node-inner .sf3-font-tag {
+ font-size: 12px;
+ color: #666666;
+ line-height: 1.625;
+ width: 1.5rem;
+ display: flex;
+ justify-content: center;
+}
diff --git a/frontend/src/tag/tags-tree-view/tags-management/index.js b/frontend/src/tag/tags-tree-view/tags-management/index.js
new file mode 100644
index 0000000000..80007ef730
--- /dev/null
+++ b/frontend/src/tag/tags-tree-view/tags-management/index.js
@@ -0,0 +1,42 @@
+import React, { useCallback, useMemo } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { PRIVATE_FILE_TYPE } from '../../../constants';
+import { PRIVATE_COLUMN_KEY, TAG_MANAGEMENT_ID } from '../../constants';
+import { useTags } from '../../hooks';
+import { gettext } from '../../../utils/constants';
+
+import './index.css';
+
+const TagsManagement = ({ currentPath }) => {
+ const { selectTag } = useTags();
+
+ const path = useMemo(() => '/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/' + TAG_MANAGEMENT_ID, []);
+ const isSelected = useMemo(() => currentPath === path, [currentPath, path]);
+
+ const selectTagManagement = useCallback(() => {
+ selectTag({
+ [PRIVATE_COLUMN_KEY.ID]: TAG_MANAGEMENT_ID,
+ }, isSelected);
+ }, [isSelected, selectTag]);
+
+ return (
+
+
{gettext('Tags management')}
+
+
+ );
+};
+
+TagsManagement.propTypes = {
+ currentPath: PropTypes.string.isRequired,
+};
+
+export default TagsManagement;
diff --git a/frontend/src/tag/utils/cell/core.js b/frontend/src/tag/utils/cell/core.js
new file mode 100644
index 0000000000..f2e79e9ba4
--- /dev/null
+++ b/frontend/src/tag/utils/cell/core.js
@@ -0,0 +1,55 @@
+import { PRIVATE_COLUMN_KEYS, PRIVATE_COLUMN_KEY } from '../../constants';
+
+/**
+ * @param {object} record eg: { [column_key]: value, [column_name]: value }
+ * @param {object} column
+ * @return {any} value
+ */
+export const getCellValueByColumn = (record, column) => {
+ if (!record || !column) return null;
+ const { key, name } = column;
+ if (PRIVATE_COLUMN_KEYS.includes(key)) return record[key];
+ return record[name];
+};
+
+export const getTagName = (tag) => {
+ return tag ? tag[PRIVATE_COLUMN_KEY.TAG_NAME] : '';
+};
+
+export const getTagColor = (tag) => {
+ return tag ? tag[PRIVATE_COLUMN_KEY.TAG_COLOR] : '';
+};
+
+export const getTagId = (tag) => {
+ return tag ? tag[PRIVATE_COLUMN_KEY.ID] : '';
+};
+
+export const getTagFilesCount = (tag) => {
+ const links = tag ? tag[PRIVATE_COLUMN_KEY.TAG_FILE_LINKS] : [];
+ if (Array.isArray(links)) return links.length;
+ return 0;
+};
+export const getTagsByNameOrColor = (tags, nameOrColor) => {
+ if (!Array.isArray(tags) || tags.length === 0) return [];
+ if (!nameOrColor) return tags;
+ const value = nameOrColor.toLowerCase();
+ return tags.filter((tag) => {
+ const tagName = getTagName(tag);
+ if (tagName && tagName.toLowerCase().includes(value)) return true;
+ const tagColor = getTagColor(tag);
+ if (tagColor && tagColor.toLowerCase().includes(value)) return true;
+ return false;
+ });
+};
+
+export const getTagByNameOrColor = (tags, nameOrColor) => {
+ if (!Array.isArray(tags) || tags.length === 0) return null;
+ if (!nameOrColor) return null;
+ return tags.find((tag) => {
+ const tagName = getTagName(tag);
+ if (tagName && tagName === nameOrColor) return true;
+ const tagColor = getTagColor(tag);
+ if (tagColor && tagColor === nameOrColor) return true;
+ return false;
+ });
+};
diff --git a/frontend/src/tag/utils/cell/index.js b/frontend/src/tag/utils/cell/index.js
new file mode 100644
index 0000000000..4b0e041376
--- /dev/null
+++ b/frontend/src/tag/utils/cell/index.js
@@ -0,0 +1 @@
+export * from './core';
diff --git a/frontend/src/tag/utils/column/index.js b/frontend/src/tag/utils/column/index.js
new file mode 100644
index 0000000000..67002b836f
--- /dev/null
+++ b/frontend/src/tag/utils/column/index.js
@@ -0,0 +1,7 @@
+import { PRIVATE_COLUMN_KEYS } from '../../constants';
+
+export const getColumnOriginName = (column) => {
+ const { key, name } = column;
+ if (PRIVATE_COLUMN_KEYS.includes(key)) return key;
+ return name;
+};
diff --git a/frontend/src/tag/utils/index.js b/frontend/src/tag/utils/index.js
new file mode 100644
index 0000000000..8daf0480cd
--- /dev/null
+++ b/frontend/src/tag/utils/index.js
@@ -0,0 +1,2 @@
+export * from './cell';
+export * from './validate';
diff --git a/frontend/src/tag/utils/validate/index.js b/frontend/src/tag/utils/validate/index.js
new file mode 100644
index 0000000000..584cc0afa1
--- /dev/null
+++ b/frontend/src/tag/utils/validate/index.js
@@ -0,0 +1 @@
+export * from './tag';
diff --git a/frontend/src/tag/utils/validate/tag.js b/frontend/src/tag/utils/validate/tag.js
new file mode 100644
index 0000000000..a7d151b383
--- /dev/null
+++ b/frontend/src/tag/utils/validate/tag.js
@@ -0,0 +1,21 @@
+import { gettext } from '../../../utils/constants';
+
+export const isValidTagName = (name, names) => {
+ if (typeof name !== 'string') {
+ return { isValid: false, message: gettext('Name should be string') };
+ }
+ name = name.trim();
+ if (name === '') {
+ return { isValid: false, message: gettext('Name is required') };
+ }
+ if (name.includes('/')) {
+ return { isValid: false, message: gettext('Name cannot contain slash') };
+ }
+ if (name.includes('\\')) {
+ return { isValid: false, message: gettext('Name cannot contain backslash') };
+ }
+ if (names.includes(name)) {
+ return { isValid: false, message: gettext('Name already exists') };
+ }
+ return { isValid: true, message: name };
+};
diff --git a/frontend/src/tag/views/index.js b/frontend/src/tag/views/index.js
new file mode 100644
index 0000000000..bd856e3b46
--- /dev/null
+++ b/frontend/src/tag/views/index.js
@@ -0,0 +1,19 @@
+import React from 'react';
+import { TagViewProvider } from '../hooks';
+import View from './view';
+import TagsManagement from './tags-management';
+import { TAG_MANAGEMENT_ID } from '../constants';
+
+const Views = ({ ...params }) => {
+ if (params.tagID === TAG_MANAGEMENT_ID) {
+ return (
);
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default Views;
diff --git a/frontend/src/tag/views/tag-files/index.css b/frontend/src/tag/views/tag-files/index.css
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frontend/src/tag/views/tag-files/index.js b/frontend/src/tag/views/tag-files/index.js
new file mode 100644
index 0000000000..2aecc710fd
--- /dev/null
+++ b/frontend/src/tag/views/tag-files/index.js
@@ -0,0 +1,95 @@
+import React, { useCallback, useState } from 'react';
+import { useTagView } from '../../hooks';
+import { gettext } from '../../../utils/constants';
+import TagFile from './tag-file';
+import { getRecordIdFromRecord } from '../../../metadata/utils/cell';
+import EmptyTip from '../../../components/empty-tip';
+
+import './index.css';
+
+const TagFiles = () => {
+ const { tagFiles, repoID } = useTagView();
+ const [selectedFiles, setSelectedFiles] = useState(null);
+
+ const onMouseDown = useCallback((event) => {
+ if (event.button === 2) {
+ event.stopPropagation();
+ return;
+ }
+ }, []);
+
+ const onThreadMouseDown = useCallback((event) => {
+ onMouseDown(event);
+ }, [onMouseDown]);
+
+ const onThreadContextMenu = useCallback((event) => {
+ event.stopPropagation();
+ }, []);
+
+ const onSelectedAll = useCallback(() => {
+ const allIds = tagFiles.rows.map(record => getRecordIdFromRecord(record));
+ setSelectedFiles(allIds);
+ }, [tagFiles]);
+
+ const onSelectFile = useCallback((fileId) => {
+ let newSelectedFiles = selectedFiles ? selectedFiles.slice(0) : [];
+ if (newSelectedFiles.includes(fileId)) {
+ newSelectedFiles = newSelectedFiles.filter(item => item !== fileId);
+ } else {
+ newSelectedFiles.push(fileId);
+ }
+ if (newSelectedFiles.length > 0) {
+ setSelectedFiles(newSelectedFiles);
+ } else {
+ setSelectedFiles(null);
+ }
+ }, [selectedFiles]);
+
+ if (tagFiles.rows.length === 0) {
+ return (
);
+ }
+
+ const isSelectedAll = selectedFiles && selectedFiles.length === tagFiles.rows.length;
+
+ return (
+
+ );
+};
+
+export default TagFiles;
diff --git a/frontend/src/tag/views/tag-files/tag-file/index.css b/frontend/src/tag/views/tag-files/tag-file/index.css
new file mode 100644
index 0000000000..083d3737e0
--- /dev/null
+++ b/frontend/src/tag/views/tag-files/tag-file/index.css
@@ -0,0 +1,8 @@
+.tag-list-title .sf-metadata-tags-formatter .sf-metadata-tag-formatter {
+ height: 16px;
+ width: 16px;
+}
+
+.tag-list-title .sf-metadata-tags-formatter .sf-metadata-tag-formatter:last-child {
+ margin-right: 0;
+}
diff --git a/frontend/src/tag/views/tag-files/tag-file/index.js b/frontend/src/tag/views/tag-files/tag-file/index.js
new file mode 100644
index 0000000000..ca48e7295e
--- /dev/null
+++ b/frontend/src/tag/views/tag-files/tag-file/index.js
@@ -0,0 +1,108 @@
+import React, { useCallback, useMemo, useState } from 'react';
+
+import classnames from 'classnames';
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import { gettext, siteRoot, thumbnailDefaultSize } from '../../../../utils/constants';
+import { getParentDirFromRecord, getRecordIdFromRecord, getFileNameFromRecord, getFileSizedFromRecord,
+ getFileMTimeFromRecord, getTagsFromRecord,
+} from '../../../../metadata/utils/cell';
+import { Utils } from '../../../../utils/utils';
+import FileTagsFormatter from '../../../../metadata/components/cell-formatter/file-tags-formatter';
+
+import './index.css';
+
+dayjs.extend(relativeTime);
+
+const TagFile = ({ isSelected, repoID, file, onSelectFile }) => {
+ const [highlight, setHighlight] = useState(false);
+ const [isIconLoadError, setIconLoadError] = useState(false);
+
+ const fileId = useMemo(() => getRecordIdFromRecord(file), [file]);
+ const parentDir = useMemo(() => getParentDirFromRecord(file), [file]);
+ const name = useMemo(() => getFileNameFromRecord(file), [file]);
+ const size = useMemo(() => {
+ const sizeBytes = getFileSizedFromRecord(file);
+ return Utils.bytesToSize(sizeBytes);
+ }, [file]);
+ const mtime = useMemo(() => {
+ const time = getFileMTimeFromRecord(file);
+ if (time) return time;
+ return '';
+ }, [file]);
+ const tags = useMemo(() => getTagsFromRecord(file), [file]);
+
+ const mtimeTip = useMemo(() => mtime ? dayjs(mtime).format('dddd, MMMM D, YYYY h:mm:ss A') : '', [mtime]);
+ const mtimeRelative = useMemo(() => mtime ? dayjs(mtime).fromNow() : '', [mtime]);
+
+ const displayIcons = useMemo(() => {
+ const defaultIconUrl = Utils.getFileIconUrl(name);
+ if (Utils.imageCheck(name)) {
+ const path = Utils.encodePath(Utils.joinPath(parentDir, name));
+ const thumbnail = `${siteRoot}thumbnail/${repoID}/${thumbnailDefaultSize}${path}`;
+ return { iconUrl: thumbnail, defaultIconUrl };
+ }
+ return { iconUrl: defaultIconUrl, defaultIconUrl };
+ }, [repoID, name, parentDir]);
+
+ const displayIcon = useMemo(() => {
+ if (!isIconLoadError) return displayIcons.iconUrl;
+ return displayIcons.defaultIconUrl;
+ }, [isIconLoadError, displayIcons]);
+
+ const onMouseEnter = useCallback(() => {
+ setHighlight(true);
+ }, []);
+
+ const onMouseLeave = useCallback(() => {
+ setHighlight(false);
+ }, []);
+
+ const handleSelected = useCallback((event) => {
+ event.stopPropagation();
+ onSelectFile(fileId);
+ }, [fileId, onSelectFile]);
+
+ const onIconLoadError = useCallback(() => {
+ setIconLoadError(true);
+ }, []);
+
+ return (
+
+
+ {}}
+ checked={isSelected}
+ aria-label={isSelected ? gettext('Unselect this item') : gettext('Select this item')}
+ />
+ |
+
+
+ 
+
+ |
+
+ {name}
+ |
+ |
+
+
+ |
+ {size || ''} |
+ {mtimeRelative} |
+
+ );
+
+};
+
+export default TagFile;
diff --git a/frontend/src/tag/views/tags-management/index.css b/frontend/src/tag/views/tags-management/index.css
new file mode 100644
index 0000000000..dea3a9efec
--- /dev/null
+++ b/frontend/src/tag/views/tags-management/index.css
@@ -0,0 +1,37 @@
+.sf-metadata-tags-wrapper {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+.sf-metadata-tags-wrapper .sf-metadata-tags-main {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+.sf-metadata-tags-management-container {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+}
+
+.sf-metadata-tags-management-container .sf-metadata-container-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 6px;
+ padding-top: 2px;
+ border-bottom: 1px solid #eaeaea;
+}
+
+.sf-metadata-tags-management-container .sf-metadata-container-header .sf-metadata-container-header-add-tag {
+ font-size: 12px;
+ font-weight: 400;
+ height: 26px;
+ padding-bottom: 0;
+ padding-top: 0;
+}
diff --git a/frontend/src/tag/views/tags-management/index.js b/frontend/src/tag/views/tags-management/index.js
new file mode 100644
index 0000000000..47f13dbbcb
--- /dev/null
+++ b/frontend/src/tag/views/tags-management/index.js
@@ -0,0 +1,67 @@
+import React, { useCallback, useEffect, useState, useMemo } from 'react';
+import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
+import { Button } from 'reactstrap';
+import { useTags } from '../../hooks';
+import Main from './main';
+import { gettext } from '../../../utils/constants';
+import EditTagDialog from '../../components/dialog/edit-tag-dialog';
+
+import './index.css';
+import { EVENT_BUS_TYPE } from '../../../metadata/constants';
+
+const TagsManagement = () => {
+ const [isShowEditTagDialog, setShowEditTagDialog] = useState(false);
+
+ const { isLoading, tagsData, addTag, context } = useTags();
+
+ useEffect(() => {
+ const eventBus = context.eventBus;
+ eventBus.dispatch(EVENT_BUS_TYPE.RELOAD_DATA);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const tags = useMemo(() => {
+ if (!tagsData) return [];
+ return tagsData.rows;
+ }, [tagsData]);
+
+ const openAddTag = useCallback(() => {
+ setShowEditTagDialog(true);
+ }, []);
+
+ const closeAddTag = useCallback(() => {
+ setShowEditTagDialog(false);
+ }, []);
+
+ const handelAddTags = useCallback((tag, callback) => {
+ addTag(tag, callback);
+ }, [addTag]);
+
+ if (isLoading) return (
);
+ return (
+ <>
+
+
+
+
+
{gettext('Tags management')}
+
+ {context.canAddTag() && (
+
+ )}
+
+
+
+
+
+
+ {isShowEditTagDialog && (
+
+ )}
+ >
+ );
+};
+
+export default TagsManagement;
diff --git a/frontend/src/tag/views/tags-management/main/index.css b/frontend/src/tag/views/tags-management/main/index.css
new file mode 100644
index 0000000000..d5febf8aab
--- /dev/null
+++ b/frontend/src/tag/views/tags-management/main/index.css
@@ -0,0 +1,63 @@
+.sf-metadata-tags-table {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ overflow-y: scroll;
+ width: 100%;
+}
+
+.sf-metadata-tags-table .sf-metadata-tags-table-header {
+ height: 37px !important;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background-color: #fff;
+}
+
+.sf-metadata-tags-table .sf-metadata-tags-table-row {
+ height: 41px;
+ width: 100%;
+ border-bottom: 1px solid #eaeaea;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-shrink: 0;
+}
+
+.sf-metadata-tags-table .sf-metadata-tags-table-row:not(.sf-metadata-tags-table-header):hover {
+ 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;
+ height: 100%;
+ line-height: 40px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ padding: 0 8px;
+}
+
+.sf-metadata-tags-table .sf-metadata-tags-table-header .sf-metadata-tags-table-cell {
+ color: #999;
+ font-size: 13px;
+ line-height: 16px;
+ padding: 10px 8px;
+}
diff --git a/frontend/src/tag/views/tags-management/main/index.js b/frontend/src/tag/views/tags-management/main/index.js
new file mode 100644
index 0000000000..314d188c33
--- /dev/null
+++ b/frontend/src/tag/views/tags-management/main/index.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { gettext } from '../../../../utils/constants';
+import Tag from './tag';
+import EmptyTip from '../../../../components/empty-tip';
+import { getTagId } from '../../../utils/cell/core';
+
+import './index.css';
+
+const Main = ({ context, tags }) => {
+ if (tags.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
{gettext('tag')}
+
{gettext('File count')}
+
+
+ {tags.map(tag => {
+ const id = getTagId(tag);
+ return (
);
+ })}
+
+ );
+};
+
+Main.propTypes = {
+ context: PropTypes.object,
+ tags: PropTypes.array,
+};
+
+export default Main;
diff --git a/frontend/src/tag/views/tags-management/main/tag/index.css b/frontend/src/tag/views/tags-management/main/tag/index.css
new file mode 100644
index 0000000000..2ae0cb9075
--- /dev/null
+++ b/frontend/src/tag/views/tags-management/main/tag/index.css
@@ -0,0 +1,39 @@
+.sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-actions {
+ display: none;
+ height: 100%;
+ width: 100%;
+}
+
+.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 {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sf-metadata-tags-table-cell-tag .sf-metadata-tag-color {
+ display: inline-block;
+ height: 12px;
+ width: 12px;
+ border-radius: 50%;
+ margin-right: 8px;
+ position: relative;
+ top: 1px;
+}
+
+.sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action {
+ height: 20px;
+ width: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.sf-metadata-tags-table-row:hover .sf-metadata-tags-table-cell .sf-metadata-tags-table-cell-action .op-icon {
+ margin-left: 0;
+}
diff --git a/frontend/src/tag/views/tags-management/main/tag/index.js b/frontend/src/tag/views/tags-management/main/tag/index.js
new file mode 100644
index 0000000000..d85b4ba15c
--- /dev/null
+++ b/frontend/src/tag/views/tags-management/main/tag/index.js
@@ -0,0 +1,77 @@
+import React, { useCallback, useState } from 'react';
+import { getTagName, getTagColor, getTagFilesCount, getTagId } from '../../../../utils/cell/core';
+import { gettext } from '../../../../../utils/constants';
+import EditTagDialog from '../../../../components/dialog/edit-tag-dialog';
+import DeleteConfirmDialog from '../../../../../metadata/components/dialog/delete-confirm-dialog';
+import { useTags } from '../../../../hooks';
+
+import './index.css';
+
+const Tag = ({ tags, tag, context }) => {
+ const tagId = getTagId(tag);
+ const tagName = getTagName(tag);
+ const tagColor = getTagColor(tag);
+ const fileCount = getTagFilesCount(tag);
+ const [isShowEditTagDialog, setShowEditTagDialog] = useState(false);
+ const [isShowDeleteDialog, setShowDeleteDialog] = useState(false);
+
+ const { updateTag, deleteTags } = useTags();
+
+ const openEditTagDialog = useCallback(() => {
+ setShowEditTagDialog(true);
+ }, []);
+
+ const closeEditTagDialog = useCallback(() => {
+ setShowEditTagDialog(false);
+ }, []);
+
+ const handelEditTag = useCallback((update, callback) => {
+ updateTag(tagId, update, callback);
+ }, [tagId, updateTag]);
+
+ const openDeleteConfirmDialog = useCallback(() => {
+ setShowDeleteDialog(true);
+ }, []);
+
+ const closeDeleteConfirmDialog = useCallback(() => {
+ setShowDeleteDialog(false);
+ }, []);
+
+ const handelDelete = useCallback(() => {
+ deleteTags([tagId]);
+ }, [tagId, deleteTags]);
+
+ return (
+ <>
+
+
+
+ {tagName}
+
+
{fileCount}
+
+
+ {context.canModifyTag() && (
+
+
+
+ )}
+ {context.checkCanDeleteTag() && (
+
+
+
+ )}
+
+
+
+ {isShowEditTagDialog && (
+
+ )}
+ {isShowDeleteDialog && (
+
+ )}
+ >
+ );
+};
+
+export default Tag;
diff --git a/frontend/src/tag/views/view.js b/frontend/src/tag/views/view.js
new file mode 100644
index 0000000000..2591acb398
--- /dev/null
+++ b/frontend/src/tag/views/view.js
@@ -0,0 +1,24 @@
+import React, { useCallback } from 'react';
+import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
+import { useTagView } from '../hooks';
+import TagFiles from './tag-files';
+
+const View = () => {
+ const { isLoading, errorMessage, tagFiles } = useTagView();
+
+ const renderTagView = useCallback(() => {
+ if (!tagFiles) return null;
+ return (
);
+ }, [tagFiles]);
+
+ if (isLoading) return (
);
+ return (
+
+
+ {errorMessage ?
{errorMessage}
: renderTagView()}
+
+
+ );
+};
+
+export default View;
diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js
index 6549a993ea..2169e8780a 100644
--- a/frontend/src/utils/utils.js
+++ b/frontend/src/utils/utils.js
@@ -159,10 +159,6 @@ export const Utils = {
}
},
- isFaceRecognition: function (type) {
- return type === PRIVATE_FILE_TYPE.FACE_RECOGNITION;
- },
-
getShareLinkPermissionList: function (itemType, permission, path, canEdit) {
// itemType: library, dir, file
// permission: rw, r, admin, cloud-edit, preview, custom-*
@@ -1090,6 +1086,10 @@ export const Utils = {
return type === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES;
},
+ isTags: function (type) {
+ return type === PRIVATE_FILE_TYPE.TAGS_PROPERTIES;
+ },
+
isInternalFileLink: function (url, repoID) {
var re = new RegExp(serviceURL + '/lib/' + repoID + '/file.*');
return re.test(url);
diff --git a/frontend/src/view-file-sdoc.js b/frontend/src/view-file-sdoc.js
index cb300f998b..505b0c1849 100644
--- a/frontend/src/view-file-sdoc.js
+++ b/frontend/src/view-file-sdoc.js
@@ -5,7 +5,8 @@ import i18n from './_i18n/i18n-sdoc-editor';
import { Utils } from './utils/utils';
import Loading from './components/loading';
import SdocEditor from './pages/sdoc/sdoc-editor';
-import { CollaboratorsProvider, EnableMetadataProvider } from './metadata';
+import { MetadataStatusProvider } from './hooks';
+import { CollaboratorsProvider } from './metadata';
const { serviceURL, avatarURL, siteRoot, lang, mediaUrl, isPro } = window.app.config;
const { username, name } = window.app.userInfo;
@@ -52,11 +53,11 @@ window.seafile = {
ReactDom.render(
}>
-
+
-
+
,
document.getElementById('wrapper')
diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/repo_metadata/apis.py
similarity index 69%
rename from seahub/api2/endpoints/metadata_manage.py
rename to seahub/repo_metadata/apis.py
index ebcfa3b05b..635de07abc 100644
--- a/seahub/api2/endpoints/metadata_manage.py
+++ b/seahub/repo_metadata/apis.py
@@ -15,7 +15,8 @@ from seahub.repo_metadata.models import RepoMetadata, RepoMetadataViews
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, add_init_face_recognition_task, \
- extract_file_details, get_someone_similar_faces, remove_faces_table, FACES_SAVE_PATH
+ extract_file_details, get_someone_similar_faces, remove_faces_table, FACES_SAVE_PATH, \
+ init_tags, remove_tags_table
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
@@ -34,7 +35,7 @@ class MetadataManage(APIView):
"""
check the repo has enabled the metadata manage or not
"""
- # recource check
+ # resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
error_msg = 'Library %s not found.' % repo_id
@@ -45,18 +46,23 @@ class MetadataManage(APIView):
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+ is_enabled = False
+ is_tags_enabled = False
try:
record = RepoMetadata.objects.filter(repo_id=repo_id).first()
if record and record.enabled:
is_enabled = True
- else:
- is_enabled = False
+ if record and record.tags_enabled:
+ is_tags_enabled = True
except Exception as e:
logger.error(e)
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
- return Response({'enabled': is_enabled})
+ return Response({
+ 'enabled': is_enabled,
+ 'tags_enabled': is_tags_enabled,
+ })
def put(self, request, repo_id):
"""
@@ -137,6 +143,7 @@ class MetadataManage(APIView):
try:
record.enabled = False
record.face_recognition_enabled = False
+ record.tags_enabled = False
record.save()
RepoMetadataViews.objects.filter(repo_id=repo_id).delete()
except Exception as e:
@@ -247,7 +254,7 @@ class MetadataRecords(APIView):
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
- from seafevents.repo_metadata.utils import METADATA_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE
try:
columns_data = metadata_server_api.list_columns(METADATA_TABLE.id)
columns = columns_data.get('columns', [])
@@ -363,7 +370,7 @@ class MetadataRecordInfo(APIView):
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
- from seafevents.repo_metadata.utils import METADATA_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE
sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE \
`{METADATA_TABLE.columns.parent_dir.name}`=? AND `{METADATA_TABLE.columns.file_name.name}`=?;'
@@ -417,7 +424,7 @@ class MetadataColumns(APIView):
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
- from seafevents.repo_metadata.utils import METADATA_TABLE, MetadataColumn
+ from seafevents.repo_metadata.constants import METADATA_TABLE, MetadataColumn
columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns')
column_keys = set()
column_names = set()
@@ -479,7 +486,7 @@ class MetadataColumns(APIView):
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
- from seafevents.repo_metadata.utils import METADATA_TABLE, MetadataColumn
+ from seafevents.repo_metadata.constants import METADATA_TABLE, MetadataColumn
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns')
try:
@@ -524,7 +531,7 @@ class MetadataColumns(APIView):
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
- from seafevents.repo_metadata.utils import METADATA_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns')
@@ -866,7 +873,7 @@ class FacesRecords(APIView):
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
- from seafevents.repo_metadata.utils import FACES_TABLE
+ from seafevents.repo_metadata.constants import FACES_TABLE
try:
metadata = metadata_server_api.get_metadata()
@@ -947,7 +954,7 @@ class FacesRecord(APIView):
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
- from seafevents.repo_metadata.utils import FACES_TABLE
+ from seafevents.repo_metadata.constants import FACES_TABLE
try:
metadata = metadata_server_api.get_metadata()
@@ -1027,7 +1034,7 @@ class PeoplePhotos(APIView):
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
- from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE, FACES_TABLE
try:
metadata = metadata_server_api.get_metadata()
@@ -1079,7 +1086,7 @@ class FaceRecognitionManage(APIView):
throttle_classes = (UserRateThrottle, )
def get(self, request, repo_id):
- # recource check
+ # resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
error_msg = 'Library %s not found.' % repo_id
@@ -1228,3 +1235,494 @@ class MetadataExtractFileDetails(APIView):
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
return Response({'details': resp})
+
+
+# tags
+class MetadataTagsStatusManage(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def put(self, request, repo_id):
+ # resource check
+ 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 is_repo_admin(request.user.username, repo_id):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ metadata = RepoMetadata.objects.filter(repo_id=repo_id).first()
+ if not metadata or not metadata.enabled:
+ error_msg = f'The metadata module is not enabled for repo {repo_id}.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ try:
+ metadata.tags_enabled = True
+ metadata.save()
+ except Exception as e:
+ logger.exception(e)
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
+
+ metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
+ init_tags(metadata_server_api)
+
+ return Response({'success': True})
+
+ def delete(self, request, repo_id):
+ # resource check
+ repo = seafile_api.get_repo(repo_id)
+ if not repo:
+ error_msg = 'Library %s not found.' % repo_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ # permission check
+ if not is_repo_admin(request.user.username, repo_id):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ # check dose the repo have opened metadata manage
+ record = RepoMetadata.objects.filter(repo_id=repo_id).first()
+ if not record or not record.enabled or not record.tags_enabled:
+ error_msg = f'The repo {repo_id} has disabled the tags manage.'
+ return api_error(status.HTTP_409_CONFLICT, error_msg)
+
+ metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
+ try:
+ remove_tags_table(metadata_server_api)
+ except Exception as err:
+ logger.error(err)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ try:
+ record.tags_enabled = False
+ record.save()
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
+
+
+class MetadataTags(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id):
+ 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)
+ from seafevents.repo_metadata.constants import TAGS_TABLE
+
+ 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)
+
+ 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')
+
+ sql = f'SELECT * FROM `{TAGS_TABLE.name}` ORDER BY `_ctime` LIMIT {0}, {1000}'
+
+ try:
+ query_result = metadata_server_api.query_rows(sql)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response(query_result)
+
+
+
+ def post(self, request, repo_id):
+ tags_data = request.data.get('tags_data', [])
+
+ if not tags_data:
+ error_msg = f'Tags data is required.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ 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)
+
+ from seafevents.repo_metadata.constants import TAGS_TABLE
+ 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)
+
+ 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:
+ resp = metadata_server_api.insert_rows(tags_table_id, tags_data)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ row_ids = resp.get('row_ids', [])
+
+ if not row_ids:
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ sql = 'SELECT * FROM %s WHERE `%s` in (%s)' % (TAGS_TABLE.name, TAGS_TABLE.columns.id.name, ', '.join(["'%s'" % id for id in row_ids]))
+
+ try:
+ query_new_rows = metadata_server_api.query_rows(sql)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({ 'tags': query_new_rows.get('results', []) })
+
+ def put(self, request, repo_id):
+ tags_data = request.data.get('tags_data')
+ if not tags_data:
+ error_msg = 'tags_data invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ 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)
+
+ from seafevents.repo_metadata.constants import TAGS_TABLE
+ 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)
+
+ 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')
+
+ tag_id_to_tag = {}
+ sql = f'SELECT `_id` FROM `{TAGS_TABLE.name}` WHERE '
+ parameters = []
+ for tag_data in tags_data:
+ tag = tag_data.get('tag', {})
+ if not tag:
+ continue
+ tag_id = tag_data.get('tag_id', '')
+ if not tag_id:
+ error_msg = 'record_id invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ sql += f' `{TAGS_TABLE.columns.id.name}` = ? OR '
+ parameters.append(tag_id)
+ tag_id_to_tag[tag_id] = tag
+
+ sql = sql.rstrip('OR ')
+ sql += ';'
+
+ if not parameters:
+ return Response({'success': True})
+
+ try:
+ query_result = metadata_server_api.query_rows(sql, parameters)
+ except Exception as e:
+ logger.exception(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ results = query_result.get('results')
+ if not results:
+ # file or folder has been deleted
+ return Response({'success': True})
+
+ rows = []
+ for tag in results:
+ tag_id = tag.get('_id')
+ update = tag_id_to_tag.get(tag_id)
+ update[TAGS_TABLE.columns.id.name] = tag_id
+ rows.append(update)
+ if rows:
+ try:
+ metadata_server_api.update_rows(tags_table_id, rows)
+ except Exception as e:
+ logger.exception(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
+
+ def delete(self, request, repo_id):
+ tag_ids = request.data.get('tag_ids', [])
+
+ if not tag_ids:
+ error_msg = f'Tag ids is required.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ 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)
+
+ from seafevents.repo_metadata.constants import TAGS_TABLE
+ 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)
+
+ 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:
+ resp = metadata_server_api.delete_rows(tags_table_id, tag_ids)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
+
+
+class MetadataFileTags(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def post(self, request, repo_id):
+ record_id = request.data.get('record_id')
+ if not record_id:
+ error_msg = 'record_id invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ tags = request.data.get('tags', [])
+ if not tags:
+ error_msg = 'tags invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ 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)
+
+ from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE
+ 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)
+
+ 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:
+ metadata_server_api.insert_link(repo_id, TAGS_TABLE.link_id, METADATA_TABLE.id, { record_id: tags })
+
+ except Exception as e:
+ logger.exception(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
+
+ def put(self, request, repo_id):
+ record_id = request.data.get('record_id')
+ if not record_id:
+ error_msg = 'record_id invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ tags = request.data.get('tags', [])
+
+ 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)
+
+ from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE
+ 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)
+
+ 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:
+ metadata_server_api.update_link(repo_id, TAGS_TABLE.link_id, METADATA_TABLE.id, { record_id: tags })
+
+ except Exception as e:
+ logger.exception(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
+
+
+class MetadataTagFiles(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id, tag_id):
+ 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)
+
+ from seafevents.repo_metadata.constants import TAGS_TABLE, METADATA_TABLE
+ 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)
+
+ 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')
+
+ tag_files_record_sql = f'SELECT * FROM {TAGS_TABLE.name} WHERE `{TAGS_TABLE.columns.id.name}` = "{tag_id}"'
+ try:
+ tag_query = metadata_server_api.query_rows(tag_files_record_sql)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ tag_files_records = tag_query.get('results', [])
+ if not tag_files_records:
+ return Response({ 'metadata': [], 'results': [] })
+
+ tag_files_record = tag_files_records[0]
+ tag_files_record_ids = tag_files_record.get(TAGS_TABLE.columns.file_links.name , [])
+
+ if not tag_files_record_ids:
+ return Response({ 'metadata': [], 'results': [] })
+
+ print(tag_files_record_ids)
+
+ tag_files_sql = 'SELECT `%s`, `%s`, `%s`, `%s`, `%s`, `%s` FROM %s WHERE `%s` IN (%s)' % (METADATA_TABLE.columns.id.name, METADATA_TABLE.columns.file_name.name, \
+ METADATA_TABLE.columns.parent_dir.name, METADATA_TABLE.columns.size.name, \
+ METADATA_TABLE.columns.file_mtime.name, METADATA_TABLE.columns.tags.name, \
+ METADATA_TABLE.name, METADATA_TABLE.columns.id.name, \
+ ', '.join(["'%s'" % id.get('row_id') for id in tag_files_record_ids]))
+ try:
+ tag_files_query = metadata_server_api.query_rows(tag_files_sql)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response(tag_files_query)
+
diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py
index 108d4465dd..1cd0393ae7 100644
--- a/seahub/repo_metadata/metadata_server_api.py
+++ b/seahub/repo_metadata/metadata_server_api.py
@@ -3,7 +3,7 @@ from seahub.settings import METADATA_SERVER_URL, METADATA_SERVER_SECRET_KEY
def list_metadata_records(repo_id, user, parent_dir=None, name=None, is_dir=None, start=0, limit=1000, order_by=None):
- from seafevents.repo_metadata.utils import METADATA_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE
sql = f'SELECT * FROM `{METADATA_TABLE.name}`'
parameters = []
@@ -43,7 +43,8 @@ def list_metadata_records(repo_id, user, parent_dir=None, name=None, is_dir=None
return response_results
def list_metadata_view_records(repo_id, user, view, start=0, limit=1000):
- from seafevents.repo_metadata.utils import METADATA_TABLE, gen_view_data_sql
+ from seafevents.repo_metadata.constants import METADATA_TABLE
+ from seafevents.repo_metadata.utils import gen_view_data_sql
metadata_server_api = MetadataServerAPI(repo_id, user)
columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns')
sql = gen_view_data_sql(METADATA_TABLE, columns, view, start, limit, user)
@@ -205,3 +206,25 @@ class MetadataServerAPI:
}
response = requests.delete(url, json=data, headers=self.headers, timeout=self.timeout)
return parse_response(response)
+
+
+ # link
+ def insert_link(self, base_id, link_id, table_id, row_id_map):
+ 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
+ }
+ 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):
+ 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
+ }
+ response = requests.put(url, json=data, headers=self.headers, timeout=self.timeout)
+ return parse_response(response)
diff --git a/seahub/repo_metadata/models.py b/seahub/repo_metadata/models.py
index 7f0df4ea06..5a5353bf4b 100644
--- a/seahub/repo_metadata/models.py
+++ b/seahub/repo_metadata/models.py
@@ -61,6 +61,7 @@ class RepoMetadata(models.Model):
face_recognition_enabled = models.BooleanField(db_index=True)
from_commit = models.CharField(max_length=40)
to_commit = models.CharField(max_length=40)
+ tags_enabled = models.BooleanField(db_index=True)
objects = RepoMetadataManager()
@@ -98,7 +99,7 @@ class RepoMetadataViewsManager(models.Manager):
def add_view(self, repo_id, view_name, view_type='table', view_data={}):
metadata_views = self.filter(repo_id=repo_id).first()
if not metadata_views:
- from seafevents.repo_metadata.utils import METADATA_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE
# init view data
new_view = RepoView(view_name, view_type, {
diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py
new file mode 100644
index 0000000000..d27354cb62
--- /dev/null
+++ b/seahub/repo_metadata/urls.py
@@ -0,0 +1,27 @@
+from django.urls import re_path
+from .apis import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \
+ MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \
+ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \
+ MetadataFileTags, MetadataTagFiles
+
+urlpatterns = [
+ re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'),
+ re_path(r'^records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'),
+ re_path(r'^record/$', MetadataRecordInfo.as_view(), name='api-v2.1-metadata-record-info'),
+ re_path(r'^columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'),
+ re_path(r'^views/$', MetadataViews.as_view(), name='api-v2.1-metadata-views'),
+ re_path(r'^views/(?P
[-0-9a-zA-Z]{4})/$', MetadataViewsDetailView.as_view(), name='api-v2.1-metadata-views-detail'),
+ re_path(r'^move-views/$', MetadataViewsMoveView.as_view(), name='api-v2.1-metadata-views-move'),
+ re_path(r'^duplicate-view/$', MetadataViewsDuplicateView.as_view(), name='api-v2.1-metadata-view-duplicate'),
+ re_path(r'^face-record/$', FacesRecord.as_view(), name='api-v2.1-metadata-face-record'),
+ re_path(r'^face-records/$', FacesRecords.as_view(), name='api-v2.1-metadata-face-records'),
+ re_path(r'^people-photos/(?P.+)/$', PeoplePhotos.as_view(), name='api-v2.1-metadata-people-photos'),
+ re_path(r'^face-recognition/$', FaceRecognitionManage.as_view(), name='api-v2.1-metadata-face-recognition'),
+ re_path(r'^extract-file-details/$', MetadataExtractFileDetails.as_view(), name='api-v2.1-metadata-extract-file-details'),
+
+ # 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'^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 378e86279b..4532999f93 100644
--- a/seahub/repo_metadata/utils.py
+++ b/seahub/repo_metadata/utils.py
@@ -32,7 +32,7 @@ def add_init_face_recognition_task(params):
def get_someone_similar_faces(faces, metadata_server_api):
- from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE, FACES_TABLE
sql = f'SELECT `{METADATA_TABLE.columns.id.name}`, `{METADATA_TABLE.columns.parent_dir.name}`, `{METADATA_TABLE.columns.file_name.name}` FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}` IN ('
parameters = []
query_result = []
@@ -82,30 +82,8 @@ def gen_unique_id(id_set, length=4):
_id = generator_base64_code(length)
-def get_sys_columns():
- from seafevents.repo_metadata.utils import METADATA_TABLE
- columns = [
- METADATA_TABLE.columns.file_creator.to_dict(),
- METADATA_TABLE.columns.file_ctime.to_dict(),
- METADATA_TABLE.columns.file_modifier.to_dict(),
- METADATA_TABLE.columns.file_mtime.to_dict(),
- METADATA_TABLE.columns.parent_dir.to_dict(),
- METADATA_TABLE.columns.file_name.to_dict(),
- METADATA_TABLE.columns.is_dir.to_dict(),
- METADATA_TABLE.columns.file_type.to_dict(),
- METADATA_TABLE.columns.location.to_dict(),
- METADATA_TABLE.columns.obj_id.to_dict(),
- METADATA_TABLE.columns.size.to_dict(),
- METADATA_TABLE.columns.suffix.to_dict(),
- METADATA_TABLE.columns.file_details.to_dict(),
- METADATA_TABLE.columns.description.to_dict(),
- ]
-
- return columns
-
-
-def get_link_column(face_table_id):
- from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+def get_face_link_column(face_table_id):
+ from seafevents.repo_metadata.constants import METADATA_TABLE, FACES_TABLE
columns = [
METADATA_TABLE.columns.face_vectors.to_dict(),
METADATA_TABLE.columns.face_links.to_dict({
@@ -120,7 +98,7 @@ def get_link_column(face_table_id):
def get_face_columns(face_table_id):
- from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE, FACES_TABLE
columns = [
FACES_TABLE.columns.photo_links.to_dict({
'link_id': FACES_TABLE.link_id,
@@ -136,7 +114,7 @@ def get_face_columns(face_table_id):
def get_unmodifiable_columns():
- from seafevents.repo_metadata.utils import METADATA_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE
columns = [
METADATA_TABLE.columns.file_creator.to_dict(),
METADATA_TABLE.columns.file_ctime.to_dict(),
@@ -157,25 +135,25 @@ def get_unmodifiable_columns():
def init_metadata(metadata_server_api):
- from seafevents.repo_metadata.utils import METADATA_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE, METADATA_TABLE_SYS_COLUMNS
# delete base to prevent dirty data caused by last failure
metadata_server_api.delete_base()
metadata_server_api.create_base()
# init sys column
- sys_columns = get_sys_columns()
+ sys_columns = METADATA_TABLE_SYS_COLUMNS
metadata_server_api.add_columns(METADATA_TABLE.id, sys_columns)
def init_faces(metadata_server_api):
- from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE, FACES_TABLE
remove_faces_table(metadata_server_api)
resp = metadata_server_api.create_table(FACES_TABLE.name)
# init link column
- link_column = get_link_column(resp['id'])
+ link_column = get_face_link_column(resp['id'])
metadata_server_api.add_columns(METADATA_TABLE.id, link_column)
# init face column
@@ -184,7 +162,7 @@ def init_faces(metadata_server_api):
def remove_faces_table(metadata_server_api):
- from seafevents.repo_metadata.utils import METADATA_TABLE, FACES_TABLE
+ from seafevents.repo_metadata.constants import METADATA_TABLE, FACES_TABLE
metadata = metadata_server_api.get_metadata()
tables = metadata.get('tables', [])
@@ -198,6 +176,69 @@ def remove_faces_table(metadata_server_api):
metadata_server_api.delete_column(table['id'], column['key'], True)
+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
+ 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):
+ from seafevents.repo_metadata.constants import METADATA_TABLE, 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)
+
+
+
+def remove_tags_table(metadata_server_api):
+ from seafevents.repo_metadata.constants import METADATA_TABLE, TAGS_TABLE
+ metadata = metadata_server_api.get_metadata()
+
+ tables = metadata.get('tables', [])
+ for table in tables:
+ if table['name'] == TAGS_TABLE.name:
+ metadata_server_api.delete_table(table['id'])
+ elif table['name'] == METADATA_TABLE.name:
+ columns = table.get('columns', [])
+ for column in columns:
+ if column['key'] in [METADATA_TABLE.columns.tags.key]:
+ metadata_server_api.delete_column(table['id'], column['key'])
+
+
def get_file_download_token(repo_id, file_id, username):
return seafile_api.get_fileserver_access_token(repo_id, file_id, 'download', username, use_onetime=True)
diff --git a/seahub/urls.py b/seahub/urls.py
index 6024273f4a..c53c008524 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -212,9 +212,6 @@ from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView,
Wiki2DuplicatePageView, WikiPageTrashView, Wiki2PublishView, Wiki2PublishConfigView, Wiki2PublishPageView, \
WikiSearch, WikiConvertView
from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView
-from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \
- MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \
- FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos
from seahub.api2.endpoints.user_list import UserListView
from seahub.api2.endpoints.seahub_io import SeahubIOStatus
@@ -1048,21 +1045,9 @@ if getattr(settings, 'ENABLE_SUBSCRIPTION', False):
re_path(r'^api/v2.1/subscription/logs/$', SubscriptionLogsView.as_view(), name='api-v2.1-subscription-logs'),
]
-if settings.ENABLE_METADATA_MANAGEMENT:
+if getattr(settings, 'ENABLE_METADATA_MANAGEMENT', False):
urlpatterns += [
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/$', MetadataManage.as_view(), name='api-v2.1-metadata'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/record/$', MetadataRecordInfo.as_view(), name='api-v2.1-metadata-record-info'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/views/$', MetadataViews.as_view(), name='api-v2.1-metadata-views'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/views/(?P[-0-9a-zA-Z]{4})/$', MetadataViewsDetailView.as_view(), name='api-v2.1-metadata-views-detail'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/move-views/$', MetadataViewsMoveView.as_view(), name='api-v2.1-metadata-views-move'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/duplicate-view/$', MetadataViewsDuplicateView.as_view(), name='api-v2.1-metadata-view-duplicate'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-record/$', FacesRecord.as_view(), name='api-v2.1-metadata-face-record'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-records/$', FacesRecords.as_view(), name='api-v2.1-metadata-face-records'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/people-photos/(?P.+)/$', PeoplePhotos.as_view(), name='api-v2.1-metadata-people-photos'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/face-recognition/$', FaceRecognitionManage.as_view(), name='api-v2.1-metadata-face-recognition'),
- re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/extract-file-details/$', MetadataExtractFileDetails.as_view(), name='api-v2.1-metadata-extract-file-details'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/', include('seahub.repo_metadata.urls')),
]
# ai API