import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import metadataAPI from '../api'; import { Utils } from '../../utils/utils'; import toaster from '../../components/toast'; import Folder from '../model/metadata/folder'; import { gettext } from '../../utils/constants'; import { PRIVATE_FILE_TYPE } from '../../constants'; import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE, VIEWS_TYPE_FOLDER, VIEWS_TYPE_VIEW } from '../constants'; import { useMetadataStatus } from '../../hooks'; import { updateFavicon } from '../utils/favicon'; import { getViewName } from '../utils/view'; const CACHED_COLLAPSED_FOLDERS_PREFIX = 'sf-metadata-collapsed-folders'; // 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, currentPath, repoInfo, hideMetadataView, selectMetadataView, children }) => { const [isLoading, setLoading] = useState(true); const [enableFaceRecognition, setEnableFaceRecognition] = useState(false); const [navigation, setNavigation] = useState([]); const [idViewMap, setIdViewMap] = useState({}); const collapsedFoldersIds = useRef([]); const originalTitleRef = useRef(document.title); const { enableMetadata, isBeingBuilt, setIsBeingBuilt } = useMetadataStatus(); const getCollapsedFolders = useCallback(() => { const strFoldedFolders = window.localStorage.getItem(`${CACHED_COLLAPSED_FOLDERS_PREFIX}-${repoID}`); const foldedFolders = strFoldedFolders && JSON.parse(strFoldedFolders); return Array.isArray(foldedFolders) ? foldedFolders : []; }, [repoID]); const setCollapsedFolders = useCallback((collapsedFoldersIds) => { window.localStorage.setItem(`${CACHED_COLLAPSED_FOLDERS_PREFIX}-${repoID}`, JSON.stringify(collapsedFoldersIds)); }, [repoID]); const addViewIntoMap = useCallback((viewId, view) => { let updatedIdViewInMap = { ...idViewMap }; updatedIdViewInMap[viewId] = view; setIdViewMap(updatedIdViewInMap); }, [idViewMap]); const deleteViewFromMap = useCallback((viewId) => { let updatedIdViewInMap = { ...idViewMap }; delete updatedIdViewInMap[viewId]; setIdViewMap(updatedIdViewInMap); }, [idViewMap]); // views useEffect(() => { setLoading(true); if (enableMetadata) { metadataAPI.listViews(repoID).then(res => { const { navigation, views } = res.data; if (Array.isArray(views)) { let idViewMap = {}; views.forEach(view => { idViewMap[view._id] = { ...view, name: getViewName(view) }; }); setIdViewMap(idViewMap); } setNavigation(navigation); setLoading(false); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); setLoading(false); }); return; } hideMetadataView && hideMetadataView(); setEnableFaceRecognition(false); setIdViewMap({}); setNavigation([]); setLoading(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [repoID, enableMetadata]); useEffect(() => { if (!enableMetadata) { setEnableFaceRecognition(false); return; } metadataAPI.getFaceRecognitionStatus(repoID).then(res => { setEnableFaceRecognition(res.data.enabled); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); }); }, [repoID, enableMetadata]); const getFirstView = useCallback(() => { const firstViewNav = navigation.find(item => item.type === VIEWS_TYPE_VIEW); const firstView = firstViewNav ? idViewMap[firstViewNav._id] : null; if (!firstView && Object.keys(idViewMap).length > 0) { return idViewMap[Object.keys(idViewMap)[0]]; } return firstView; }, [navigation, idViewMap]); const selectView = useCallback((view, isSelected) => { if (isSelected) return; const node = { children: [], path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view._id, isExpanded: false, isLoaded: true, isPreload: true, object: { file_tags: [], id: view._id, type: PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, isDir: () => false, }, parentNode: {}, key: repoID, view_id: view._id, view_type: view.type, }; selectMetadataView(node); // eslint-disable-next-line react-hooks/exhaustive-deps }, [repoID, selectMetadataView]); useEffect(() => { collapsedFoldersIds.current = getCollapsedFolders(); }, [getCollapsedFolders]); const collapseFolder = useCallback((folderId) => { let updatedCollapsedFoldersIds = getCollapsedFolders(); if (updatedCollapsedFoldersIds.includes(folderId)) { return; } updatedCollapsedFoldersIds.push(folderId); setCollapsedFolders(updatedCollapsedFoldersIds); }, [getCollapsedFolders, setCollapsedFolders]); const expandFolder = useCallback((folderId) => { let updatedCollapsedFoldersIds = getCollapsedFolders(); if (!updatedCollapsedFoldersIds.includes(folderId)) { return; } updatedCollapsedFoldersIds = updatedCollapsedFoldersIds.filter((collapsedFolderId) => collapsedFolderId !== folderId); setCollapsedFolders(updatedCollapsedFoldersIds); }, [getCollapsedFolders, setCollapsedFolders]); const addFolder = useCallback((name, successCallback, failCallback) => { metadataAPI.addFolder(repoID, name).then(res => { let newNavigation = [...navigation]; const folder = new Folder(res.data.folder); newNavigation.push(folder); setNavigation(newNavigation); successCallback && successCallback(); }).catch(error => { failCallback && failCallback(error); }); }, [repoID, navigation]); const modifyFolder = useCallback((folderId, updates, successCallback, failCallback) => { metadataAPI.modifyFolder(repoID, folderId, updates).then(res => { let newNavigation = [...navigation]; let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); if (folderIndex < 0) { return; } const validUpdates = { ...updates }; delete validUpdates._id; delete validUpdates.type; delete validUpdates.children; let updatedFolder = newNavigation[folderIndex]; updatedFolder = Object.assign({}, updatedFolder, validUpdates); newNavigation[folderIndex] = updatedFolder; setNavigation(newNavigation); successCallback && successCallback(); }).catch(error => { failCallback && failCallback(error); }); }, [repoID, navigation]); const deleteFolder = useCallback((folderId) => { metadataAPI.deleteFolder(repoID, folderId).then(res => { let newNavigation = [...navigation]; let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); if (folderIndex < 0) { return; } const viewsInFolder = newNavigation[folderIndex].children; newNavigation.splice(folderIndex, 1); if (viewsInFolder.length > 0) { newNavigation.push(...viewsInFolder); } setNavigation(newNavigation); }).catch((error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); })); }, [repoID, navigation]); const addViewCallback = useCallback((view, folderId) => { const newViewNav = { _id: view._id, type: VIEWS_TYPE_VIEW }; let newNavigation = [...navigation]; if (folderId) { // add view into folder const folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); if (folderIndex < 0) { return; } let updatedFolder = newNavigation[folderIndex]; updatedFolder.children = Array.isArray(updatedFolder.children) ? updatedFolder.children : []; updatedFolder.children.push(newViewNav); } else { newNavigation.push(newViewNav); } const newView = { ...view, name: getViewName(view) }; addViewIntoMap(newView._id, newView); setNavigation(newNavigation); selectView(newView); }, [navigation, addViewIntoMap, setNavigation, selectView]); const addView = useCallback(({ folderId, name, type, successCallback, failCallback }) => { metadataAPI.addView(repoID, name, type, folderId).then(res => { const view = res.data.view; addViewCallback(view, folderId); successCallback && successCallback(); }).catch(error => { failCallback && failCallback(error); }); }, [repoID, addViewCallback]); const duplicateView = useCallback(({ folderId, viewId }) => { metadataAPI.duplicateView(repoID, viewId, folderId).then(res => { const view = res.data.view; addViewCallback(view, folderId); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); }); }, [repoID, addViewCallback]); const deleteView = useCallback(({ folderId, viewId, isSelected }) => { metadataAPI.deleteView(repoID, viewId, folderId).then(res => { let newNavigation = [...navigation]; let prevViewNav = null; if (folderId) { let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); if (folderIndex < 0) { return; } let updatedFolder = newNavigation[folderIndex]; if (!Array.isArray(updatedFolder.children) || updatedFolder.children.length === 0) { return; } const currentViewIndex = updatedFolder.children.findIndex((viewNav) => viewNav._id === viewId); prevViewNav = updatedFolder.children[currentViewIndex - 1]; updatedFolder.children = updatedFolder.children.filter(viewNav => viewNav._id !== viewId); } else { const currentViewIndex = newNavigation.findIndex(item => item._id === viewId); prevViewNav = newNavigation[currentViewIndex - 1]; newNavigation = newNavigation.filter(nav => nav._id !== viewId); } setNavigation(newNavigation); deleteViewFromMap(viewId); // re-select the previous view if (isSelected) { let prevView = null; if (prevViewNav && prevViewNav.type === VIEWS_TYPE_VIEW) { prevView = idViewMap[prevViewNav._id]; } if (!prevView) { prevView = getFirstView(); } selectView(prevView); } }).catch((error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); })); }, [repoID, navigation, idViewMap, deleteViewFromMap, getFirstView, selectView]); const updateView = useCallback((viewId, update, successCallback, failCallback) => { metadataAPI.modifyView(repoID, viewId, update).then(res => { const currentView = idViewMap[viewId]; addViewIntoMap(viewId, { ...currentView, ...update }); successCallback && successCallback(); }).catch(error => { failCallback && failCallback(error); }); }, [repoID, idViewMap, addViewIntoMap]); const moveView = useCallback(({ sourceViewId, sourceFolderId, targetViewId, targetFolderId }) => { if ( (!sourceViewId && !sourceFolderId) // must drag view or folder || (!targetViewId && !targetFolderId) // must move above to view/folder or move view into folder || (sourceViewId === targetViewId && sourceFolderId === targetFolderId) // not changed || (!sourceViewId && sourceFolderId && targetViewId && targetFolderId) // not allowed to drag folder into folder ) { return; } metadataAPI.moveView(repoID, sourceViewId, sourceFolderId, targetViewId, targetFolderId).then(res => { let newNavigation = [...navigation]; // remove folder/view from old position let updatedSourceNavList = null; let sourceId = null; if (sourceFolderId) { if (sourceViewId) { // drag view from folder const sourceFolder = newNavigation.find((folder) => folder._id === sourceFolderId); updatedSourceNavList = sourceFolder && sourceFolder.children; sourceId = sourceViewId; } else { // drag folder updatedSourceNavList = newNavigation; sourceId = sourceFolderId; } } else if (sourceViewId) { // drag view outer of folders updatedSourceNavList = newNavigation; sourceId = sourceViewId; } // invalid drag source if (!Array.isArray(updatedSourceNavList) || updatedSourceNavList.length === 0 || !sourceId) { return; } const movedNavIndex = updatedSourceNavList.findIndex((nav) => nav._id === sourceId); if (movedNavIndex < 0) { return; } const movedNav = updatedSourceNavList[movedNavIndex]; updatedSourceNavList.splice(movedNavIndex, 1); // insert folder/view into new position let updatedTargetNavList = newNavigation; if (targetFolderId && sourceViewId) { // move view into folder let targetFolder = newNavigation.find((folder) => folder._id === targetFolderId); if (!Array.isArray(targetFolder.children)) { targetFolder.children = []; } updatedTargetNavList = targetFolder.children; } let targetNavIndex = -1; if (targetViewId) { // move folder/view above to view targetNavIndex = updatedTargetNavList.findIndex((nav) => nav._id === targetViewId); } else if (!sourceViewId && targetFolderId) { // move folder above to folder targetNavIndex = updatedTargetNavList.findIndex((nav) => nav._id === targetFolderId); } if (targetNavIndex > -1) { updatedTargetNavList.splice(targetNavIndex, 0, movedNav); // move above to the target folder/view } else { updatedTargetNavList.push(movedNav); // move into navigation or folder } setNavigation(newNavigation); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); }); }, [repoID, navigation]); const updateEnableFaceRecognition = useCallback((newValue) => { if (newValue === enableFaceRecognition) return; if (newValue) { toaster.success(gettext('Recognizing portraits. Please refresh the page later.')); addView({ name: '_people', type: VIEW_TYPE.FACE_RECOGNITION }); } else { if (idViewMap[FACE_RECOGNITION_VIEW_ID]) { let isSelected = false; if (currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) { const currentViewId = currentPath.split('/').pop(); isSelected = currentViewId === FACE_RECOGNITION_VIEW_ID; } const folders = navigation.filter((nav) => nav.type === VIEWS_TYPE_FOLDER); const targetFolder = folders.find((folder) => { const { children } = folder; if (Array.isArray(children) && children.length > 0) { const view = children.find((viewNav) => viewNav._id === FACE_RECOGNITION_VIEW_ID); if (view) { return true; } } return false; }); const folderId = targetFolder ? targetFolder._id : null; deleteView({ folderId, viewId: FACE_RECOGNITION_VIEW_ID, isSelected }); } } setEnableFaceRecognition(newValue); }, [enableFaceRecognition, currentPath, idViewMap, navigation, addView, deleteView]); useEffect(() => { if (isLoading) return; if (isBeingBuilt) { const firstView = getFirstView(); if (firstView) { selectView(firstView); } return; } const { origin, pathname, search } = window.location; const urlParams = new URLSearchParams(search); if (!urlParams.has('view')) return; const viewID = urlParams.get('view'); if (viewID) { const lastOpenedView = idViewMap[viewID] || ''; if (lastOpenedView) { selectView(lastOpenedView); return; } const url = `${origin}${pathname}`; window.history.pushState({ url: url, path: '' }, '', url); } const firstView = getFirstView(); if (firstView) { selectView(firstView); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading, isBeingBuilt]); useEffect(() => { if (!currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) return; const currentViewId = currentPath.split('/').pop(); const currentView = idViewMap[currentViewId]; if (currentView) { document.title = `${currentView.name} - Seafile`; updateFavicon(currentView.type); return; } document.title = originalTitleRef.current; updateFavicon('default'); }, [currentPath, idViewMap]); return ( {children} ); }; export const useMetadata = () => { const context = useContext(MetadataContext); if (!context) { throw new Error('\'MetadataContext\' is null'); } return context; };