diff --git a/frontend/src/assets/icons/open-record.svg b/frontend/src/assets/icons/expand.svg similarity index 100% rename from frontend/src/assets/icons/open-record.svg rename to frontend/src/assets/icons/expand.svg diff --git a/frontend/src/assets/icons/link.svg b/frontend/src/assets/icons/link.svg new file mode 100644 index 0000000000..5aad94f55c --- /dev/null +++ b/frontend/src/assets/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/cur-dir-path/dir-path.js b/frontend/src/components/cur-dir-path/dir-path.js index 2ef7694dc8..ee51c0f8bd 100644 --- a/frontend/src/components/cur-dir-path/dir-path.js +++ b/frontend/src/components/cur-dir-path/dir-path.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { Link } from '@gatsbyjs/reach-router'; import DirOperationToolBar from '../../components/toolbar/dir-operation-toolbar'; import MetadataViewName from '../../metadata/components/metadata-view-name'; +import TagViewName from '../../tag/components/tag-view-name'; import { siteRoot, gettext } from '../../utils/constants'; import { Utils } from '../../utils/utils'; import { PRIVATE_FILE_TYPE } from '../../constants'; @@ -134,6 +135,15 @@ class DirPath extends React.Component { ); } + if (index === pathList.length - 2 && item === PRIVATE_FILE_TYPE.TAGS_PROPERTIES) { + return ( + + / + {gettext('Tags')} + + ); + } + if (index === pathList.length - 1 && pathList[pathList.length - 2] === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) { return ( @@ -143,6 +153,15 @@ class DirPath extends React.Component { ); } + if (index === pathList.length - 1 && pathList[pathList.length - 2] === PRIVATE_FILE_TYPE.TAGS_PROPERTIES) { + return ( + + / + + + ); + } + if (index === (pathList.length - 1)) { return ( diff --git a/frontend/src/components/cur-dir-path/dir-tool.js b/frontend/src/components/cur-dir-path/dir-tool.js index 426cc0d4b5..fc4bde9027 100644 --- a/frontend/src/components/cur-dir-path/dir-tool.js +++ b/frontend/src/components/cur-dir-path/dir-tool.js @@ -109,6 +109,7 @@ class DirTool extends React.Component { const { repoID, currentMode, currentPath, sortBy, sortOrder, viewId, isCustomPermission } = this.props; const propertiesText = TextTranslation.PROPERTIES.value; const isFileExtended = currentPath.startsWith('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/'); + const isTagView = currentPath.startsWith('/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES + '/'); const sortOptions = this.sortOptions.map(item => { return { @@ -130,6 +131,13 @@ class DirTool extends React.Component { ); } + if (isTagView) { + return ( +
+
+ ); + } + return (
diff --git a/frontend/src/components/dialog/lib-settings.js b/frontend/src/components/dialog/lib-settings.js index aab07e5817..4ce337b6b4 100644 --- a/frontend/src/components/dialog/lib-settings.js +++ b/frontend/src/components/dialog/lib-settings.js @@ -7,11 +7,21 @@ import LibAutoDelSettingPanel from './lib-settings/lib-old-files-auto-del-settin import { MetadataStatusManagementDialog as LibExtendedPropertiesSettingPanel, MetadataFaceRecognitionDialog as LibFaceRecognitionSettingPanel, + MetadataTagsStatusDialog as LibMetadataTagsStatusSettingsPanel, useMetadata } from '../../metadata'; +import { useMetadataStatus } from '../../hooks'; import '../../css/lib-settings.css'; +const TAB = { + HISTORY_SETTINGS: 'history_settings', + AUTO_DELETE_SETTINGS: 'auto_delete_settings', + EXTENDED_PROPERTIES_SETTINGS: 'extended_properties_settings', + FACE_RECOGNITION_SETTINGS: 'face_recognition_settings', + TAGS_SETTINGS: 'tags_settings', +}; + const propTypes = { toggleDialog: PropTypes.func.isRequired, repoID: PropTypes.string.isRequired, @@ -19,7 +29,7 @@ const propTypes = { }; const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => { - let [activeTab, setActiveTab] = useState(tab || 'historySetting'); + const [activeTab, setActiveTab] = useState(tab || TAB.HISTORY_SETTINGS); const toggleTab = useCallback((tab) => { setActiveTab(tab); @@ -33,11 +43,12 @@ const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => { const { encrypted, is_admin } = currentRepoInfo; const { enableMetadataManagement } = window.app.pageOptions; - const { enableMetadata, updateEnableMetadata, enableFaceRecognition, updateEnableFaceRecognition } = useMetadata(); + const { enableFaceRecognition, updateEnableFaceRecognition } = useMetadata(); + const { enableMetadata, updateEnableMetadata, enableTags, updateEnableTags } = useMetadataStatus(); const enableHistorySetting = is_admin; // repo owner, admin of the department which the repo belongs to, and ... const enableAutoDelSetting = is_admin && enableRepoAutoDel; const enableExtendedPropertiesSetting = !encrypted && is_admin && enableMetadataManagement; - const enableFaceRecognitionSetting = enableExtendedPropertiesSetting && enableMetadata; + const enableMetadataOtherSettings = enableExtendedPropertiesSetting && enableMetadata; return (
@@ -49,73 +60,90 @@ const LibSettingsDialog = ({ repoID, currentRepoInfo, toggleDialog, tab }) => {
- {(enableHistorySetting && activeTab === 'historySetting') && - + {(enableHistorySetting && activeTab === TAB.HISTORY_SETTINGS) && ( + - } - {(enableAutoDelSetting && activeTab === 'autoDelSetting') && - + )} + {(enableAutoDelSetting && activeTab === TAB.AUTO_DELETE_SETTINGS) && ( + - } - {(enableExtendedPropertiesSetting && activeTab === 'extendedPropertiesSetting') && - + )} + {(enableExtendedPropertiesSetting && activeTab === TAB.EXTENDED_PROPERTIES_SETTINGS) && ( + { updateEnableMetadata(value); }} + submit={updateEnableMetadata} toggleDialog={toggleDialog} /> - } - {(enableFaceRecognitionSetting && activeTab === 'faceRecognitionSetting') && - + )} + {(enableMetadataOtherSettings && activeTab === TAB.FACE_RECOGNITION_SETTINGS) && ( + { updateEnableFaceRecognition(value); }} + submit={updateEnableFaceRecognition} toggleDialog={toggleDialog} /> - } + )} + {(enableMetadataOtherSettings && activeTab === TAB.TAGS_SETTINGS) && ( + + + + )}
diff --git a/frontend/src/components/dir-view-mode/constants.js b/frontend/src/components/dir-view-mode/constants.js index 96c85d5843..88f82f3e44 100644 --- a/frontend/src/components/dir-view-mode/constants.js +++ b/frontend/src/components/dir-view-mode/constants.js @@ -2,3 +2,4 @@ export const LIST_MODE = 'list'; export const GRID_MODE = 'grid'; export const DIRENT_DETAIL_MODE = 'detail'; export const METADATA_MODE = 'metadata'; +export const TAGS_MODE = 'tags'; diff --git a/frontend/src/components/dir-view-mode/dir-column-nav.css b/frontend/src/components/dir-view-mode/dir-column-nav/index.css similarity index 100% rename from frontend/src/components/dir-view-mode/dir-column-nav.css rename to frontend/src/components/dir-view-mode/dir-column-nav/index.css diff --git a/frontend/src/components/dir-view-mode/dir-column-nav.js b/frontend/src/components/dir-view-mode/dir-column-nav/index.js similarity index 93% rename from frontend/src/components/dir-view-mode/dir-column-nav.js rename to frontend/src/components/dir-view-mode/dir-column-nav/index.js index 552bf27658..e6fcf73b87 100644 --- a/frontend/src/components/dir-view-mode/dir-column-nav.js +++ b/frontend/src/components/dir-view-mode/dir-column-nav/index.js @@ -1,25 +1,26 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import TreeView from '../../components/tree-view/tree-view'; -import Loading from '../../components/loading'; -import ModalPortal from '../../components/modal-portal'; -import Rename from '../../components/dialog/rename-dialog'; -import Copy from '../../components/dialog/copy-dirent-dialog'; -import Move from '../../components/dialog/move-dirent-dialog'; -import CreateFolder from '../../components/dialog/create-folder-dialog'; -import CreateFile from '../../components/dialog/create-file-dialog'; -import ImageDialog from '../../components/dialog/image-dialog'; -import { fileServerRoot, gettext, siteRoot, thumbnailSizeForOriginal, thumbnailDefaultSize } from '../../utils/constants'; -import { Utils } from '../../utils/utils'; -import TextTranslation from '../../utils/text-translation'; -import TreeSection from '../../components/tree-section'; -import DirViews from './dir-views'; -import DirOthers from './dir-others'; -import imageAPI from '../../utils/image-api'; -import { seafileAPI } from '../../utils/seafile-api'; -import toaster from '../toast'; +import TreeView from '../../tree-view/tree-view'; +import Loading from '../../loading'; +import ModalPortal from '../../modal-portal'; +import Rename from '../../dialog/rename-dialog'; +import Copy from '../../dialog/copy-dirent-dialog'; +import Move from '../../dialog/move-dirent-dialog'; +import CreateFolder from '../../dialog/create-folder-dialog'; +import CreateFile from '../../dialog/create-file-dialog'; +import ImageDialog from '../../dialog/image-dialog'; +import { fileServerRoot, gettext, siteRoot, thumbnailSizeForOriginal, thumbnailDefaultSize } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import TextTranslation from '../../../utils/text-translation'; +import TreeSection from '../../tree-section'; +import DirViews from '../dir-views'; +import DirTags from '../dir-tags'; +import DirOthers from '../dir-others'; +import imageAPI from '../../../utils/image-api'; +import { seafileAPI } from '../../../utils/seafile-api'; +import toaster from '../../toast'; -import './dir-column-nav.css'; +import './index.css'; const propTypes = { currentPath: PropTypes.string.isRequired, @@ -406,6 +407,7 @@ class DirColumnNav extends React.Component { /> + + <>
{this.renderContent()}
@@ -496,7 +498,7 @@ class DirColumnNav extends React.Component { /> )} - + ); } } diff --git a/frontend/src/components/dir-view-mode/dir-column-view.js b/frontend/src/components/dir-view-mode/dir-column-view.js index 0b074b0072..c98dc1f6ba 100644 --- a/frontend/src/components/dir-view-mode/dir-column-view.js +++ b/frontend/src/components/dir-view-mode/dir-column-view.js @@ -7,8 +7,9 @@ import { SIDE_PANEL_FOLDED_WIDTH } from '../../constants'; import ResizeBar from '../resize-bar'; import { DRAG_HANDLER_HEIGHT, MAX_SIDE_PANEL_RATE, MIN_SIDE_PANEL_RATE } from '../resize-bar/constants'; import { SeafileMetadata } from '../../metadata'; +import { TagsView } from '../../tag'; import { mediaUrl } from '../../utils/constants'; -import { GRID_MODE, LIST_MODE, METADATA_MODE } from './constants'; +import { GRID_MODE, LIST_MODE, METADATA_MODE, TAGS_MODE } from './constants'; const propTypes = { isSidePanelFolded: PropTypes.bool, @@ -39,6 +40,7 @@ const propTypes = { filePermission: PropTypes.string, content: PropTypes.string, viewId: PropTypes.string, + tagId: PropTypes.string, lastModified: PropTypes.string, latestContributor: PropTypes.string, onLinkClick: PropTypes.func.isRequired, @@ -208,6 +210,18 @@ class DirColumnView extends React.Component { showDirentDetail={this.props.showDirentDetail} /> )} + {currentMode === TAGS_MODE && ( + + )} {currentMode === LIST_MODE && { const showSettings = currentRepoInfo.is_admin; // repo owner, department admin, shared with 'Admin' permission @@ -24,26 +24,27 @@ const DirOthers = ({ userPerm, repoID, currentRepoInfo }) => { const toggleTrashDialog = () => { setShowTrashDialog(!showTrashDialog); }; + return ( - {showSettings && + {showSettings && (
{gettext('Settings')}
- } - {trashUrl && + )} + {trashUrl && (
{gettext('Trash')}
- } - {Utils.isDesktop() && -
location.href = historyUrl}> - - {gettext('History')} -
- } + )} + {Utils.isDesktop() && ( +
location.href = historyUrl}> + + {gettext('History')} +
+ )} {showTrashDialog && ( { + + const enableMetadataManagement = useMemo(() => { + if (currentRepoInfo.encrypted) return false; + return window.app.pageOptions.enableMetadataManagement; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.app.pageOptions.enableMetadataManagement, currentRepoInfo]); + + const { enableMetadata, enableTags } = useMetadataStatus(); + const { isLoading } = useTags(); + + if (!enableMetadataManagement) return null; + if (!enableMetadata || !enableTags) return null; + + return ( + + {!isLoading && ()} + + ); +}; + +DirTags.propTypes = { + userPerm: PropTypes.string, + repoID: PropTypes.string, + currentPath: PropTypes.string, + currentRepoInfo: PropTypes.object, +}; + +export default DirTags; diff --git a/frontend/src/components/dir-view-mode/dir-views.js b/frontend/src/components/dir-view-mode/dir-views.js index f74c6e5e08..023b17ebab 100644 --- a/frontend/src/components/dir-view-mode/dir-views.js +++ b/frontend/src/components/dir-view-mode/dir-views.js @@ -5,6 +5,7 @@ import TreeSection from '../tree-section'; import { MetadataTreeView, useMetadata } from '../../metadata'; import ExtensionPrompts from './extension-prompts'; import LibSettingsDialog from '../dialog/lib-settings'; +import { useMetadataStatus } from '../../hooks'; const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { const enableMetadataManagement = useMemo(() => { @@ -13,12 +14,14 @@ const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [window.app.pageOptions.enableMetadataManagement, currentRepoInfo]); - const { enableMetadata, navigation } = useMetadata(); + const { navigation } = useMetadata(); + const { enableMetadata } = useMetadataStatus(); let [isSettingsDialogOpen, setSettingsDialogOpen] = useState(false); const toggleSettingsDialog = () => { setSettingsDialogOpen(!isSettingsDialogOpen); }; + const onExtendedProperties = useCallback(() => { setSettingsDialogOpen(true); }, []); @@ -27,9 +30,7 @@ const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { return ( <> - + {!enableMetadata ? ( ) : Array.isArray(navigation) && navigation.length > 0 ? ( diff --git a/frontend/src/components/dirent-detail/dirent-details/dir-details.js b/frontend/src/components/dirent-detail/dirent-details/dir-details.js index d549facd34..7b0ea7244a 100644 --- a/frontend/src/components/dirent-detail/dirent-details/dir-details.js +++ b/frontend/src/components/dirent-detail/dirent-details/dir-details.js @@ -5,11 +5,12 @@ import { getDirentPath } from './utils'; import DetailItem from '../detail-item'; import { CellType } from '../../../metadata/constants'; import { gettext } from '../../../utils/constants'; -import { MetadataDetails, useMetadata } from '../../../metadata'; +import { MetadataDetails } from '../../../metadata'; +import { useMetadataStatus } from '../../../hooks'; const DirDetails = ({ repoID, repoInfo, dirent, path, direntDetail }) => { const direntPath = useMemo(() => getDirentPath(dirent, path), [dirent, path]); - const { enableMetadata } = useMetadata(); + const { enableMetadata } = useMetadataStatus(); const lastModifiedTimeField = useMemo(() => { return { type: CellType.MTIME, name: gettext('Last modified time') }; }, []); diff --git a/frontend/src/components/dirent-detail/dirent-details/file-details/index.js b/frontend/src/components/dirent-detail/dirent-details/file-details/index.js index aaccd4bed0..e11454bf3a 100644 --- a/frontend/src/components/dirent-detail/dirent-details/file-details/index.js +++ b/frontend/src/components/dirent-detail/dirent-details/file-details/index.js @@ -10,10 +10,11 @@ import { gettext } from '../../../../utils/constants'; import EditFileTagPopover from '../../../popover/edit-filetag-popover'; import FileTagList from '../../../file-tag-list'; import { Utils } from '../../../../utils/utils'; -import { MetadataDetails, useMetadata } from '../../../../metadata'; +import { MetadataDetails } from '../../../../metadata'; import ObjectUtils from '../../../../metadata/utils/object-utils'; import { getCellValueByColumn, getDateDisplayString, decimalToExposureTime } from '../../../../metadata/utils/cell'; import Collapse from './collapse'; +import { useMetadataStatus } from '../../../../hooks'; import './index.css'; @@ -58,7 +59,7 @@ const getImageInfoValue = (key, value) => { const FileDetails = React.memo(({ repoID, repoInfo, dirent, path, direntDetail, onFileTagChanged, repoTags, fileTagList }) => { const [isEditFileTagShow, setEditFileTagShow] = useState(false); - const { enableMetadata } = useMetadata(); + const { enableMetadata } = useMetadataStatus(); const [record, setRecord] = useState(null); const direntPath = useMemo(() => getDirentPath(dirent, path), [dirent, path]); diff --git a/frontend/src/components/dirent-detail/embedded-file-details/file-details.js b/frontend/src/components/dirent-detail/embedded-file-details/file-details.js index 09c2b9c285..35f494a1b4 100644 --- a/frontend/src/components/dirent-detail/embedded-file-details/file-details.js +++ b/frontend/src/components/dirent-detail/embedded-file-details/file-details.js @@ -5,10 +5,11 @@ import DetailItem from '../detail-item'; import { CellType } from '../../../metadata/constants'; import { gettext } from '../../../utils/constants'; import { Utils } from '../../../utils/utils'; -import { MetadataDetails, useEnableMetadata } from '../../../metadata'; +import { MetadataDetails } from '../../../metadata'; +import { useMetadataStatus } from '../../../hooks'; const FileDetails = ({ repoID, repoInfo, path, direntDetail }) => { - const { enableMetadata } = useEnableMetadata(); + const { enableMetadata } = useMetadataStatus(); const sizeField = useMemo(() => ({ type: 'size', name: gettext('Size') }), []); const lastModifierField = useMemo(() => ({ type: CellType.LAST_MODIFIER, name: gettext('Last modifier') }), []); diff --git a/frontend/src/components/file-view/file-view.js b/frontend/src/components/file-view/file-view.js index 568e4b1947..8fdc4e4d9e 100644 --- a/frontend/src/components/file-view/file-view.js +++ b/frontend/src/components/file-view/file-view.js @@ -12,7 +12,8 @@ import FileInfo from './file-info'; import FileToolbar from './file-toolbar'; import OnlyofficeFileToolbar from './onlyoffice-file-toolbar'; import EmbeddedFileDetails from '../dirent-detail/embedded-file-details'; -import { CollaboratorsProvider, EnableMetadataProvider } from '../../metadata'; +import { MetadataStatusProvider } from '../../hooks'; +import { CollaboratorsProvider } from '../../metadata'; import Loading from '../loading'; import '../../css/file-view.css'; @@ -150,7 +151,7 @@ class FileView extends React.Component { } {this.props.content} {isDetailsPanelOpen && ( - + - + )}
diff --git a/frontend/src/components/tree-section/index.js b/frontend/src/components/tree-section/index.js index f01db885aa..911601fac9 100644 --- a/frontend/src/components/tree-section/index.js +++ b/frontend/src/components/tree-section/index.js @@ -16,7 +16,9 @@ const TreeSection = ({ title, children, moreKey, moreOperations, moreOperationCl return moreOperations.filter(operation => operation.key && operation.value); }, [moreOperations]); - const toggleShowChildren = useCallback(() => { + const toggleShowChildren = useCallback((event) => { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); setShowChildren(!showChildren); }, [showChildren]); diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index 3625e3bec0..f369c58f78 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -5,11 +5,17 @@ export const DIALOG_MAX_HEIGHT = window.innerHeight - 56; // Dialog margin is 3. export const PRIVATE_FILE_TYPE = { FILE_EXTENDED_PROPERTIES: '__file_extended_properties', - FACE_RECOGNITION: '__face_recognition', + TAGS_PROPERTIES: '__tags_properties', }; -const TAG_COLORS = ['#FBD44A', '#EAA775', '#F4667C', '#DC82D2', '#9860E5', '#9F8CF1', '#59CB74', '#ADDF84', - '#89D2EA', '#4ECCCB', '#46A1FD', '#C2C2C2']; +const TAG_COLORS = [ + '#FBD44A', '#EAA775', + '#F4667C', '#DC82D2', + '#9860E5', '#9F8CF1', + '#59CB74', '#ADDF84', + '#89D2EA', '#4ECCCB', + '#46A1FD', '#C2C2C2', +]; export const SIDE_PANEL_FOLDED_WIDTH = 71; export const SUB_NAV_ITEM_HEIGHT = 28; diff --git a/frontend/src/hooks/index.js b/frontend/src/hooks/index.js new file mode 100644 index 0000000000..68b307d548 --- /dev/null +++ b/frontend/src/hooks/index.js @@ -0,0 +1 @@ +export { MetadataStatusProvider, useMetadataStatus } from './metadata-status'; diff --git a/frontend/src/hooks/metadata-status.js b/frontend/src/hooks/metadata-status.js new file mode 100644 index 0000000000..a2f5c056d7 --- /dev/null +++ b/frontend/src/hooks/metadata-status.js @@ -0,0 +1,93 @@ +import React, { useContext, useEffect, useCallback, useState, useMemo } from 'react'; +import metadataAPI from '../metadata/api'; +import { Utils } from '../utils/utils'; +import toaster from '../components/toast'; + +// This hook provides content related to seahub interaction, such as whether to enable extended attributes +const EnableMetadataContext = React.createContext(null); + +export const MetadataStatusProvider = ({ repoID, currentRepoInfo, hideMetadataView, children }) => { + const enableMetadataManagement = useMemo(() => { + if (currentRepoInfo?.encrypted) return false; + return window.app.pageOptions.enableMetadataManagement; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.app.pageOptions.enableMetadataManagement, currentRepoInfo]); + + const [isLoading, setLoading] = useState(true); + const [enableMetadata, setEnableMetadata] = useState(false); + const [enableTags, setEnableTags] = useState(false); + + const cancelMetadataURL = useCallback(() => { + // If attribute extension is turned off, unmark the URL + const { origin, pathname, search } = window.location; + const urlParams = new URLSearchParams(search); + const param = urlParams.get('view') || urlParams.get('tag'); + if (param) { + const url = `${origin}${pathname}`; + window.history.pushState({ url: url, path: '' }, '', url); + } + }, []); + + useEffect(() => { + if (!enableMetadataManagement) { + cancelMetadataURL(); + setLoading(false); + return; + } + metadataAPI.getMetadataStatus(repoID).then(res => { + const { enabled: enableMetadata, tags_enabled: enableTags } = res.data; + if (!enableMetadata) { + cancelMetadataURL(); + } + setEnableTags(enableTags); + setEnableMetadata(enableMetadata); + setLoading(false); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error, true); + toaster.danger(errorMsg); + setEnableMetadata(false); + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repoID, enableMetadataManagement]); + + const updateEnableMetadata = useCallback((newValue) => { + if (newValue === enableMetadata) return; + if (!newValue) { + cancelMetadataURL(); + setEnableTags(false); + } + setEnableMetadata(newValue); + }, [enableMetadata, cancelMetadataURL]); + + const updateEnableTags = useCallback((newValue) => { + if (newValue === enableTags) return; + if (!newValue) { + cancelMetadataURL(); + hideMetadataView && hideMetadataView(); + } + setEnableTags(newValue); + }, [enableTags, cancelMetadataURL, hideMetadataView]); + + return ( + + {!isLoading && children} + + ); +}; + +export const useMetadataStatus = () => { + const context = useContext(EnableMetadataContext); + if (!context) { + throw new Error('\'EnableMetadataContext\' is null'); + } + return context; +}; diff --git a/frontend/src/index.js b/frontend/src/index.js index d3f2e67d3d..8dc1862bbd 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -5,7 +5,8 @@ import { I18nextProvider } from 'react-i18next'; import i18n from './_i18n/i18n-seafile-editor'; import MarkdownEditor from './pages/markdown-editor'; import Loading from './components/loading'; -import { CollaboratorsProvider, EnableMetadataProvider } from './metadata'; +import { MetadataStatusProvider } from './hooks'; +import { CollaboratorsProvider } from './metadata'; import './index.css'; @@ -14,11 +15,11 @@ const { repoID } = window.app.pageOptions; ReactDom.render( }> - + - + , document.getElementById('root') diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js index 59369bf84f..41dfe919f5 100644 --- a/frontend/src/metadata/api.js +++ b/frontend/src/metadata/api.js @@ -298,3 +298,4 @@ const xcsrfHeaders = cookie.load('sfcsrftoken'); metadataAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); export default metadataAPI; +export { MetadataManagerAPI }; diff --git a/frontend/src/metadata/components/cell-editors/editor-container/index.js b/frontend/src/metadata/components/cell-editors/editor-container/index.js index 72c51606cc..6f06f6307b 100644 --- a/frontend/src/metadata/components/cell-editors/editor-container/index.js +++ b/frontend/src/metadata/components/cell-editors/editor-container/index.js @@ -11,6 +11,8 @@ const POPUP_EDITOR_COLUMN_TYPES = [ CellType.SINGLE_SELECT, CellType.MULTIPLE_SELECT, CellType.LONG_TEXT, + CellType.LINK, + CellType.TAGS, ]; const PREVIEW_EDITOR_COLUMN_TYPES = [ diff --git a/frontend/src/metadata/components/cell-editors/editor-container/normal-editor-container.js b/frontend/src/metadata/components/cell-editors/editor-container/normal-editor-container.js index 6970bde267..8aae698dcb 100644 --- a/frontend/src/metadata/components/cell-editors/editor-container/normal-editor-container.js +++ b/frontend/src/metadata/components/cell-editors/editor-container/normal-editor-container.js @@ -8,7 +8,7 @@ import { getEventClassName } from '../../../utils/common'; import { isCellValueChanged, getCellValueByColumn } from '../../../utils/cell'; import { canEditCell } from '../../../utils/column'; import { isCtrlKeyHeldDown, isKeyPrintable } from '../../../utils/keyboard-utils'; -import { EVENT_BUS_TYPE, PRIVATE_COLUMN_KEYS, metadataZIndexes } from '../../../constants'; +import { EVENT_BUS_TYPE, PRIVATE_COLUMN_KEYS, metadataZIndexes, CellType } from '../../../constants'; class NormalEditorContainer extends React.Component { @@ -219,6 +219,7 @@ class NormalEditorContainer extends React.Component { commit = (args) => { const { record, column } = this.props; const { key: columnKey, type: columnType } = column; + if (columnType === CellType.TAGS) return; const originalOldCellValue = getCellValueByColumn(record, column); const updated = this.getEditor().getValue(); if (!isCellValueChanged(originalOldCellValue, updated[columnKey], columnType)) { diff --git a/frontend/src/metadata/components/cell-editors/editor-container/popup-editor-container.js b/frontend/src/metadata/components/cell-editors/editor-container/popup-editor-container.js index 6da9f6d175..ab31c79f00 100644 --- a/frontend/src/metadata/components/cell-editors/editor-container/popup-editor-container.js +++ b/frontend/src/metadata/components/cell-editors/editor-container/popup-editor-container.js @@ -58,7 +58,7 @@ class PopupEditorContainer extends React.Component { }; createEditor = () => { - const { column, record, height, onPressTab, editorPosition, columns, modifyColumnData } = this.props; + const { column, record, height, onPressTab, editorPosition, columns, modifyColumnData, addFileTags, updateFileTags } = this.props; const readOnly = !canEditCell(column, record, true) || NOT_SUPPORT_EDITOR_COLUMN_TYPES.includes(column.type); const value = this.getInitialValue(readOnly); @@ -81,6 +81,8 @@ class PopupEditorContainer extends React.Component { column, readOnly, onPressTab, + addFileTags, + updateFileTags, }; if (column.type === CellType.DATE) { @@ -141,6 +143,7 @@ class PopupEditorContainer extends React.Component { const { column, record } = this.props; if (!record._id) return; const { key: columnKey, type: columnType } = column; + if (columnType === CellType.TAGS) return; const newValue = this.getEditor().getValue(); let updated = columnType === CellType.DATE ? { [columnKey]: newValue } : newValue; if (columnType === CellType.SINGLE_SELECT) { diff --git a/frontend/src/metadata/components/cell-editors/editor.js b/frontend/src/metadata/components/cell-editors/editor.js index d1f8dcb6ed..3ca1e4f376 100644 --- a/frontend/src/metadata/components/cell-editors/editor.js +++ b/frontend/src/metadata/components/cell-editors/editor.js @@ -8,6 +8,7 @@ import SingleSelectEditor from './single-select-editor'; import MultipleSelectEditor from './multiple-select-editor'; import CollaboratorEditor from './collaborator-editor'; import LongTextEditor from './long-text-editor'; +import TagsEditor from './tags-editor'; import { lang } from '../../../utils/constants'; import { CellType } from '../../constants'; @@ -39,6 +40,12 @@ const Editor = React.forwardRef((props, ref) => { case CellType.LONG_TEXT: { return (); } + case CellType.TAGS: { + return (); + } + case CellType.LINK: { + return null; + } default: { return null; } diff --git a/frontend/src/metadata/components/cell-editors/multiple-select-editor/index.js b/frontend/src/metadata/components/cell-editors/multiple-select-editor/index.js index 0030654423..55bf3febba 100644 --- a/frontend/src/metadata/components/cell-editors/multiple-select-editor/index.js +++ b/frontend/src/metadata/components/cell-editors/multiple-select-editor/index.js @@ -120,8 +120,7 @@ const MultipleSelectEditor = forwardRef(({ option = displayOptions[highlightIndex]; } if (option) { - let newOptionId = option.id; - if (value === option.id) newOptionId = null; + const newOptionId = option.id; onSelectOption(newOptionId); return; } @@ -129,9 +128,9 @@ const MultipleSelectEditor = forwardRef(({ if (searchValue) { isShowCreateBtn = canEditData && displayOptions.findIndex(option => option.name === searchValue) === -1 ? true : false; } - if (!isShowCreateBtn || displayOptions.length === 0) return; + if (!isShowCreateBtn || displayOptions.length > 0) return; createOption(); - }, [canEditData, displayOptions, highlightIndex, value, searchValue, onSelectOption, createOption]); + }, [canEditData, displayOptions, highlightIndex, searchValue, onSelectOption, createOption]); const onUpArrow = useCallback((event) => { event.preventDefault(); diff --git a/frontend/src/metadata/components/cell-editors/tags-editor/delete-tags/index.css b/frontend/src/metadata/components/cell-editors/tags-editor/delete-tags/index.css new file mode 100644 index 0000000000..0470c74c4f --- /dev/null +++ b/frontend/src/metadata/components/cell-editors/tags-editor/delete-tags/index.css @@ -0,0 +1,54 @@ +.sf-metadata-delete-select-tags { + background-color: #f6f6f6; + border-bottom: 1px solid #dde2ea; + border-radius: 3px 3px 0 0; + min-height: 35px; + padding: 2px 10px; + line-height: 1; +} + +.sf-metadata-delete-select-tags .sf-metadata-delete-select-tag { + display: inline-flex; + align-items: center; + height: 20px; + margin-right: 10px; + padding: 0 8px 0 2px; + font-size: 13px; + border-radius: 10px; + background: #eaeaea; + margin-top: 2px; + margin-bottom: 2px; +} + +.sf-metadata-delete-select-tags .sf-metadata-delete-select-tag .sf-metadata-delete-select-tag-color { + width: 14px; + height: 14px; + border-radius: 50%; + margin-left: 1px; +} + +.sf-metadata-delete-select-tags .sf-metadata-delete-select-tag .sf-metadata-delete-select-tag-name { + display: inline-block; + height: 20px; + line-height: 20px; + color: #212529; + margin-left: 5px; + max-width: 200px; + flex: 1 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sf-metadata-delete-select-tags .sf-metadata-delete-select-tag .sf-metadata-delete-select-remove { + height: 14px; + width: 14px; + position: relative; + left: 2px; +} + +.sf-metadata-delete-select-tags .sf-metadata-delete-select-remove .sf-metadata-icon-x-01 { + fill: #666; + font-size: 12px; +} + diff --git a/frontend/src/metadata/components/cell-editors/tags-editor/delete-tags/index.js b/frontend/src/metadata/components/cell-editors/tags-editor/delete-tags/index.js new file mode 100644 index 0000000000..050eecae4f --- /dev/null +++ b/frontend/src/metadata/components/cell-editors/tags-editor/delete-tags/index.js @@ -0,0 +1,35 @@ +import React from './index'; +import PropTypes from 'prop-types'; +import { IconBtn } from '@seafile/sf-metadata-ui-component'; +import { getRowById } from '../../../../utils/table'; +import { getTagColor, getTagName } from '../../../../../tag/utils/cell/core'; + +import './index.css'; + +const DeleteTag = ({ value, tags, onDelete }) => { + return ( +
+ {Array.isArray(value) && value.map(tagId => { + const tag = getRowById(tags, tagId); + if (!tag) return null; + const tagName = getTagName(tag); + const tagColor = getTagColor(tag); + return ( +
+
+
{tagName}
+ onDelete(tagId, event)} iconName="x-01" /> +
+ ); + })} +
+ ); +}; + +DeleteTag.propTypes = { + value: PropTypes.array, + tags: PropTypes.object, + onDelete: PropTypes.func, +}; + +export default DeleteTag; diff --git a/frontend/src/metadata/components/cell-editors/tags-editor/index.css b/frontend/src/metadata/components/cell-editors/tags-editor/index.css new file mode 100644 index 0000000000..ac0b7bbe70 --- /dev/null +++ b/frontend/src/metadata/components/cell-editors/tags-editor/index.css @@ -0,0 +1,70 @@ +.sf-metadata-tags-editor { + background-color: #fff; + border: 1px solid #dedede; + border-radius: 4px; + box-shadow: 0 2px 10px 0 #dedede; + left: 0; + min-height: 160px; + min-width: 200px; + opacity: 1; + overflow: hidden; + padding: 0; + position: absolute; +} + +.sf-metadata-tags-editor .sf-metadata-search-tags-container { + padding: 10px 10px 0; +} + +.sf-metadata-tags-editor .sf-metadata-search-tags-container .sf-metadata-search-tags { + font-size: 14px; + max-height: 30px; +} + +.sf-metadata-tags-editor .sf-metadata-tags-editor-container { + max-height: 200px; + min-height: 100px; + overflow: auto; + padding: 10px; +} + +.sf-metadata-tags-editor .sf-metadata-tags-editor-tag-container { + align-items: center; + border-radius: 2px; + color: #212529; + display: flex; + font-size: 13px; + height: 30px; + width: 100%; +} + +.sf-metadata-tags-editor .sf-metadata-tags-editor-tag-container-highlight { + background: #f5f5f5; + cursor: pointer; +} + +.sf-metadata-tags-editor .sf-metadata-tags-editor-tag-check-icon { + text-align: center; + width: 20px; +} + +.sf-metadata-tag-color-and-name { + display: flex; + align-items: center; + flex: 1; +} + +.sf-metadata-tag-color-and-name .sf-metadata-tag-color { + height: 14px; + width: 14px; + border-radius: 50%; + flex-shrink: 0; +} + +.sf-metadata-tag-color-and-name .sf-metadata-tag-name { + flex: 1; + margin-left: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/frontend/src/metadata/components/cell-editors/tags-editor/index.js b/frontend/src/metadata/components/cell-editors/tags-editor/index.js new file mode 100644 index 0000000000..85b9c32f31 --- /dev/null +++ b/frontend/src/metadata/components/cell-editors/tags-editor/index.js @@ -0,0 +1,282 @@ +import React, { forwardRef, useMemo, useCallback, useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { SearchInput, CustomizeAddTool, Icon } from '@seafile/sf-metadata-ui-component'; +import { Utils } from '../../../../utils/utils'; +import { KeyCodes } from '../../../../constants'; +import { gettext } from '../../../../utils/constants'; +import { useTags } from '../../../../tag/hooks'; +import { getTagColor, getTagId, getTagName, getTagsByNameOrColor, getTagByNameOrColor } from '../../../../tag/utils/cell/core'; +import { getRecordIdFromRecord } from '../../../utils/cell'; +import { getRowById } from '../../../utils/table'; +import { SELECT_OPTION_COLORS } from '../../../constants'; +import { PRIVATE_COLUMN_KEY as TAG_PRIVATE_COLUMN_KEY } from '../../../../tag/constants'; +import DeleteTags from './delete-tags'; + +import './index.css'; + +const TagsEditor = forwardRef(({ + height, + column, + record, + value: oldValue, + editorPosition = { left: 0, top: 0 }, + onPressTab, + addFileTags, + updateFileTags, +}, ref) => { + const { tagsData, addTag } = useTags(); + + const [value, setValue] = useState((oldValue || []).map(item => item.row_id).filter(item => getRowById(tagsData, item))); + const [searchValue, setSearchValue] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(-1); + const [maxItemNum, setMaxItemNum] = useState(0); + const itemHeight = 30; + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + const selectItemRef = useRef(null); + const canEditData = window.sfMetadataContext.canModifyColumnData(column); + + const tags = useMemo(() => { + if (!tagsData) return []; + return tagsData?.rows || []; + }, [tagsData]); + + const displayTags = useMemo(() => getTagsByNameOrColor(tags, searchValue), [searchValue, tags]); + + const isShowCreateBtn = useMemo(() => { + if (!canEditData || !searchValue) return false; + return !getTagByNameOrColor(displayTags, searchValue); + }, [canEditData, displayTags, searchValue]); + + const style = useMemo(() => { + return { width: column.width }; + }, [column]); + + const onChangeSearch = useCallback((newSearchValue) => { + if (searchValue === newSearchValue) return; + setSearchValue(newSearchValue); + }, [searchValue]); + + const onSelectTag = useCallback((tagId) => { + const newValue = value.slice(0); + let optionIdx = value.indexOf(tagId); + if (optionIdx > -1) { + newValue.splice(optionIdx, 1); + } else { + newValue.push(tagId); + } + setValue(newValue); + const recordId = getRecordIdFromRecord(record); + if (value.length === 0) { + addFileTags(recordId, newValue, value); + } else { + updateFileTags(recordId, newValue, value); + } + }, [value, record, addFileTags, updateFileTags]); + + const onDeleteTag = useCallback((tagId) => { + const newValue = value.slice(0); + let optionIdx = value.indexOf(tagId); + if (optionIdx > -1) { + newValue.splice(optionIdx, 1); + } + setValue(newValue); + const recordId = getRecordIdFromRecord(record); + updateFileTags(recordId, newValue, value); + }, [value, record, updateFileTags]); + + const onMenuMouseEnter = useCallback((highlightIndex) => { + setHighlightIndex(highlightIndex); + }, []); + + const onMenuMouseLeave = useCallback((index) => { + setHighlightIndex(-1); + }, []); + + const createTag = useCallback((event) => { + event && event.stopPropagation(); + event && event.nativeEvent.stopImmediatePropagation(); + const defaultOptions = SELECT_OPTION_COLORS.slice(0, 24); + const defaultOption = defaultOptions[Math.floor(Math.random() * defaultOptions.length)]; + addTag({ [TAG_PRIVATE_COLUMN_KEY.TAG_NAME]: searchValue, [TAG_PRIVATE_COLUMN_KEY.TAG_COLOR]: defaultOption.COLOR }, { + success_callback: (operation) => { + const tags = operation.tags?.map(tag => getTagId(tag)); + const recordId = getRecordIdFromRecord(record); + let newValue = []; + if (value.length === 0) { + newValue = tags; + addFileTags(recordId, newValue, value); + } else { + newValue = [...value, ...tags]; + updateFileTags(recordId, newValue, value); + } + setValue(newValue); + }, + fail_callback: () => { + + }, + }); + }, [value, searchValue, record, addTag, addFileTags, updateFileTags]); + + const getMaxItemNum = useCallback(() => { + let selectContainerStyle = getComputedStyle(editorContainerRef.current, null); + let selectItemStyle = getComputedStyle(selectItemRef.current, null); + let maxSelectItemNum = Math.floor(parseInt(selectContainerStyle.maxHeight) / parseInt(selectItemStyle.height)); + return maxSelectItemNum - 1; + }, [editorContainerRef, selectItemRef]); + + const onEnter = useCallback((event) => { + event.preventDefault(); + let tag; + if (displayTags.length === 1) { + tag = displayTags[0]; + } else if (highlightIndex > -1) { + tag = displayTags[highlightIndex]; + } + if (tag) { + const newTagId = getTagId(tag); + onSelectTag(newTagId); + return; + } + if (isShowCreateBtn) { + createTag(); + } + }, [displayTags, highlightIndex, isShowCreateBtn, onSelectTag, createTag]); + + const onUpArrow = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + if (highlightIndex === 0) return; + setHighlightIndex(highlightIndex - 1); + if (highlightIndex > displayTags.length - maxItemNum) { + editorContainerRef.current.scrollTop -= itemHeight; + } + }, [editorContainerRef, highlightIndex, maxItemNum, displayTags, itemHeight]); + + const onDownArrow = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + if (highlightIndex === displayTags.length - 1) return; + setHighlightIndex(highlightIndex + 1); + if (highlightIndex >= maxItemNum) { + editorContainerRef.current.scrollTop += itemHeight; + } + }, [editorContainerRef, highlightIndex, maxItemNum, displayTags, itemHeight]); + + const onHotKey = useCallback((event) => { + if (event.keyCode === KeyCodes.Enter) { + onEnter(event); + } else if (event.keyCode === KeyCodes.UpArrow) { + onUpArrow(event); + } else if (event.keyCode === KeyCodes.DownArrow) { + onDownArrow(event); + } else if (event.keyCode === KeyCodes.Tab) { + if (Utils.isFunction(onPressTab)) { + onPressTab(event); + } + } + }, [onEnter, onUpArrow, onDownArrow, onPressTab]); + + const onKeyDown = useCallback((event) => { + if ( + event.keyCode === KeyCodes.ChineseInputMethod || + event.keyCode === KeyCodes.Enter || + event.keyCode === KeyCodes.LeftArrow || + event.keyCode === KeyCodes.RightArrow + ) { + event.stopPropagation(); + } + }, []); + + useEffect(() => { + if (editorRef.current) { + const { bottom } = editorRef.current.getBoundingClientRect(); + if (bottom > window.innerHeight) { + editorRef.current.style.top = 'unset'; + editorRef.current.style.bottom = editorPosition.top + height - window.innerHeight + 'px'; + } + } + if (editorContainerRef.current && selectItemRef.current) { + setMaxItemNum(getMaxItemNum()); + } + document.addEventListener('keydown', onHotKey, true); + return () => { + document.removeEventListener('keydown', onHotKey, true); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onHotKey]); + + useEffect(() => { + const highlightIndex = displayTags.length === 0 ? -1 : 0; + setHighlightIndex(highlightIndex); + }, [displayTags]); + + const renderOptions = useCallback(() => { + if (displayTags.length === 0) { + const noOptionsTip = searchValue ? gettext('No tags available') : gettext('No tag'); + return ({noOptionsTip}); + } + + return displayTags.map((tag, i) => { + const tagId = getTagId(tag); + const tagName = getTagName(tag); + const tagColor = getTagColor(tag); + const isSelected = Array.isArray(value) ? value.includes(tagId) : false; + return ( +
+
onSelectTag(tagId)} + onMouseEnter={() => onMenuMouseEnter(i)} + onMouseLeave={() => onMenuMouseLeave(i)} + > +
+
+
{tagName}
+
+
+ {isSelected && ()} +
+
+
+ ); + }); + + }, [displayTags, searchValue, value, highlightIndex, onMenuMouseEnter, onMenuMouseLeave, onSelectTag]); + + return ( +
+ +
+ +
+
+ {renderOptions()} +
+ {isShowCreateBtn && ( + + )} +
+ ); +}); + +TagsEditor.propTypes = { + height: PropTypes.number, + column: PropTypes.object, + value: PropTypes.array, + editorPosition: PropTypes.object, + onPressTab: PropTypes.func, +}; + +export default TagsEditor; diff --git a/frontend/src/metadata/components/cell-formatter/file-tags-formatter/index.css b/frontend/src/metadata/components/cell-formatter/file-tags-formatter/index.css new file mode 100644 index 0000000000..1e54638657 --- /dev/null +++ b/frontend/src/metadata/components/cell-formatter/file-tags-formatter/index.css @@ -0,0 +1,45 @@ +.sf-metadata-tags-formatter .sf-metadata-tags-operation-container { + display: flex; + align-items: center; +} + +.sf-metadata-tags-formatter .sf-metadata-tags-operation-container .sf-metadata-tags-operation-add-btn { + display: flex; + align-items: center; + justify-content: center; + height: 20px; + width: 20px; + background: #eceff4; + border-radius: 3px; + cursor: pointer; +} + +.sf-metadata-tags-formatter .sf-metadata-tags-operation-container .sf-metadata-tags-operation-add-btn:hover { + background-color: #f2f2f2; +} + +.sf-metadata-tags-formatter .sf-metadata-tags-operation-add-btn .sf-metadata-icon-add-table { + font-size: 12px; + fill: #212519; +} + +.sf-metadata-tags-formatter .sf-metadata-tags-formatter-container { + display: flex; + align-items: center; + width: max-content; + min-height: 1rem; + position: relative; + justify-content: flex-end; +} + +.sf-metadata-tags-formatter .sf-metadata-tags-formatter-container .sf-metadata-tag-formatter { + margin-right: -0.5rem; + border: 0.125rem solid #fff; + cursor: pointer; + position: relative; + top: 1px; + display: inline-block; + width: 18px; + height: 18px; + border-radius: 50%; +} diff --git a/frontend/src/metadata/components/cell-formatter/file-tags-formatter/index.js b/frontend/src/metadata/components/cell-formatter/file-tags-formatter/index.js new file mode 100644 index 0000000000..c1876f8d1c --- /dev/null +++ b/frontend/src/metadata/components/cell-formatter/file-tags-formatter/index.js @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useTags } from '../../../../tag/hooks'; +import { getRowById } from '../../../utils/table'; +import { getTagColor, getTagName } from '../../../../tag/utils/cell/core'; + +import './index.css'; + +const FileTagsFormatter = ({ value: oldValue }) => { + const { tagsData } = useTags(); + const value = useMemo(() => { + if (!Array.isArray(oldValue)) return []; + return oldValue.filter(item => getRowById(tagsData, item.row_id)).map(item => item.row_id); + }, [oldValue, tagsData]); + + return ( +
+ {value.length > 0 && ( +
+ {value.map((item) => { + const tag = getRowById(tagsData, item); + const tagColor = getTagColor(tag); + const tagName = getTagName(tag); + + return ( + + ); + })} +
+ )} +
+ ); +}; + +FileTagsFormatter.propTypes = { + value: PropTypes.array, +}; + +export default FileTagsFormatter; diff --git a/frontend/src/metadata/components/dialog/metadata-tags-status-dialog/index.js b/frontend/src/metadata/components/dialog/metadata-tags-status-dialog/index.js new file mode 100644 index 0000000000..973b8a4778 --- /dev/null +++ b/frontend/src/metadata/components/dialog/metadata-tags-status-dialog/index.js @@ -0,0 +1,99 @@ +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { ModalBody, ModalFooter, Button } from 'reactstrap'; +import classnames from 'classnames'; +import Switch from '../../../../components/common/switch'; +import { gettext } from '../../../../utils/constants'; +import tagsAPI from '../../../../tag/api'; +import toaster from '../../../../components/toast'; +import { Utils } from '../../../../utils/utils'; +import TurnOffConfirmDialog from './turn-off-confirm'; + +// import './index.css'; + +const MetadataTagsStatusDialog = ({ value: oldValue, repoID, toggleDialog: toggle, submit }) => { + const [value, setValue] = useState(oldValue); + const [submitting, setSubmitting] = useState(false); + const [showTurnOffConfirmDialog, setShowTurnOffConfirmDialog] = useState(false); + + const onToggle = useCallback(() => { + toggle(); + }, [toggle]); + + const onSubmit = useCallback(() => { + if (!value) { + setShowTurnOffConfirmDialog(true); + return; + } + setSubmitting(true); + tagsAPI.openTags(repoID).then(res => { + submit(true); + toggle(); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + setSubmitting(false); + }); + }, [repoID, submit, toggle, value]); + + const turnOffConfirmToggle = useCallback(() => { + setShowTurnOffConfirmDialog(!showTurnOffConfirmDialog); + }, [showTurnOffConfirmDialog]); + + const turnOffConfirmSubmit = useCallback(() => { + setShowTurnOffConfirmDialog(false); + setSubmitting(true); + tagsAPI.closeTags(repoID).then(res => { + submit(false); + toggle(); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + setSubmitting(false); + }); + }, [repoID, submit, toggle]); + + const onValueChange = useCallback(() => { + const nextValue = !value; + setValue(nextValue); + }, [value]); + + return ( + <> + {!showTurnOffConfirmDialog && ( + <> + + +

+ {gettext('Enable tags to describe, categorize and mark files.')} +

+
+ + + + + + )} + {showTurnOffConfirmDialog && ( + + )} + + ); +}; + +MetadataTagsStatusDialog.propTypes = { + value: PropTypes.bool.isRequired, + repoID: PropTypes.string.isRequired, + toggleDialog: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired, +}; + +export default MetadataTagsStatusDialog; diff --git a/frontend/src/metadata/components/dialog/metadata-tags-status-dialog/turn-off-confirm.js b/frontend/src/metadata/components/dialog/metadata-tags-status-dialog/turn-off-confirm.js new file mode 100644 index 0000000000..88dabc5045 --- /dev/null +++ b/frontend/src/metadata/components/dialog/metadata-tags-status-dialog/turn-off-confirm.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { gettext } from '../../../../utils/constants'; + +const TurnOffConfirmDialog = ({ toggle, submit }) => { + return ( + + {gettext('Turn off tags')} + +

{gettext('Do you really want to turn off tags? Existing tags will all be deleted.')}

+
+ + + + +
+ ); +}; + +TurnOffConfirmDialog.propTypes = { + toggle: PropTypes.func.isRequired, + submit: PropTypes.func.isRequired +}; + +export default TurnOffConfirmDialog; diff --git a/frontend/src/metadata/components/metadata-details/constants.js b/frontend/src/metadata/components/metadata-details/constants.js index 12d7876bf6..0ca1dca88b 100644 --- a/frontend/src/metadata/components/metadata-details/constants.js +++ b/frontend/src/metadata/components/metadata-details/constants.js @@ -21,6 +21,7 @@ export const NOT_DISPLAY_COLUMN_KEYS = [ PRIVATE_COLUMN_KEY.LOCATION, PRIVATE_COLUMN_KEY.FACE_LINKS, PRIVATE_COLUMN_KEY.FACE_VECTORS, + PRIVATE_COLUMN_KEY.TAGS, ]; export const SYSTEM_FOLDERS = [ diff --git a/frontend/src/metadata/components/metadata-details/index.js b/frontend/src/metadata/components/metadata-details/index.js index 7a06fa29c5..5f626c0a85 100644 --- a/frontend/src/metadata/components/metadata-details/index.js +++ b/frontend/src/metadata/components/metadata-details/index.js @@ -6,8 +6,8 @@ import DetailEditor from '../detail-editor'; import DetailItem from '../../../components/dirent-detail/detail-item'; import { Utils } from '../../../utils/utils'; import metadataAPI from '../../api'; -import Column from '../../model/metadata/column'; -import { getCellValueByColumn, getOptionName, getColumnOptionNamesByIds, getColumnOptionNameById, getFileNameFromRecord, geRecordIdFromRecord, getFileObjIdFromRecord } from '../../utils/cell'; +import Column from '../../model/column'; +import { getCellValueByColumn, getOptionName, getColumnOptionNamesByIds, getColumnOptionNameById, getFileNameFromRecord, getRecordIdFromRecord, getFileObjIdFromRecord } from '../../utils/cell'; import { normalizeFields } from './utils'; import { gettext } from '../../../utils/constants'; import { CellType, EVENT_BUS_TYPE, PREDEFINED_COLUMN_KEYS, PRIVATE_COLUMN_KEY } from '../../constants'; @@ -26,7 +26,7 @@ const MetadataDetails = ({ repoID, filePath, repoInfo, direntType, updateRecord const { record, fields } = metadata; const field = fields.find(f => f.key === fieldKey); const fileName = getColumnOriginName(field); - const recordId = geRecordIdFromRecord(record); + const recordId = getRecordIdFromRecord(record); const fileObjId = getFileObjIdFromRecord(record); let update = { [fileName]: newValue }; if (field.type === CellType.SINGLE_SELECT) { @@ -77,7 +77,7 @@ const MetadataDetails = ({ repoID, filePath, repoInfo, direntType, updateRecord }, [repoID, metadata]); const localRecordChanged = useCallback((recordId, updates) => { - if (geRecordIdFromRecord(metadata?.record) !== recordId) return; + if (getRecordIdFromRecord(metadata?.record) !== recordId) return; const newMetadata = { ...metadata, record: { ...metadata.record, ...updates } }; setMetadata(newMetadata); }, [metadata]); diff --git a/frontend/src/metadata/components/popover/view-popover/add-view/index.js b/frontend/src/metadata/components/popover/view-popover/add-view/index.js index beb594d8f2..244a88f0c2 100644 --- a/frontend/src/metadata/components/popover/view-popover/add-view/index.js +++ b/frontend/src/metadata/components/popover/view-popover/add-view/index.js @@ -11,12 +11,10 @@ const VIEW_OPTIONS = [ { key: 'table', type: VIEW_TYPE.TABLE, - }, - { + }, { key: 'gallery', type: VIEW_TYPE.GALLERY, - }, - { + }, { key: 'kanban', type: VIEW_TYPE.KANBAN, }, diff --git a/frontend/src/metadata/constants/column/icon.js b/frontend/src/metadata/constants/column/icon.js index 7d584ed5ec..8465254366 100644 --- a/frontend/src/metadata/constants/column/icon.js +++ b/frontend/src/metadata/constants/column/icon.js @@ -17,6 +17,8 @@ const COLUMNS_ICON_CONFIG = { [CellType.NUMBER]: 'number', [CellType.GEOLOCATION]: 'location', [CellType.RATE]: 'rate', + [CellType.LINK]: 'link', + [CellType.TAGS]: 'tag', }; const COLUMNS_ICON_NAME = { @@ -36,6 +38,7 @@ const COLUMNS_ICON_NAME = { [CellType.NUMBER]: 'Number', [CellType.GEOLOCATION]: 'Geolocation', [CellType.RATE]: 'Rate', + [CellType.LINK]: 'Link', }; export { diff --git a/frontend/src/metadata/constants/column/private.js b/frontend/src/metadata/constants/column/private.js index 45da729e31..333c45d7b9 100644 --- a/frontend/src/metadata/constants/column/private.js +++ b/frontend/src/metadata/constants/column/private.js @@ -29,9 +29,14 @@ export const PRIVATE_COLUMN_KEY = { CAPTURE_TIME: '_capture_time', FILE_REVIEWER: '_reviewer', OWNER: '_owner', + FILE_RATE: '_rate', + + // face FACE_LINKS: '_face_links', FACE_VECTORS: '_face_vectors', - FILE_RATE: '_rate', + + // tag + TAGS: '_tags', }; export const PRIVATE_COLUMN_KEYS = [ @@ -65,6 +70,7 @@ export const PRIVATE_COLUMN_KEYS = [ PRIVATE_COLUMN_KEY.FACE_LINKS, PRIVATE_COLUMN_KEY.FACE_VECTORS, PRIVATE_COLUMN_KEY.FILE_RATE, + PRIVATE_COLUMN_KEY.TAGS, ]; export const EDITABLE_PRIVATE_COLUMN_KEYS = [ @@ -78,12 +84,14 @@ export const EDITABLE_PRIVATE_COLUMN_KEYS = [ PRIVATE_COLUMN_KEY.CAPTURE_TIME, PRIVATE_COLUMN_KEY.OWNER, PRIVATE_COLUMN_KEY.FILE_RATE, + PRIVATE_COLUMN_KEY.TAGS, ]; export const EDITABLE_DATA_PRIVATE_COLUMN_KEYS = [ PRIVATE_COLUMN_KEY.CAPTURE_TIME, PRIVATE_COLUMN_KEY.FILE_STATUS, PRIVATE_COLUMN_KEY.FILE_RATE, + PRIVATE_COLUMN_KEY.TAGS, ]; export const DELETABLE_PRIVATE_COLUMN_KEY = [ diff --git a/frontend/src/metadata/constants/column/type.js b/frontend/src/metadata/constants/column/type.js index ac3cb31a94..451da522fd 100644 --- a/frontend/src/metadata/constants/column/type.js +++ b/frontend/src/metadata/constants/column/type.js @@ -15,6 +15,8 @@ const CellType = { NUMBER: 'number', GEOLOCATION: 'geolocation', RATE: 'rate', + LINK: 'link', + TAGS: 'tags' }; export default CellType; diff --git a/frontend/src/metadata/constants/index.js b/frontend/src/metadata/constants/index.js index def6e945d7..95af7a74fa 100644 --- a/frontend/src/metadata/constants/index.js +++ b/frontend/src/metadata/constants/index.js @@ -52,8 +52,12 @@ export const TABLE_SUPPORT_EDIT_TYPE_MAP = { [CellType.DATE]: true, [CellType.NUMBER]: true, [CellType.SINGLE_SELECT]: true, + [CellType.MULTIPLE_SELECT]: true, [CellType.COLLABORATOR]: true, [CellType.CHECKBOX]: true, + [CellType.LONG_TEXT]: true, + [CellType.LINK]: true, + [CellType.TAGS]: true, }; export const TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP = { diff --git a/frontend/src/metadata/constants/view.js b/frontend/src/metadata/constants/view.js index 1d0a27d511..aaee69f0a5 100644 --- a/frontend/src/metadata/constants/view.js +++ b/frontend/src/metadata/constants/view.js @@ -107,6 +107,6 @@ export const VIEW_DEFAULT_SETTINGS = { [KANBAN_SETTINGS_KEYS.HIDE_EMPTY_VALUE]: false, [KANBAN_SETTINGS_KEYS.SHOW_COLUMN_NAME]: false, [KANBAN_SETTINGS_KEYS.TEXT_WRAP]: false, - [KANBAN_SETTINGS_KEYS.COLUMNS_KEYS]: [], + [KANBAN_SETTINGS_KEYS.COLUMNS]: [], } }; diff --git a/frontend/src/metadata/context.js b/frontend/src/metadata/context.js index c242c8e182..031f099d26 100644 --- a/frontend/src/metadata/context.js +++ b/frontend/src/metadata/context.js @@ -254,6 +254,17 @@ class Context { return this.metadataAPI.getPeoplePhotos(repoID, recordId, start, limit); }; + // file tag + addFileTags = (recordId, tagIds) => { + const repoID = this.settings['repoID']; + return this.metadataAPI.addFileTags(repoID, recordId, tagIds); + }; + + updateFileTags = (recordId, tagIds) => { + const repoID = this.settings['repoID']; + return this.metadataAPI.updateFileTags(repoID, recordId, tagIds); + }; + } export default Context; diff --git a/frontend/src/metadata/hooks/enable-metadata.js b/frontend/src/metadata/hooks/enable-metadata.js deleted file mode 100644 index c240559edc..0000000000 --- a/frontend/src/metadata/hooks/enable-metadata.js +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import metadataAPI from '../api'; -import { Utils } from '../../utils/utils'; -import toaster from '../../components/toast'; -import { seafileAPI } from '../../utils/seafile-api'; - -// This hook provides content related to seahub interaction, such as whether to enable extended attributes -const EnableMetadataContext = React.createContext(null); - -export const EnableMetadataProvider = ({ repoID, children }) => { - - const [enableMetadataManagement, setEnableMetadataManagement] = useState(false); - const [enableMetadata, setEnableExtendedProperties] = useState(false); - - useEffect(() => { - seafileAPI.getRepoInfo(repoID).then(res => { - if (res.data.encrypted) { - setEnableMetadataManagement(false); - } else { - setEnableMetadataManagement(window.app.pageOptions.enableMetadataManagement); - } - }); - }, [repoID]); - - useEffect(() => { - if (!enableMetadataManagement) { - return; - } - metadataAPI.getMetadataStatus(repoID).then(res => { - const enableMetadata = res.data.enabled; - setEnableExtendedProperties(enableMetadata); - }).catch(error => { - const errorMsg = Utils.getErrorMsg(error, true); - toaster.danger(errorMsg); - setEnableExtendedProperties(false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [repoID, enableMetadataManagement]); - - return ( - - {children} - - ); -}; - -export const useEnableMetadata = () => { - const context = useContext(EnableMetadataContext); - if (!context) { - throw new Error('\'EnableMetadataContext\' is null'); - } - return context; -}; diff --git a/frontend/src/metadata/hooks/index.js b/frontend/src/metadata/hooks/index.js index 74b4c9ab56..a00eced1d9 100644 --- a/frontend/src/metadata/hooks/index.js +++ b/frontend/src/metadata/hooks/index.js @@ -1,3 +1,2 @@ export { MetadataProvider, useMetadata } from './metadata'; -export { EnableMetadataProvider, useEnableMetadata } from './enable-metadata'; export { CollaboratorsProvider, useCollaborators } from './collaborators'; diff --git a/frontend/src/metadata/hooks/metadata-view.js b/frontend/src/metadata/hooks/metadata-view.js index e987f75352..d246449508 100644 --- a/frontend/src/metadata/hooks/metadata-view.js +++ b/frontend/src/metadata/hooks/metadata-view.js @@ -18,6 +18,7 @@ export const MetadataViewProvider = ({ }) => { const [isLoading, setLoading] = useState(true); const [metadata, setMetadata] = useState({ rows: [], columns: [], view: {} }); + const [errorMessage, setErrorMessage] = useState(null); const storeRef = useRef(null); const { collaborators } = useCollaborators(); const { isEmptyRepo, showFirstView, setShowFirstView } = useMetadata(); @@ -41,7 +42,8 @@ export const MetadataViewProvider = ({ setLoading(false); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); - toaster.danger(errorMsg); + setErrorMessage(errorMsg); + setLoading(false); }); }, []); @@ -131,6 +133,7 @@ export const MetadataViewProvider = ({ value={{ isLoading, showFirstView, + errorMessage, metadata, store: storeRef.current, isDirentDetailShow: params.isDirentDetailShow, diff --git a/frontend/src/metadata/hooks/metadata.js b/frontend/src/metadata/hooks/metadata.js index f9c341b798..826a5bfa72 100644 --- a/frontend/src/metadata/hooks/metadata.js +++ b/frontend/src/metadata/hooks/metadata.js @@ -1,23 +1,16 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useRef, useState, useMemo } from 'react'; import metadataAPI from '../api'; import { Utils } from '../../utils/utils'; import toaster from '../../components/toast'; import { gettext } from '../../utils/constants'; import { PRIVATE_FILE_TYPE } from '../../constants'; import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE } from '../constants'; +import { useMetadataStatus } from '../../hooks'; // This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc. const MetadataContext = React.createContext(null); -export const MetadataProvider = ({ repoID, currentRepoInfo, hideMetadataView, selectMetadataView, children }) => { - const enableMetadataManagement = useMemo(() => { - if (currentRepoInfo.encrypted) return false; - return window.app.pageOptions.enableMetadataManagement; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [window.app.pageOptions.enableMetadataManagement, currentRepoInfo]); - const isEmptyRepo = useMemo(() => currentRepoInfo.file_count === 0, [currentRepoInfo]); - - const [enableMetadata, setEnableExtendedProperties] = useState(false); +export const MetadataProvider = ({ repoID, repoInfo, hideMetadataView, selectMetadataView, children }) => { const [enableFaceRecognition, setEnableFaceRecognition] = useState(false); const [showFirstView, setShowFirstView] = useState(false); const [navigation, setNavigation] = useState([]); @@ -25,47 +18,9 @@ export const MetadataProvider = ({ repoID, currentRepoInfo, hideMetadataView, se const [, setCount] = useState(0); const viewsMap = useRef({}); - const cancelURLView = useCallback(() => { - // If attribute extension is turned off, unmark the URL - const { origin, pathname, search } = window.location; - const urlParams = new URLSearchParams(search); - const viewID = urlParams.get('view'); - if (viewID) { - const url = `${origin}${pathname}`; - window.history.pushState({ url: url, path: '' }, '', url); - } - }, []); + const isEmptyRepo = useMemo(() => repoInfo.file_count === 0, [repoInfo]); - useEffect(() => { - if (!enableMetadataManagement) { - cancelURLView(); - return; - } - metadataAPI.getMetadataStatus(repoID).then(res => { - const enableMetadata = res.data.enabled; - if (!enableMetadata) { - cancelURLView(); - } - setEnableExtendedProperties(enableMetadata); - }).catch(error => { - const errorMsg = Utils.getErrorMsg(error, true); - toaster.danger(errorMsg); - setEnableExtendedProperties(false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [repoID, enableMetadataManagement]); - - const updateEnableMetadata = useCallback((newValue) => { - if (newValue === enableMetadata) return; - if (!newValue) { - hideMetadataView && hideMetadataView(); - cancelURLView(); - setEnableFaceRecognition(false); - } else { - setShowFirstView(true); - } - setEnableExtendedProperties(newValue); - }, [enableMetadata, hideMetadataView, cancelURLView]); + const { enableMetadata } = useMetadataStatus(); const updateEnableFaceRecognition = useCallback((newValue) => { if (newValue === enableFaceRecognition) return; @@ -97,11 +52,13 @@ export const MetadataProvider = ({ repoID, currentRepoInfo, hideMetadataView, se }); return; } - + hideMetadataView && hideMetadataView(); + setEnableFaceRecognition(false); viewsMap.current = {}; + setStaticView([]); setNavigation([]); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [repoID, enableMetadata]); + }, [repoID, enableMetadata, hideMetadataView]); useEffect(() => { if (!enableMetadata) { @@ -217,8 +174,6 @@ export const MetadataProvider = ({ repoID, currentRepoInfo, hideMetadataView, se return ( { }, []); useEffect(() => { - if (!currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) return null; + if (!currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) return; const currentViewId = currentPath.split('/').pop(); const currentView = viewsMap[currentViewId]; if (currentView) { diff --git a/frontend/src/metadata/metadata-tree-view/view-item/index.js b/frontend/src/metadata/metadata-tree-view/view-item/index.js index bf77b7a324..c313feab29 100644 --- a/frontend/src/metadata/metadata-tree-view/view-item/index.js +++ b/frontend/src/metadata/metadata-tree-view/view-item/index.js @@ -203,7 +203,7 @@ const ViewItem = ({ <>
{ + const type = OPERATION_TYPE.MODIFY_SETTINGS; + const operation = this.createOperation({ + type, repo_id: this.repoId, view_id: this.viewId, settings + }); + this.applyOperation(operation); + }; + // column insertColumn = (name, columnType, { key, data }) => { const operationType = OPERATION_TYPE.INSERT_COLUMN; @@ -560,10 +568,19 @@ class Store { this.applyOperation(operation); }; - modifySettings = (settings) => { - const type = OPERATION_TYPE.MODIFY_SETTINGS; + // tag + addFileTags = (recordId, tagIds) => { + const type = OPERATION_TYPE.ADD_FILE_TAGS; const operation = this.createOperation({ - type, repo_id: this.repoId, view_id: this.viewId, settings + type, repo_id: this.repoId, record_id: recordId, tag_ids: tagIds + }); + this.applyOperation(operation); + }; + + updateFileTags = (recordId, tagIds) => { + const type = OPERATION_TYPE.UPDATE_FILE_TAGS; + const operation = this.createOperation({ + type, repo_id: this.repoId, record_id: recordId, tag_ids: tagIds }); this.applyOperation(operation); }; diff --git a/frontend/src/metadata/store/operations/apply.js b/frontend/src/metadata/store/operations/apply.js index e7f1765ae6..63984c79c2 100644 --- a/frontend/src/metadata/store/operations/apply.js +++ b/frontend/src/metadata/store/operations/apply.js @@ -1,11 +1,11 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; -import { UTC_FORMAT_DEFAULT } from '../../constants'; +import { UTC_FORMAT_DEFAULT, PRIVATE_COLUMN_KEY } from '../../constants'; import { OPERATION_TYPE } from './constants'; -import Column from '../../model/metadata/column'; +import Column from '../../model/column'; import View from '../../model/metadata/view'; import { getColumnOriginName } from '../../utils/column'; -import { geRecordIdFromRecord } from '../../utils/cell'; +import { getRecordIdFromRecord } from '../../utils/cell'; dayjs.extend(utc); @@ -163,7 +163,7 @@ export default function apply(data, operation) { let id_row_map = {}; data.rows.forEach(row => { delete row[columnOriginName]; - const id = geRecordIdFromRecord(row); + const id = getRecordIdFromRecord(row); rows.push(row); id_row_map[id] = row; }); @@ -211,6 +211,12 @@ export default function apply(data, operation) { data.view = new View({ ...data.view, columns_keys: new_columns_keys }, data.columns); return data; } + case OPERATION_TYPE.MODIFY_SETTINGS: { + const { settings } = operation; + data.view.settings = settings; + return data; + } + // face table op case OPERATION_TYPE.RENAME_PEOPLE_NAME: { const { people_id, new_name } = operation; @@ -249,11 +255,25 @@ export default function apply(data, operation) { data.recordsCount = updatedRows.length; return data; } - case OPERATION_TYPE.MODIFY_SETTINGS: { - const { settings } = operation; - data.view.settings = settings; + + // tags + case OPERATION_TYPE.ADD_FILE_TAGS: + case OPERATION_TYPE.UPDATE_FILE_TAGS: { + const { record_id, tag_ids } = operation; + const { rows } = data; + let updatedRows = [...rows]; + rows.forEach((row, index) => { + const { _id: rowId } = row; + if (rowId === record_id) { + const updatedRow = Object.assign({}, row, { [PRIVATE_COLUMN_KEY.TAGS]: tag_ids ? tag_ids.map(item => ({ row_id: item })) : [] }); + updatedRows[index] = updatedRow; + data.id_row_map[rowId] = updatedRow; + } + }); + data.rows = updatedRows; return data; } + default: { return data; } diff --git a/frontend/src/metadata/store/operations/constants.js b/frontend/src/metadata/store/operations/constants.js index 02b918c2d5..0836416076 100644 --- a/frontend/src/metadata/store/operations/constants.js +++ b/frontend/src/metadata/store/operations/constants.js @@ -23,6 +23,10 @@ export const OPERATION_TYPE = { // face table RENAME_PEOPLE_NAME: 'rename_people_name', DELETE_PEOPLE_PHOTOS: 'delete_people_photos', + + // tag + ADD_FILE_TAGS: 'add_file_tags', + UPDATE_FILE_TAGS: 'update_file_tags', }; export const COLUMN_DATA_OPERATION_TYPE = { @@ -55,6 +59,8 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.DELETE_PEOPLE_PHOTOS]: ['repo_id', 'people_id', 'deleted_photos'], [OPERATION_TYPE.MODIFY_SETTINGS]: ['repo_id', 'view_id', 'settings'], [OPERATION_TYPE.MODIFY_LOCAL_RECORD]: ['repo_id', 'row_id', 'updates'], + [OPERATION_TYPE.ADD_FILE_TAGS]: ['repo_id', 'record_id', 'tag_ids'], + [OPERATION_TYPE.UPDATE_FILE_TAGS]: ['repo_id', 'record_id', 'tag_ids'], }; export const UNDO_OPERATION_TYPE = [ diff --git a/frontend/src/metadata/store/server-operator.js b/frontend/src/metadata/store/server-operator.js index 6aa3fa3066..60cf9f4e0d 100644 --- a/frontend/src/metadata/store/server-operator.js +++ b/frontend/src/metadata/store/server-operator.js @@ -173,6 +173,15 @@ class ServerOperator { }); break; } + case OPERATION_TYPE.MODIFY_SETTINGS: { + const { repo_id, view_id, settings } = operation; + window.sfMetadataContext.modifyView(repo_id, view_id, { settings }).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: gettext('Failed to modify settings') }); + }); + break; + } // face table op case OPERATION_TYPE.RENAME_PEOPLE_NAME: { @@ -185,15 +194,27 @@ class ServerOperator { break; } - case OPERATION_TYPE.MODIFY_SETTINGS: { - const { repo_id, view_id, settings } = operation; - window.sfMetadataContext.modifyView(repo_id, view_id, { settings }).then(res => { + // tags + case OPERATION_TYPE.ADD_FILE_TAGS: { + const { record_id, tag_ids } = operation; + window.sfMetadataContext.addFileTags(record_id, tag_ids).then(res => { callback({ operation }); }).catch(error => { - callback({ error: gettext('Failed to modify settings') }); + callback({ error: gettext('Failed to modify people name') }); }); break; } + case OPERATION_TYPE.UPDATE_FILE_TAGS: { + const { record_id, tag_ids } = operation; + window.sfMetadataContext.updateFileTags(record_id, tag_ids).then(res => { + callback({ operation }); + + }).catch(error => { + callback({ error: gettext('Failed to modify people name') }); + }); + break; + } + default: { break; } diff --git a/frontend/src/metadata/utils/cell/core.js b/frontend/src/metadata/utils/cell/core.js index b484d5c395..350f2833bf 100644 --- a/frontend/src/metadata/utils/cell/core.js +++ b/frontend/src/metadata/utils/cell/core.js @@ -32,7 +32,7 @@ export const getFileNameFromRecord = (record) => { return record ? record[PRIVATE_COLUMN_KEY.FILE_NAME] : ''; }; -export const geRecordIdFromRecord = record => { +export const getRecordIdFromRecord = record => { return record ? record[PRIVATE_COLUMN_KEY.ID] : ''; }; @@ -47,3 +47,15 @@ export const getImageLocationFromRecord = (record) => { export const getFileTypeFromRecord = (record) => { return record ? record[PRIVATE_COLUMN_KEY.FILE_TYPE] : null; }; + +export const getFileSizedFromRecord = record => { + return record ? record[PRIVATE_COLUMN_KEY.SIZE] : ''; +}; + +export const getFileMTimeFromRecord = record => { + return record ? record[PRIVATE_COLUMN_KEY.FILE_MTIME] : ''; +}; + +export const getTagsFromRecord = record => { + return record ? record[PRIVATE_COLUMN_KEY.TAGS] : ''; +}; diff --git a/frontend/src/metadata/utils/column/index.js b/frontend/src/metadata/utils/column/index.js index 29a177ac84..ac832de9fd 100644 --- a/frontend/src/metadata/utils/column/index.js +++ b/frontend/src/metadata/utils/column/index.js @@ -209,6 +209,8 @@ export const getColumnDisplayName = (key, name) => { return gettext('File owner'); case PRIVATE_COLUMN_KEY.FILE_RATE: return gettext('File rate'); + case PRIVATE_COLUMN_KEY.TAGS: + return gettext('Tags'); default: return name; } @@ -259,6 +261,8 @@ export const getNormalizedColumnType = (key, type) => { return CellType.GEOLOCATION; case PRIVATE_COLUMN_KEY.OWNER: return CellType.COLLABORATOR; + case PRIVATE_COLUMN_KEY.TAGS: + return CellType.TAGS; default: return type; } diff --git a/frontend/src/metadata/metadata-view/index.css b/frontend/src/metadata/views/index.css similarity index 100% rename from frontend/src/metadata/metadata-view/index.css rename to frontend/src/metadata/views/index.css diff --git a/frontend/src/metadata/metadata-view/index.js b/frontend/src/metadata/views/index.js similarity index 56% rename from frontend/src/metadata/metadata-view/index.js rename to frontend/src/metadata/views/index.js index 4c7b3726c0..d4a987f080 100644 --- a/frontend/src/metadata/metadata-view/index.js +++ b/frontend/src/metadata/views/index.js @@ -1,8 +1,6 @@ import React from 'react'; -import PropTypes from 'prop-types'; import View from './view'; import { MetadataViewProvider } from '../hooks/metadata-view'; -import Context from '../context'; import './index.css'; @@ -14,13 +12,4 @@ const SeafileMetadata = ({ ...params }) => { ); }; -SeafileMetadata.propTypes = { - collaborators: PropTypes.array, - collaboratorsCache: PropTypes.object, - updateCollaboratorsCache: PropTypes.func, -}; - export default SeafileMetadata; -export { - Context, -}; diff --git a/frontend/src/metadata/views/kanban/boards/board/index.js b/frontend/src/metadata/views/kanban/boards/board/index.js index a090d2b971..c05f8da0cb 100644 --- a/frontend/src/metadata/views/kanban/boards/board/index.js +++ b/frontend/src/metadata/views/kanban/boards/board/index.js @@ -6,7 +6,7 @@ import { useMetadataView } from '../../../../hooks/metadata-view'; import { getRowById } from '../../../../utils/table'; import Container from '../../dnd/container'; import Draggable from '../../dnd/draggable'; -import { geRecordIdFromRecord } from '../../../../utils/cell'; +import { getRecordIdFromRecord } from '../../../../utils/cell'; import './index.css'; @@ -80,7 +80,7 @@ const Board = ({ {board.children.map((cardKey) => { const record = getRowById(metadata, cardKey); if (!record) return null; - const isSelected = selectedCard === geRecordIdFromRecord(record); + const isSelected = selectedCard === getRecordIdFromRecord(record); const CardElement = ( { rows.forEach(row => { const cellValue = getCellValueByColumn(row, groupByColumn); - const recordId = geRecordIdFromRecord(row); + const recordId = getRecordIdFromRecord(row); if (isValidCellValue(cellValue)) { switch (groupByColumnType) { case CellType.SINGLE_SELECT: { @@ -188,7 +188,7 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => { }, []); const onSelectCard = useCallback((record) => { - const recordId = geRecordIdFromRecord(record); + const recordId = getRecordIdFromRecord(record); if (selectedCard === recordId) return; const name = getFileNameFromRecord(record); const path = getParentDirFromRecord(record); diff --git a/frontend/src/metadata/views/map/index.js b/frontend/src/metadata/views/map/index.js index 667eaae28a..ce1f75733e 100644 --- a/frontend/src/metadata/views/map/index.js +++ b/frontend/src/metadata/views/map/index.js @@ -7,7 +7,7 @@ import { isValidPosition } from '../../utils/validate'; import { appAvatarURL, baiduMapKey, gettext, googleMapKey, mediaUrl, siteRoot, thumbnailSizeForGrid } from '../../../utils/constants'; import { useMetadataView } from '../../hooks/metadata-view'; import { PREDEFINED_FILE_TYPE_OPTION_KEY } from '../../constants'; -import { geRecordIdFromRecord, getFileNameFromRecord, getImageLocationFromRecord, getParentDirFromRecord, +import { getRecordIdFromRecord, getFileNameFromRecord, getImageLocationFromRecord, getParentDirFromRecord, getFileTypeFromRecord } from '../../utils/cell'; import { Utils } from '../../../utils/utils'; @@ -39,7 +39,7 @@ const Map = () => { .map(record => { const recordType = getFileTypeFromRecord(record); if (recordType !== PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE) return null; - const id = geRecordIdFromRecord(record); + const id = getRecordIdFromRecord(record); const fileName = getFileNameFromRecord(record); const parentDir = getParentDirFromRecord(record); const path = Utils.encodePath(Utils.joinPath(parentDir, fileName)); diff --git a/frontend/src/metadata/views/table/context-menu/index.js b/frontend/src/metadata/views/table/context-menu/index.js index 239f7e703a..95cc99f3d7 100644 --- a/frontend/src/metadata/views/table/context-menu/index.js +++ b/frontend/src/metadata/views/table/context-menu/index.js @@ -8,7 +8,7 @@ import { getColumnByKey, isNameColumn } from '../../../utils/column'; import { checkIsDir } from '../../../utils/row'; import { EVENT_BUS_TYPE, EVENT_BUS_TYPE as METADATA_EVENT_BUS_TYPE, PRIVATE_COLUMN_KEY } from '../../../constants'; import { getFileNameFromRecord, getParentDirFromRecord, getFileObjIdFromRecord, - geRecordIdFromRecord, + getRecordIdFromRecord, } from '../../../utils/cell'; import './index.css'; @@ -251,7 +251,7 @@ const ContextMenu = (props) => { return; } - const recordIds = records.map(record => geRecordIdFromRecord(record)); + const recordIds = records.map(record => getRecordIdFromRecord(record)); window.sfMetadataContext.extractFileDetails(recordObjIds).then(res => { const captureColumn = getColumnByKey(metadata.columns, PRIVATE_COLUMN_KEY.CAPTURE_TIME); diff --git a/frontend/src/metadata/views/table/index.js b/frontend/src/metadata/views/table/index.js index d59c6b0ef0..2d4aa84def 100644 --- a/frontend/src/metadata/views/table/index.js +++ b/frontend/src/metadata/views/table/index.js @@ -184,6 +184,18 @@ const Table = () => { store.modifyColumnOrder(sourceColumnKey, targetColumnKey); }, [store]); + const addFileTags = useCallback((recordId, tagIds) => { + store.addFileTags(recordId, tagIds); + }, [store]); + + const updateFileTags = useCallback((recordId, tagIds) => { + store.updateFileTags(recordId, tagIds); + }, [store]); + + const insertColumn = useCallback((name, type, { key, data }) => { + store.insertColumn(name, type, { key, data }); + }, [store]); + const recordGetterById = useCallback((recordId) => { return metadata.id_row_map[recordId]; }, [metadata]); @@ -232,11 +244,14 @@ const Table = () => { getTableContentRect={getTableContentRect} getAdjacentRowsIds={getAdjacentRowsIds} loadAll={loadAll} + insertColumn={insertColumn} renameColumn={renameColumn} deleteColumn={deleteColumn} modifyColumnData={modifyColumnData} modifyColumnWidth={modifyColumnWidth} modifyColumnOrder={modifyColumnOrder} + addFileTags={addFileTags} + updateFileTags={updateFileTags} />
); diff --git a/frontend/src/metadata/views/table/masks/interaction-masks/index.js b/frontend/src/metadata/views/table/masks/interaction-masks/index.js index f200b2f39c..ef554a6ac2 100644 --- a/frontend/src/metadata/views/table/masks/interaction-masks/index.js +++ b/frontend/src/metadata/views/table/masks/interaction-masks/index.js @@ -1099,6 +1099,8 @@ class InteractionMasks extends React.Component { onCommit={this.onCommit} onCommitCancel={this.onCommitCancel} modifyColumnData={this.props.modifyColumnData} + addFileTags={this.props.addFileTags} + updateFileTags={this.props.updateFileTags} editorPosition={editorPosition} {...{ ...this.getSelectedDimensions(selectedPosition), diff --git a/frontend/src/metadata/views/table/table-main/index.js b/frontend/src/metadata/views/table/table-main/index.js index b13e3efa1a..5ad87bd048 100644 --- a/frontend/src/metadata/views/table/table-main/index.js +++ b/frontend/src/metadata/views/table/table-main/index.js @@ -7,7 +7,7 @@ import { GROUP_VIEW_OFFSET } from '../../../constants'; import './index.css'; -const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, searchResult, recordGetterByIndex, recordGetterById, modifyColumnData, ...props }) => { +const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, searchResult, recordGetterByIndex, recordGetterById, insertColumn, modifyColumnData, ...props }) => { const gridUtils = useMemo(() => { return new GridUtils(metadata, { modifyRecord, modifyRecords, recordGetterByIndex, recordGetterById, modifyColumnData }); @@ -39,6 +39,10 @@ const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, s modifyRecords && modifyRecords(recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData, isCopyPaste); }, [modifyRecords]); + const handelInsertColumn = useCallback((name, type, { key, data }) => { + insertColumn && insertColumn(name, type, { key, data }); + }, [insertColumn]); + const paste = useCallback(({ type, copied, multiplePaste, pasteRange, isGroupView }) => { gridUtils.paste({ type, copied, multiplePaste, pasteRange, isGroupView, columns }); }, [gridUtils, columns]); @@ -65,6 +69,7 @@ const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, s recordGetterById={recordGetterById} recordGetterByIndex={recordGetterByIndex} modifyColumnData={modifyColumnData} + insertColumn={handelInsertColumn} {...props} /> diff --git a/frontend/src/metadata/views/table/table-main/records-header/index.js b/frontend/src/metadata/views/table/table-main/records-header/index.js index 84487637fc..bc6b29028f 100644 --- a/frontend/src/metadata/views/table/table-main/records-header/index.js +++ b/frontend/src/metadata/views/table/table-main/records-header/index.js @@ -26,6 +26,7 @@ const RecordsHeader = ({ selectAllRecords, modifyColumnWidth: modifyColumnWidthAPI, modifyColumnOrder: modifyColumnOrderAPI, + insertColumn, ...props }) => { const [resizingColumnMetrics, setResizingColumnMetrics] = useState(null); @@ -161,7 +162,15 @@ const RecordsHeader = ({ /> ); })} - + {insertColumn && ( + + )} ); @@ -181,6 +190,7 @@ RecordsHeader.propTypes = { onRef: PropTypes.func, selectNoneRecords: PropTypes.func, selectAllRecords: PropTypes.func, + insertColumn: PropTypes.func, }; export default RecordsHeader; diff --git a/frontend/src/metadata/views/table/table-main/records-header/insert-column/index.js b/frontend/src/metadata/views/table/table-main/records-header/insert-column/index.js index 40194f9f07..59151d3d24 100644 --- a/frontend/src/metadata/views/table/table-main/records-header/insert-column/index.js +++ b/frontend/src/metadata/views/table/table-main/records-header/insert-column/index.js @@ -2,12 +2,11 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import { Icon } from '@seafile/sf-metadata-ui-component'; import ColumnPopover from '../../../../../components/popover/column-popover'; -import { useMetadataView } from '../../../../../hooks/metadata-view'; import { isEnter } from '../../../../../utils/hotkey'; import './index.css'; -const InsertColumn = ({ lastColumn, height, groupOffsetLeft }) => { +const InsertColumn = ({ lastColumn, height, groupOffsetLeft, insertColumn: insertColumnAPI }) => { const id = useMemo(() => 'sf-metadata-add-column', []); const ref = useRef(null); const style = useMemo(() => { @@ -21,15 +20,13 @@ const InsertColumn = ({ lastColumn, height, groupOffsetLeft }) => { }; }, [lastColumn, height, groupOffsetLeft]); - const { store } = useMetadataView(); - const openPopover = useCallback(() => { ref?.current?.click(); }, [ref]); const insertColumn = useCallback((name, type, { key, data }) => { - store.insertColumn(name, type, { key, data }); - }, [store]); + insertColumnAPI(name, type, { key, data }); + }, [insertColumnAPI]); const onHotKey = useCallback((event) => { if (isEnter(event) && document.activeElement && document.activeElement.id === id) { @@ -61,6 +58,7 @@ InsertColumn.propTypes = { lastColumn: PropTypes.object.isRequired, height: PropTypes.number, groupOffsetLeft: PropTypes.number, + insertColumn: PropTypes.func.isRequired, }; export default InsertColumn; diff --git a/frontend/src/metadata/views/table/table-main/records/body.js b/frontend/src/metadata/views/table/table-main/records/body.js index ecd72caa07..5044dce1ee 100644 --- a/frontend/src/metadata/views/table/table-main/records/body.js +++ b/frontend/src/metadata/views/table/table-main/records/body.js @@ -543,6 +543,8 @@ class RecordsBody extends Component { getCopiedRecordsAndColumnsFromRange={this.props.getCopiedRecordsAndColumnsFromRange} modifyColumnData={this.props.modifyColumnData} getTableCanvasContainerRect={this.props.getTableCanvasContainerRect} + addFileTags={this.props.addFileTags} + updateFileTags={this.props.updateFileTags} />
{this.renderRecords()} diff --git a/frontend/src/metadata/views/table/table-main/records/group-body/index.js b/frontend/src/metadata/views/table/table-main/records/group-body/index.js index 1e1bc4304b..1006f03ffd 100644 --- a/frontend/src/metadata/views/table/table-main/records/group-body/index.js +++ b/frontend/src/metadata/views/table/table-main/records/group-body/index.js @@ -905,6 +905,8 @@ class GroupBody extends Component { getCopiedRecordsAndColumnsFromRange={this.props.getCopiedRecordsAndColumnsFromRange} modifyColumnData={this.props.modifyColumnData} getTableCanvasContainerRect={this.props.getTableCanvasContainerRect} + addFileTags={this.props.addFileTags} + updateFileTags={this.props.updateFileTags} />
{this.renderGroups()} diff --git a/frontend/src/metadata/views/table/table-main/records/index.js b/frontend/src/metadata/views/table/table-main/records/index.js index 58520160dc..93bd28573a 100644 --- a/frontend/src/metadata/views/table/table-main/records/index.js +++ b/frontend/src/metadata/views/table/table-main/records/index.js @@ -688,7 +688,7 @@ class Records extends Component { render() { const { recordIds, recordsCount, table, isGroupView, groupOffsetLeft, renameColumn, modifyColumnData, - deleteColumn, modifyColumnOrder, + deleteColumn, modifyColumnOrder, insertColumn, } = this.props; const { recordMetrics, columnMetrics, selectedRange, colOverScanStartIdx, colOverScanEndIdx } = this.state; const { columns, totalWidth, lastFrozenColumnKey } = columnMetrics; @@ -726,6 +726,7 @@ class Records extends Component { selectAllRecords={this.selectAllRecords} renameColumn={renameColumn} deleteColumn={deleteColumn} + insertColumn={insertColumn} modifyColumnData={modifyColumnData} modifyColumnOrder={modifyColumnOrder} /> @@ -791,6 +792,7 @@ Records.propTypes = { loadAll: PropTypes.func, renameColumn: PropTypes.func, deleteColumn: PropTypes.func, + insertColumn: PropTypes.func, modifyColumnData: PropTypes.func, modifyColumnWidth: PropTypes.func, modifyColumnOrder: PropTypes.func, diff --git a/frontend/src/metadata/views/table/table-main/records/record/actions-cell/index.css b/frontend/src/metadata/views/table/table-main/records/record/actions-cell/index.css index 4563bb2c1e..87b305e77a 100644 --- a/frontend/src/metadata/views/table/table-main/records/record/actions-cell/index.css +++ b/frontend/src/metadata/views/table/table-main/records/record/actions-cell/index.css @@ -33,13 +33,3 @@ .sf-metadata-result-table-row.row-selected .sf-metadata-result-column-content.row-index { display: none !important; } - -.sf-metadata-result-table-row .rdg-row-expand-icon:hover { - background: #c2f5e9; -} - -.sf-metadata-result-table-row .rdg-row-expand-icon .sf-metadata-icon-open-record { - color: #467fcf !important; - fill: #467fcf !important; - transform: scale(0.8); -} diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/formatter.js b/frontend/src/metadata/views/table/table-main/records/record/cell/formatter.js index 68dc9e536a..957baad556 100644 --- a/frontend/src/metadata/views/table/table-main/records/record/cell/formatter.js +++ b/frontend/src/metadata/views/table/table-main/records/record/cell/formatter.js @@ -5,8 +5,9 @@ import CheckboxEditor from '../../../../../../components/cell-editors/checkbox-e import RateEditor from '../../../../../../components/cell-editors/rate-editor'; import { canEditCell } from '../../../../../../utils/column'; import { CellType } from '../../../../../../constants'; +import FileTagsFormatter from '../../../../../../components/cell-formatter/file-tags-formatter'; -const Formatter = ({ isCellSelected, isDir, field, value, onChange, record }) => { +const Formatter = ({ isCellSelected, field, value, onChange, record }) => { const { type } = field; const cellEditAble = canEditCell(field, record, true); if (type === CellType.CHECKBOX && cellEditAble) { @@ -15,12 +16,16 @@ const Formatter = ({ isCellSelected, isDir, field, value, onChange, record }) => if (type === CellType.RATE && cellEditAble) { return (); } - return (); + + if (field.type === CellType.TAGS) { + return (); + } + + return (); }; Formatter.propTypes = { isCellSelected: PropTypes.bool, - isDir: PropTypes.bool, field: PropTypes.object, value: PropTypes.any, record: PropTypes.object, diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/index.js b/frontend/src/metadata/views/table/table-main/records/record/cell/index.js index d04e82b29e..9f73152512 100644 --- a/frontend/src/metadata/views/table/table-main/records/record/cell/index.js +++ b/frontend/src/metadata/views/table/table-main/records/record/cell/index.js @@ -26,24 +26,25 @@ const Cell = React.memo(({ frozen, height, }) => { + const canEditable = useMemo(() => { + const { type } = column; + if (!window.sfMetadataContext.canModifyColumn(column)) return false; + if (!TABLE_SUPPORT_EDIT_TYPE_MAP[type]) return false; + if (type === CellType.TAGS) return !checkIsDir(record); + return true; + }, [column, record]); + const className = useMemo(() => { const { type } = column; - const canEditable = window.sfMetadataContext.canModifyColumn(column); return classnames('sf-metadata-result-table-cell', `sf-metadata-result-table-${type}-cell`, highlightClassName, { - 'table-cell-uneditable': !canEditable || !TABLE_SUPPORT_EDIT_TYPE_MAP[type], + 'table-cell-uneditable': !canEditable, 'last-cell': isLastCell, 'table-last--frozen': isLastFrozenCell, 'cell-selected': isCellSelected, // 'dragging-file-to-cell': , // 'row-comment-cell': , }); - }, [column, highlightClassName, isLastCell, isLastFrozenCell, isCellSelected]); - const isFileNameColumn = useMemo(() => { - return column.type === CellType.FILE_NAME; - }, [column]); - const isDir = useMemo(() => { - return checkIsDir(record); - }, [record]); + }, [canEditable, column, highlightClassName, isLastCell, isLastFrozenCell, isCellSelected]); const style = useMemo(() => { const { left, width } = column; let value = { @@ -158,15 +159,8 @@ const Cell = React.memo(({ return (
- - {(isCellSelected && isFileNameColumn) && ( - - )} + + {isCellSelected && ()}
); }, (props, nextProps) => { diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/index.css b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.css similarity index 100% rename from frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/index.css rename to frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.css diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.js b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.js new file mode 100644 index 0000000000..7d117e93db --- /dev/null +++ b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.js @@ -0,0 +1,54 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { UncontrolledTooltip } from 'reactstrap'; +import { IconBtn } from '@seafile/sf-metadata-ui-component'; +import { gettext } from '../../../../../../../../../utils/constants'; +import { EVENT_BUS_TYPE as METADATA_EVENT_BUS_TYPE, EDITOR_TYPE } from '../../../../../../../../constants'; +import { checkIsDir } from '../../../../../../../../utils/row'; +import { openFile } from '../../../../../../../../utils/open-file'; + +import './index.css'; + +const FileNameOperationBtn = ({ column, record, ...props }) => { + + const fileName = useMemo(() => { + const { key } = column; + return record[key]; + }, [column, record]); + + const isDir = useMemo(() => checkIsDir(record), [record]); + + const handelClick = (event) => { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + openFile(record, window.sfMetadataContext.eventBus, () => { + window.sfMetadataContext.eventBus.dispatch(METADATA_EVENT_BUS_TYPE.OPEN_EDITOR, EDITOR_TYPE.PREVIEWER); + }); + }; + + if (!fileName) return null; + + return ( + <> + + + {isDir ? gettext('Open folder') : gettext('Open file')} + + + ); +}; + +FileNameOperationBtn.propTypes = { + column: PropTypes.object, + record: PropTypes.object, +}; + +export default FileNameOperationBtn; diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/index.js b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/index.js index 21f7951e2c..bc7faa029d 100644 --- a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/index.js +++ b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/index.js @@ -1,47 +1,26 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { UncontrolledTooltip } from 'reactstrap'; -import { IconBtn } from '@seafile/sf-metadata-ui-component'; -import { gettext } from '../../../../../../../../utils/constants'; -import { EVENT_BUS_TYPE as METADATA_EVENT_BUS_TYPE, EDITOR_TYPE } from '../../../../../../../constants'; -import { openFile } from '../../../../../../../utils/open-file'; +import { CellType } from '../../../../../../../constants'; +import FileNameOperationBtn from './file-name-operation-btn'; +import LinkOperationBtn from './link-operation-btn'; -import './index.css'; - -const CellOperationBtn = ({ isDir, column, record, cellValue, ...props }) => { - - const handelClick = (event) => { - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation(); - openFile(record, window.sfMetadataContext.eventBus, () => { - window.sfMetadataContext.eventBus.dispatch(METADATA_EVENT_BUS_TYPE.OPEN_EDITOR, EDITOR_TYPE.PREVIEWER); - }); - }; - - if (!cellValue) return null; - - return ( - <> - - - {isDir ? gettext('Open folder') : gettext('Open file')} - - - ); +const CellOperationBtn = ({ column, record }) => { + switch (column.type) { + case CellType.FILE_NAME: { + return (); + } + case CellType.LINK: { + return (); + } + default: { + return null; + } + } }; CellOperationBtn.propTypes = { - isDir: PropTypes.bool, - column: PropTypes.object, - value: PropTypes.any, + column: PropTypes.object.isRequired, + record: PropTypes.object.isRequired, }; export default CellOperationBtn; diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/link-operation-btn/index.css b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/link-operation-btn/index.css new file mode 100644 index 0000000000..2606d4a5ec --- /dev/null +++ b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/link-operation-btn/index.css @@ -0,0 +1,22 @@ +.sf-metadata-cell-expand-operation-btn { + background-color: #2d7ff9; + border-radius: 2px; + top: 10px; + box-shadow: none; +} + +.sf-metadata-cell-expand-operation-btn:hover { + box-shadow: 0 0 0 2px #d0f0fd; + cursor: pointer; + background-color: #2d7ff9; +} + +.sf-metadata-cell-expand-operation-btn .sf-metadata-icon-expand { + fill: #fff; + font-size: 12px; + transform: scale(.8); +} + +.sf-metadata-cell-expand-operation-btn:hover .sf-metadata-icon-expand { + fill: #fff; +} diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/link-operation-btn/index.js b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/link-operation-btn/index.js new file mode 100644 index 0000000000..402e9a8d7d --- /dev/null +++ b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/link-operation-btn/index.js @@ -0,0 +1,35 @@ +import React, { useCallback, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { IconBtn } from '@seafile/sf-metadata-ui-component'; +import { canEditCell } from '../../../../../../../../utils/column'; + +import './index.css'; + +const LinkOperationBtn = ({ record, column }) => { + + const canEdit = useMemo(() => canEditCell(column, record, true), [column, record]); + + const openEditor = useCallback(() => { + + }, []); + + if (!canEdit) return null; + + return ( + + ); +}; + +LinkOperationBtn.propTypes = { + record: PropTypes.object.isRequired, + column: PropTypes.object.isRequired, +}; + +export default LinkOperationBtn; + diff --git a/frontend/src/metadata/views/table/utils/selected-cell-utils.js b/frontend/src/metadata/views/table/utils/selected-cell-utils.js index 54ca208370..306af9ded1 100644 --- a/frontend/src/metadata/views/table/utils/selected-cell-utils.js +++ b/frontend/src/metadata/views/table/utils/selected-cell-utils.js @@ -2,8 +2,9 @@ import { Utils } from '../../../../utils/utils'; import { getCellValueByColumn, getFileNameFromRecord } from '../../../utils/cell'; import { getGroupByPath } from '../../../utils/view'; import { getColumnByIndex, canEditCell } from '../../../utils/column'; -import { PRIVATE_COLUMN_KEY, SUPPORT_PREVIEW_COLUMN_TYPES, metadataZIndexes } from '../../../constants'; +import { CellType, PRIVATE_COLUMN_KEY, SUPPORT_PREVIEW_COLUMN_TYPES, metadataZIndexes } from '../../../constants'; import { getGroupRecordByIndex } from './group-metrics'; +import { checkIsDir } from '../../../utils/row'; const SELECT_DIRECTION = { UP: 'upwards', @@ -51,6 +52,7 @@ export const isSelectedCellEditable = ({ enableCellSelect, selectedPosition, col isCellEditable = isCellEditable && canEditCell(column, row, enableCellSelect); if (imageRow) return isCellEditable; if (column?.key === PRIVATE_COLUMN_KEY.CAPTURE_TIME) return false; + if (column?.type === CellType.TAGS && checkIsDir(row)) return false; return isCellEditable; }; diff --git a/frontend/src/metadata/metadata-view/view.js b/frontend/src/metadata/views/view.js similarity index 76% rename from frontend/src/metadata/metadata-view/view.js rename to frontend/src/metadata/views/view.js index 94be7e9139..8be96bbdcb 100644 --- a/frontend/src/metadata/metadata-view/view.js +++ b/frontend/src/metadata/views/view.js @@ -1,16 +1,16 @@ import React, { useCallback } from 'react'; import { CenteredLoading, Loading } from '@seafile/sf-metadata-ui-component'; -import Table from '../views/table'; -import Gallery from '../views/gallery'; -import FaceRecognition from '../views/face-recognition'; -import Kanban from '../views/kanban'; -import Map from '../views/map'; +import Table from './table'; +import Gallery from './gallery'; +import FaceRecognition from './face-recognition'; +import Kanban from './kanban'; +import Map from './map'; import { useMetadataView } from '../hooks/metadata-view'; -import { gettext } from '../../utils/constants'; import { VIEW_TYPE } from '../constants'; +import { gettext } from '../../utils/constants'; const View = () => { - const { isLoading, showFirstView, metadata, errorMsg } = useMetadataView(); + const { isLoading, showFirstView, metadata, errorMessage } = useMetadataView(); const renderView = useCallback((metadata) => { if (!metadata) return null; @@ -48,7 +48,7 @@ const View = () => { return (
- {errorMsg ?
{gettext(errorMsg)}
: renderView(metadata)} + {errorMessage ?
{errorMessage}
: renderView(metadata)}
); diff --git a/frontend/src/pages/lib-content-view/lib-content-view.js b/frontend/src/pages/lib-content-view/lib-content-view.js index fd80b5da78..bb1ac88a93 100644 --- a/frontend/src/pages/lib-content-view/lib-content-view.js +++ b/frontend/src/pages/lib-content-view/lib-content-view.js @@ -22,8 +22,10 @@ import CopyMoveDirentProgressDialog from '../../components/dialog/copy-move-dire import DeleteFolderDialog from '../../components/dialog/delete-folder-dialog'; import { EVENT_BUS_TYPE } from '../../components/common/event-bus-type'; import { PRIVATE_FILE_TYPE } from '../../constants'; +import { MetadataStatusProvider } from '../../hooks'; import { MetadataProvider, CollaboratorsProvider } from '../../metadata/hooks'; -import { LIST_MODE, METADATA_MODE, DIRENT_DETAIL_MODE } from '../../components/dir-view-mode/constants'; +import { TagsProvider } from '../../tag/hooks'; +import { LIST_MODE, METADATA_MODE, DIRENT_DETAIL_MODE, TAGS_MODE } from '../../components/dir-view-mode/constants'; import CurDirPath from '../../components/cur-dir-path'; import DirTool from '../../components/cur-dir-path/dir-tool'; import Detail from '../../components/dirent-detail'; @@ -96,6 +98,7 @@ class LibContentView extends React.Component { asyncOperationProgress: 0, asyncOperatedFilesLength: 0, viewId: '0000', + tagId: '', currentDirent: null, }; @@ -531,6 +534,19 @@ class LibContentView extends React.Component { }); }; + showTagsView = (filePath, tagId) => { + const repoID = this.props.repoID; + const repoInfo = this.state.currentRepoInfo; + this.setState({ + currentMode: TAGS_MODE, + path: filePath, + tagId: tagId, + isDirentDetailShow: false + }); + const url = `${siteRoot}library/${repoID}/${encodeURIComponent(repoInfo.repo_name)}/?tag=${encodeURIComponent(tagId)}`; + window.history.pushState({ url: url, path: '' }, '', url); + }; + loadDirentList = (path) => { const { repoID } = this.props; const { sortBy, sortOrder } = this.state; @@ -1856,6 +1872,10 @@ class LibContentView extends React.Component { if (node.path !== this.state.path) { this.showFileMetadata(node.path, node.view_id || '0000', node.view_type || VIEW_TYPE.TABLE); } + } else if (Utils.isTags(node?.object?.type)) { + if (node.path !== this.state.path) { + this.showTagsView(node.path, node.tag_id); + } } else { let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path); let dirent = node.object; @@ -1972,12 +1992,17 @@ class LibContentView extends React.Component { isDirentSelected: false, isAllDirentSelected: false, }); + const path = node.path || ''; if (this.state.currentMode === METADATA_MODE) { - const path = node.path || ''; const isMetadataView = path.startsWith('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES); this.setState({ currentMode: cookie.load('seafile_view_mode') || (isMetadataView ? METADATA_MODE : LIST_MODE), }); + } else if (this.state.currentMode === TAGS_MODE) { + const isTagsView = path.startsWith('/' + PRIVATE_FILE_TYPE.TAGS_PROPERTIES); + this.setState({ + currentMode: cookie.load('seafile_view_mode') || (isTagsView ? TAGS_MODE : LIST_MODE), + }); } }; @@ -2180,228 +2205,228 @@ class LibContentView extends React.Component { } return ( - - -
-
- {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} + +
+ + +
e && e.stopPropagation()}> + {SELECT_OPTION_COLORS.map((colorItem, index) => { + const { COLOR: optionColor, BORDER_COLOR: borderColor, TEXT_COLOR: textColor } = colorItem; + const isSelected = (index === 0 && !color) || color === optionColor; + return ( +
+ +
+ ); + })} +
+
+
+
+ + + + +
+ {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 ( +
+ + + + + + + + + + + + + + {tagFiles.rows.map(file => { + const fileId = getRecordIdFromRecord(file); + return ( + ); + })} + +
+ + {/* icon */}{gettext('Name')}{/* operation */}{/* tag */}{gettext('Size')}{gettext('Last Update')}
+
+ ); +}; + +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