diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 617aed6684..fe87be8926 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@gatsbyjs/reach-router": "1.3.9", - "@seafile/react-image-lightbox": "3.0.1", + "@seafile/react-image-lightbox": "3.0.3", "@seafile/resumablejs": "1.1.16", "@seafile/sdoc-editor": "1.0.180", "@seafile/seafile-calendar": "0.0.28", @@ -4710,9 +4710,9 @@ "integrity": "sha512-UBXYfa0JvIRIdszECDCfK1w0vBS+TOrBC6X19x27kWoCzY3Q3OSbTkxmlLcYH4w346/rJXjjyK5nyKZYTgrZaA==" }, "node_modules/@seafile/react-image-lightbox": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@seafile/react-image-lightbox/-/react-image-lightbox-3.0.1.tgz", - "integrity": "sha512-GbofvFRYcdprxstC/kwJCnInFxS5lrs6CEYQ+8ecg/aOJfeIZpYFKzI2r4FB6avtrcdSz3QCwY5J1xjtBJ3BZw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@seafile/react-image-lightbox/-/react-image-lightbox-3.0.3.tgz", + "integrity": "sha512-VHSGO3wNLo6x8/ro4o1UWmYyuEJ8Gz0dxcL8nxw0XU4SJyDoeLB9rtQciZLu+pv9p6yY4nljcTL6riihF53LGA==", "dependencies": { "prop-types": "^15.8.1", "react-modal": "^3.16.1" @@ -4960,6 +4960,19 @@ "xtend": "4.0.2" } }, + "node_modules/@seafile/seafile-editor/node_modules/@seafile/react-image-lightbox": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@seafile/react-image-lightbox/-/react-image-lightbox-3.0.1.tgz", + "integrity": "sha512-GbofvFRYcdprxstC/kwJCnInFxS5lrs6CEYQ+8ecg/aOJfeIZpYFKzI2r4FB6avtrcdSz3QCwY5J1xjtBJ3BZw==", + "dependencies": { + "prop-types": "^15.8.1", + "react-modal": "^3.16.1" + }, + "peerDependencies": { + "react": "^16.x || ^17.x", + "react-dom": "^16.x || ^17.x" + } + }, "node_modules/@seafile/seafile-editor/node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 64924ecccf..5030ccc7c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@gatsbyjs/reach-router": "1.3.9", - "@seafile/react-image-lightbox": "3.0.1", + "@seafile/react-image-lightbox": "3.0.3", "@seafile/resumablejs": "1.1.16", "@seafile/sdoc-editor": "1.0.180", "@seafile/seafile-calendar": "0.0.28", diff --git a/frontend/src/components/dialog/image-dialog.js b/frontend/src/components/dialog/image-dialog.js index 3e8178d6e5..f1fb09f61b 100644 --- a/frontend/src/components/dialog/image-dialog.js +++ b/frontend/src/components/dialog/image-dialog.js @@ -1,10 +1,69 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { gettext } from '../../utils/constants'; import Lightbox from '@seafile/react-image-lightbox'; +import { useMetadataOperations } from '../../hooks/metadata-operation'; +import { SYSTEM_FOLDERS } from '../../constants'; + import '@seafile/react-image-lightbox/style.css'; -const propTypes = { +const ImageDialog = ({ enableRotate: oldEnableRotate, imageItems, imageIndex, closeImagePopup, moveToPrevImage, moveToNextImage, onDeleteImage, onRotateImage }) => { + const { onOCR } = useMetadataOperations(); + + const downloadImage = useCallback((url) => { + location.href = url; + }, []); + + const onViewOriginal = useCallback(() => { + window.open(imageItems[imageIndex].url, '_blank'); + }, [imageItems, imageIndex]); + + const imageItemsLength = imageItems.length; + if (imageItemsLength === 0) return null; + const name = imageItems[imageIndex].name; + const mainImg = imageItems[imageIndex]; + const nextImg = imageItems[(imageIndex + 1) % imageItemsLength]; + const prevImg = imageItems[(imageIndex + imageItemsLength - 1) % imageItemsLength]; + + // The backend server does not support rotating HEIC images + let enableRotate = oldEnableRotate; + const suffix = mainImg.src.slice(mainImg.src.lastIndexOf('.') + 1, mainImg.src.lastIndexOf('?')).toLowerCase(); + if (suffix === 'heic') { + enableRotate = false; + } + + const isSystemFolder = SYSTEM_FOLDERS.find(folderPath => mainImg.parentDir.startsWith(folderPath)); + + return ( + downloadImage(imageItems[imageIndex].downloadURL)} + onClickDelete={onDeleteImage ? () => onDeleteImage(name) : null} + onViewOriginal={onViewOriginal} + viewOriginalImageLabel={gettext('View original image')} + onRotateImage={(onRotateImage && enableRotate) ? (angle) => onRotateImage(imageIndex, angle) : null} + onOCR={onOCR && !isSystemFolder ? () => onOCR(mainImg.parentDir, mainImg.name) : null} + OCRLabel={gettext('OCR')} + /> + ); +}; + +ImageDialog.propTypes = { imageItems: PropTypes.array.isRequired, imageIndex: PropTypes.number.isRequired, closeImagePopup: PropTypes.func.isRequired, @@ -15,60 +74,6 @@ const propTypes = { enableRotate: PropTypes.bool, }; -class ImageDialog extends React.Component { - - downloadImage = (url) => { - location.href = url; - }; - - onViewOriginal = () => { - window.open(this.props.imageItems[this.props.imageIndex].url, '_blank'); - }; - - render() { - const { imageItems, imageIndex, closeImagePopup, moveToPrevImage, moveToNextImage, onDeleteImage, onRotateImage } = this.props; - const imageItemsLength = imageItems.length; - if (imageItemsLength === 0) return null; - const name = imageItems[imageIndex].name; - const mainImg = imageItems[imageIndex]; - const nextImg = imageItems[(imageIndex + 1) % imageItemsLength]; - const prevImg = imageItems[(imageIndex + imageItemsLength - 1) % imageItemsLength]; - // The backend server does not support rotating HEIC images - let enableRotate = this.props.enableRotate; - const suffix = mainImg.src.slice(mainImg.src.lastIndexOf('.') + 1, mainImg.src.lastIndexOf('?')).toLowerCase(); - if (suffix === 'heic') { - enableRotate = false; - } - return ( - this.downloadImage(imageItems[imageIndex].downloadURL)} - onClickDelete={onDeleteImage ? () => onDeleteImage(name) : null} - onViewOriginal={this.onViewOriginal} - viewOriginalImageLabel={gettext('View original image')} - onRotateImage={(onRotateImage && enableRotate) ? (angle) => onRotateImage(imageIndex, angle) : null} - /> - ); - } -} - -ImageDialog.propTypes = propTypes; - ImageDialog.defaultProps = { enableRotate: true, }; diff --git a/frontend/src/components/dir-view-mode/dir-files.js b/frontend/src/components/dir-view-mode/dir-files.js index 1008c21ad7..c128a1e182 100644 --- a/frontend/src/components/dir-view-mode/dir-files.js +++ b/frontend/src/components/dir-view-mode/dir-files.js @@ -253,6 +253,7 @@ class DirFiles extends React.Component { } return { name, + parentDir: node.parentNode.path, src, thumbnail, 'url': `${siteRoot}lib/${repoID}/file${path}`, diff --git a/frontend/src/components/dirent-grid-view/dirent-grid-view.js b/frontend/src/components/dirent-grid-view/dirent-grid-view.js index d86ae67b80..bb3d0bc10e 100644 --- a/frontend/src/components/dirent-grid-view/dirent-grid-view.js +++ b/frontend/src/components/dirent-grid-view/dirent-grid-view.js @@ -581,10 +581,10 @@ class DirentGridView extends React.Component { }; prepareImageItem = (item) => { + const { path: parentDir, repoID, currentRepoInfo } = this.props; const name = item.name; - const repoID = this.props.repoID; - const repoEncrypted = this.props.currentRepoInfo.encrypted; - const path = Utils.encodePath(Utils.joinPath(this.props.path, name)); + const repoEncrypted = currentRepoInfo.encrypted; + const path = Utils.encodePath(Utils.joinPath(parentDir, name)); const cacheBuster = new Date().getTime(); const fileExt = name.substr(name.lastIndexOf('.') + 1).toLowerCase(); @@ -608,6 +608,7 @@ class DirentGridView extends React.Component { name, thumbnail, src, + parentDir, 'url': `${siteRoot}lib/${repoID}/file${path}`, 'downloadURL': `${fileServerRoot}repos/${repoID}/files${path}?op=download`, }; diff --git a/frontend/src/components/dirent-list-view/dirent-list-view.js b/frontend/src/components/dirent-list-view/dirent-list-view.js index 415f7bbaaa..2e962b6039 100644 --- a/frontend/src/components/dirent-list-view/dirent-list-view.js +++ b/frontend/src/components/dirent-list-view/dirent-list-view.js @@ -190,10 +190,10 @@ class DirentListView extends React.Component { // for image popup prepareImageItem = (item) => { + const { path: parentDir, repoID, currentRepoInfo } = this.props; const name = item.name; - const repoID = this.props.repoID; - const repoEncrypted = this.props.currentRepoInfo.encrypted; - const path = Utils.encodePath(Utils.joinPath(this.props.path, name)); + const repoEncrypted = currentRepoInfo.encrypted; + const path = Utils.encodePath(Utils.joinPath(parentDir, name)); const fileExt = name.substr(name.lastIndexOf('.') + 1).toLowerCase(); let thumbnail = ''; @@ -216,6 +216,7 @@ class DirentListView extends React.Component { name, thumbnail, src, + parentDir, 'url': `${siteRoot}lib/${repoID}/file${path}`, 'downloadURL': `${fileServerRoot}repos/${repoID}/files${path}/?op=download` }; diff --git a/frontend/src/hooks/metadata-operation.js b/frontend/src/hooks/metadata-operation.js new file mode 100644 index 0000000000..a4b98fb6fe --- /dev/null +++ b/frontend/src/hooks/metadata-operation.js @@ -0,0 +1,54 @@ +import React, { useContext, useCallback, useMemo } from 'react'; +import metadataAPI from '../metadata/api'; +import { Utils } from '../utils/utils'; +import toaster from '../components/toast'; +import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../metadata/constants'; +import { gettext } from '../utils/constants'; + +// This hook provides content related to metadata record operation +const MetadataOperationsContext = React.createContext(null); + +export const MetadataOperationsProvider = ({ repoID, enableMetadata, enableOCR, repoInfo, children }) => { + const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]); + const canModify = useMemo(() => permission === 'rw', [permission]); + + const onOCR = useCallback((parentDir, fileName) => { + const filePath = Utils.joinPath(parentDir, fileName); + metadataAPI.ocr(repoID, filePath).then(res => { + const ocrResult = res.data.ocr_result; + const validResult = Array.isArray(ocrResult) && ocrResult.length > 0 ? JSON.stringify(ocrResult) : null; + toaster.success(gettext('Successfully OCR')); + if (validResult) { + const update = { [PRIVATE_COLUMN_KEY.OCR]: validResult }; + metadataAPI.modifyRecord(repoID, { parentDir, fileName }, update).then(res => { + const eventBus = window?.sfMetadataContext?.eventBus; + if (eventBus) { + eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { parentDir, fileName }, update); + } + }); + } + }).catch(error => { + const errorMessage = Utils.getErrorMsg(error); + toaster.danger(errorMessage); + }); + }, [repoID]); + + let value = {}; + if (canModify && enableMetadata && enableOCR) { + value['onOCR'] = onOCR; + } + + return ( + + {children} + + ); +}; + +export const useMetadataOperations = () => { + const context = useContext(MetadataOperationsContext); + if (!context) { + throw new Error('\'MetadataOperationsContext\' is null'); + } + return context; +}; diff --git a/frontend/src/hooks/metadata-status.js b/frontend/src/hooks/metadata-status.js index 163c818fa1..6c9181b797 100644 --- a/frontend/src/hooks/metadata-status.js +++ b/frontend/src/hooks/metadata-status.js @@ -2,9 +2,10 @@ import React, { useContext, useEffect, useCallback, useState, useMemo } from 're import metadataAPI from '../metadata/api'; import { Utils } from '../utils/utils'; import toaster from '../components/toast'; +import { MetadataOperationsProvider } from './metadata-operation'; // This hook provides content related to seahub interaction, such as whether to enable extended attributes -const EnableMetadataContext = React.createContext(null); +const MetadataStatusContext = React.createContext(null); export const MetadataStatusProvider = ({ repoID, currentRepoInfo, hideMetadataView, children }) => { const enableMetadataManagement = useMemo(() => { @@ -101,7 +102,7 @@ export const MetadataStatusProvider = ({ repoID, currentRepoInfo, hideMetadataVi }, [repoID, detailsSettings]); return ( - - {!isLoading && children} - + {!isLoading && ( + + {children} + + )} + ); }; export const useMetadataStatus = () => { - const context = useContext(EnableMetadataContext); + const context = useContext(MetadataStatusContext); if (!context) { - throw new Error('\'EnableMetadataContext\' is null'); + throw new Error('\'MetadataStatusContext\' is null'); } return context; }; diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js index 0ce13ab0d0..d82a82c251 100644 --- a/frontend/src/metadata/api.js +++ b/frontend/src/metadata/api.js @@ -76,24 +76,6 @@ class MetadataManagerAPI { return this.req.get(url, { params: params }); } - getMetadataRecordInfo(repoID, parentDir, name) { - const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/'; - let params = {}; - if (parentDir) { - params['parent_dir'] = parentDir; - } - if (name) { - params['name'] = name; - } - return this.req.get(url, { params: params }); - } - - modifyRecord = (repoID, recordID, update, objID) => { - const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/'; - const data = { records_data: [{ record_id: recordID, record: update, obj_id: objID }] }; - return this.req.put(url, data); - }; - modifyRecords = (repoID, recordsData, is_copy_paste = false) => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/'; let data = { records_data: recordsData }; @@ -103,6 +85,40 @@ class MetadataManagerAPI { return this.req.put(url, data); }; + getRecord(repoID, { recordId, parentDir, fileName }) { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/'; + let params = {}; + if (recordId) { + params['record_id'] = recordId; + } else { + if (parentDir) { + params['parent_dir'] = parentDir; + } + if (fileName) { + params['file_name'] = fileName; + } + } + return this.req.get(url, { params: params }); + } + + modifyRecord(repoID, { recordId, parentDir, fileName }, updateData) { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/'; + let data = { + 'data': updateData + }; + if (recordId) { + data['record_id'] = recordId; + } else { + if (parentDir) { + data['parent_dir'] = parentDir; + } + if (fileName) { + data['file_name'] = fileName; + } + } + return this.req.put(url, data); + } + listUserInfo = (userIds) => { const url = this.server + '/api/v2.1/user-list/'; const params = { user_id_list: userIds }; diff --git a/frontend/src/metadata/components/cell-formatter/image-previewer.js b/frontend/src/metadata/components/cell-formatter/image-previewer.js index 4b7c90d1cb..fc0d24cc8e 100644 --- a/frontend/src/metadata/components/cell-formatter/image-previewer.js +++ b/frontend/src/metadata/components/cell-formatter/image-previewer.js @@ -32,6 +32,7 @@ const ImagePreviewer = ({ record, table, repoID, repoInfo, closeImagePopup }) => url: `${siteRoot}lib/${repoID}/file${path}`, thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`, src: src, + parentDir, downloadURL: `${fileServerRoot}repos/${repoID}/files${path}/?op=download`, rawPath: Utils.joinPath(parentDir, fileName), }; diff --git a/frontend/src/metadata/context.js b/frontend/src/metadata/context.js index ba03f85045..3c2cdd9dc2 100644 --- a/frontend/src/metadata/context.js +++ b/frontend/src/metadata/context.js @@ -86,11 +86,6 @@ class Context { return this.metadataAPI.getMetadata(repoID, params); }; - getRecord = (parentDir, fileName) => { - const repoID = this.settings['repoID']; - return this.metadataAPI.getMetadataRecordInfo(repoID, parentDir, fileName); - }; - getViews = () => { const repoID = this.settings['repoID']; return this.metadataAPI.listViews(repoID); @@ -203,8 +198,8 @@ class Context { }; // record - modifyRecord = (repoId, recordId, objID, update) => { - return this.metadataAPI.modifyRecord(repoId, recordId, objID, update); + modifyRecord = (repoId, recordId, update) => { + return this.metadataAPI.modifyRecord(repoId, { recordId }, update); }; modifyRecords = (repoId, recordsData, isCopyPaste) => { diff --git a/frontend/src/metadata/hooks/metadata-details.js b/frontend/src/metadata/hooks/metadata-details.js index 6395f4ac27..1ad4967b2c 100644 --- a/frontend/src/metadata/hooks/metadata-details.js +++ b/frontend/src/metadata/hooks/metadata-details.js @@ -7,9 +7,7 @@ import { SYSTEM_FOLDERS } from '../../constants'; import Column from '../model/column'; import { normalizeFields } from '../components/metadata-details/utils'; import { CellType, EVENT_BUS_TYPE, PREDEFINED_COLUMN_KEYS, PRIVATE_COLUMN_KEY } from '../constants'; -import { getCellValueByColumn, getOptionName, getColumnOptionNamesByIds, getColumnOptionNameById, getRecordIdFromRecord, - getFileObjIdFromRecord -} from '../utils/cell'; +import { getCellValueByColumn, getOptionName, getColumnOptionNamesByIds, getColumnOptionNameById, getRecordIdFromRecord } from '../utils/cell'; import tagsAPI from '../../tag/api'; import { getColumnByKey, getColumnOptions, getColumnOriginName } from '../utils/column'; import ObjectUtils from '../utils/object-utils'; @@ -59,19 +57,18 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent const onChange = useCallback((fieldKey, newValue) => { const field = getColumnByKey(originColumns, fieldKey); - const fileName = getColumnOriginName(field); + const columnName = getColumnOriginName(field); const recordId = getRecordIdFromRecord(record); - const fileObjId = getFileObjIdFromRecord(record); - let update = { [fileName]: newValue }; + let update = { [columnName]: newValue }; if (field.type === CellType.SINGLE_SELECT) { - update = { [fileName]: getColumnOptionNameById(field, newValue) }; + update = { [columnName]: getColumnOptionNameById(field, newValue) }; } else if (field.type === CellType.MULTIPLE_SELECT) { - update = { [fileName]: newValue ? getColumnOptionNamesByIds(field, newValue) : [] }; + update = { [columnName]: newValue ? getColumnOptionNamesByIds(field, newValue) : [] }; } - metadataAPI.modifyRecord(repoID, recordId, update, fileObjId).then(res => { + metadataAPI.modifyRecord(repoID, { recordId }, update).then(res => { setRecord({ ...record, ...update }); if (window?.sfMetadataContext?.eventBus) { - window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, recordId, update); + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { recordId }, update); } }).catch(error => { const errorMsg = Utils.getErrorMsg(error); @@ -98,7 +95,7 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent const oldValue = getCellValueByColumn(record, newField) || []; update = { [fileName]: [...oldValue, newOption.name] }; } - return metadataAPI.modifyRecord(repoID, record._id, update, record._obj_id); + return metadataAPI.modifyRecord(repoID, { recordId: record._id }, update); }).then(res => { setOriginColumns(newColumns); setRecord({ ...record, ...update }); @@ -116,7 +113,7 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent const update = { [PRIVATE_COLUMN_KEY.TAGS]: newValue }; setRecord({ ...record, ...update }); if (window?.sfMetadataContext?.eventBus) { - window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, record_id, update); + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { recordId: record_id }, update); } }).catch(error => { const errorMsg = Utils.getErrorMsg(error); @@ -164,7 +161,7 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent if (!parentDir.startsWith('/')) { parentDir = '/' + parentDir; } - metadataAPI.getMetadataRecordInfo(repoID, parentDir, fileName).then(res => { + metadataAPI.getRecord(repoID, { parentDir, fileName }).then(res => { const { results, metadata } = res.data; const record = Array.isArray(results) && results.length > 0 ? results[0] : {}; const columns = normalizeFields(metadata).map(field => new Column(field)); diff --git a/frontend/src/metadata/hooks/metadata-view.js b/frontend/src/metadata/hooks/metadata-view.js index 74e3a51c61..f14dd8ae4c 100644 --- a/frontend/src/metadata/hooks/metadata-view.js +++ b/frontend/src/metadata/hooks/metadata-view.js @@ -86,8 +86,8 @@ export const MetadataViewProvider = ({ storeRef.current.modifySettings(settings); }, [storeRef]); - const updateLocalRecord = useCallback((recordId, update) => { - storeRef.current.modifyLocalRecord(recordId, update); + const updateLocalRecord = useCallback(({ recordId, parentDir, fileName }, update) => { + storeRef.current.modifyLocalRecord({ record_id: recordId, parent_dir: parentDir, file_name: fileName }, update); }, [storeRef]); const modifyRecords = (rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, isCopyPaste = false, { success_callback, fail_callback } = {}) => { diff --git a/frontend/src/metadata/store/index.js b/frontend/src/metadata/store/index.js index 7b04c8efdc..c17a844aa8 100644 --- a/frontend/src/metadata/store/index.js +++ b/frontend/src/metadata/store/index.js @@ -394,11 +394,13 @@ class Store { this.applyOperation(operation); } - modifyLocalRecord(row_id, updates) { + modifyLocalRecord({ parent_dir, file_name, record_id }, updates) { const type = OPERATION_TYPE.MODIFY_LOCAL_RECORD; const operation = this.createOperation({ type, - row_id, + row_id: record_id, + parent_dir, + file_name, repo_id: this.repoId, updates }); diff --git a/frontend/src/metadata/store/operations/apply.js b/frontend/src/metadata/store/operations/apply.js index ee43e4eedf..fcf68b87d2 100644 --- a/frontend/src/metadata/store/operations/apply.js +++ b/frontend/src/metadata/store/operations/apply.js @@ -5,7 +5,7 @@ import { OPERATION_TYPE } from './constants'; import Column from '../../model/column'; import View from '../../model/metadata/view'; import { getColumnOriginName } from '../../utils/column'; -import { getRecordIdFromRecord } from '../../utils/cell'; +import { getFileNameFromRecord, getParentDirFromRecord, getRecordIdFromRecord } from '../../utils/cell'; dayjs.extend(utc); @@ -106,14 +106,16 @@ export default function apply(data, operation) { return data; } case OPERATION_TYPE.MODIFY_LOCAL_RECORD: { - const { row_id, updates } = operation; + const { row_id, parent_dir = '', file_name = '', updates } = operation; const { rows } = data; const modifyTime = dayjs().utc().format(UTC_FORMAT_DEFAULT); const modifier = window.sfMetadataContext.getUsername(); let updatedRows = [...rows]; rows.forEach((row, index) => { - const { _id: rowId } = row; - if (rowId === row_id && updates) { + const rowId = getRecordIdFromRecord(row); + const parentDir = getParentDirFromRecord(row); + const fileName = getFileNameFromRecord(row); + if ((rowId === row_id || (parentDir === parent_dir && fileName === file_name)) && updates) { const updatedRow = Object.assign({}, row, updates, { '_mtime': modifyTime, '_last_modifier': modifier, diff --git a/frontend/src/metadata/store/operations/constants.js b/frontend/src/metadata/store/operations/constants.js index ca25214fd5..fbf8de22e7 100644 --- a/frontend/src/metadata/store/operations/constants.js +++ b/frontend/src/metadata/store/operations/constants.js @@ -48,6 +48,7 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'], [OPERATION_TYPE.MOVE_RECORD]: ['repo_id', 'row_id', 'target_repo_id', 'dirent', 'target_parent_path', 'source_parent_path', 'update_data'], [OPERATION_TYPE.DUPLICATE_RECORD]: ['repo_id', 'row_id', 'target_repo_id', 'dirent', 'target_parent_path', 'source_parent_path'], + [OPERATION_TYPE.MODIFY_LOCAL_RECORD]: ['repo_id', 'row_id', 'parent_dir', 'file_name', 'updates'], [OPERATION_TYPE.MODIFY_FILTERS]: ['repo_id', 'view_id', 'filter_conjunction', 'filters', 'basic_filters'], [OPERATION_TYPE.MODIFY_SORTS]: ['repo_id', 'view_id', 'sorts'], @@ -64,7 +65,7 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.RENAME_PEOPLE_NAME]: ['repo_id', 'people_id', 'new_name', 'old_name'], [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.UPDATE_FILE_TAGS]: ['repo_id', 'file_tags_data'], }; diff --git a/frontend/src/metadata/views/gallery/main.js b/frontend/src/metadata/views/gallery/main.js index c9bd812083..0c5fada3b7 100644 --- a/frontend/src/metadata/views/gallery/main.js +++ b/frontend/src/metadata/views/gallery/main.js @@ -73,7 +73,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord, const img = { id, name: fileName, - path: parentDir, + parentDir, url: `${siteRoot}lib/${repoID}/file${path}`, src: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForGrid}${path}`, thumbnail: `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`, @@ -232,7 +232,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord, updateCurrentDirent({ type: 'file', name: image.name, - path: image.path, + path: image.parentDir, file_tags: [] }); }, [metadata, updateCurrentDirent]); diff --git a/frontend/src/metadata/views/table/context-menu/index.js b/frontend/src/metadata/views/table/context-menu/index.js index 4fd6c0cc33..46d7dc2242 100644 --- a/frontend/src/metadata/views/table/context-menu/index.js +++ b/frontend/src/metadata/views/table/context-menu/index.js @@ -287,13 +287,16 @@ const ContextMenu = ({ if (path === '') return; window.sfMetadataContext.ocr(path).then(res => { const ocrResult = res.data.ocr_result; - const updateRecordId = record[PRIVATE_COLUMN_KEY.ID]; - const recordIds = [updateRecordId]; - let idRecordUpdates = {}; - let idOriginalRecordUpdates = {}; - idRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null }; - idOriginalRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null }; - updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); + const validResult = Array.isArray(ocrResult) && ocrResult.length > 0 ? JSON.stringify(ocrResult) : null; + if (validResult) { + const updateRecordId = record[PRIVATE_COLUMN_KEY.ID]; + const recordIds = [updateRecordId]; + let idRecordUpdates = {}; + let idOriginalRecordUpdates = {}; + idRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null }; + idOriginalRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null }; + updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); + } }).catch(error => { const errorMessage = gettext('OCR failed'); toaster.danger(errorMessage); diff --git a/frontend/src/shared-dir-view.js b/frontend/src/shared-dir-view.js index 611ad9b69b..4fbebf6c06 100644 --- a/frontend/src/shared-dir-view.js +++ b/frontend/src/shared-dir-view.js @@ -20,6 +20,7 @@ import CopyMoveDirentProgressDialog from './components/dialog/copy-move-dirent-p import RepoInfoBar from './components/repo-info-bar'; import RepoTag from './models/repo-tag'; import { GRID_MODE, LIST_MODE } from './components/dir-view-mode/constants'; +import { MetadataOperationsProvider } from './hooks/metadata-operation'; import './css/shared-dir-view.css'; import './css/grid-view.css'; @@ -327,6 +328,7 @@ class SharedDirView extends React.Component { return { 'name': name, 'url': fileURL, + 'parentDir': item.file_path.slice(0, item.file_path.indexOf(name)), 'thumbnail': `${siteRoot}thumbnail/${token}/${thumbnailSizeForOriginal}${Utils.encodePath(item.file_path)}`, 'src': src, 'downloadURL': fileURL + '&dl=1', @@ -439,7 +441,7 @@ class SharedDirView extends React.Component { const isDesktop = Utils.isDesktop(); const modeBaseClass = 'btn btn-secondary btn-icon sf-view-mode-btn'; return ( - +
@@ -579,7 +581,7 @@ class SharedDirView extends React.Component { /> } - + ); } } diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py index 90a9e0081a..57a88f8f8d 100644 --- a/seahub/repo_metadata/apis.py +++ b/seahub/repo_metadata/apis.py @@ -16,9 +16,9 @@ from seahub.views import check_folder_permission from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id, init_metadata, \ get_unmodifiable_columns, can_read_metadata, init_faces, \ extract_file_details, get_someone_similar_faces, remove_faces_table, FACES_SAVE_PATH, \ - init_tags, init_tag_self_link_columns, remove_tags_table, add_init_face_recognition_task, init_ocr, remove_ocr_column + init_tags, init_tag_self_link_columns, remove_tags_table, add_init_face_recognition_task, init_ocr, \ + remove_ocr_column, get_update_record 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 from seaserv import seafile_api from seahub.repo_metadata.constants import FACE_RECOGNITION_VIEW_ID @@ -431,33 +431,12 @@ class MetadataRecords(APIView): rows = [] for record in results: - record_id = record.get('_id') to_updated_record = record_id_to_record.get(record_id) - update = { - METADATA_TABLE.columns.id.name: record_id, - } - column_name, value = list(to_updated_record.items())[0] - if column_name not in unmodifiable_column_names: - try: - column = next(column for column in columns if column['name'] == column_name) - if value and column['type'] == 'date': - column_data = column.get('data', {}) - format = column_data.get('format', 'YYYY-MM-DD') - saved_format = '%Y-%m-%d' - if 'HH:mm:ss' in format: - saved_format = '%Y-%m-%d %H:%M:%S' - elif 'HH:mm' in format: - saved_format = '%Y-%m-%d %H:%M' - - datetime_obj = datetime.strptime(value, saved_format) - update[column_name] = datetime_to_isoformat_timestr(datetime_obj) - elif column['type'] == 'single-select' and not value: - update[column_name] = None - else: - update[column_name] = value - rows.append(update) - except Exception as e: - pass + update = get_update_record(to_updated_record, columns, unmodifiable_column_names) + if update: + record_id = record.get('_id') + update[METADATA_TABLE.columns.id.name] = record_id + rows.append(update) if rows: try: metadata_server_api.update_rows(METADATA_TABLE.id, rows) @@ -468,21 +447,23 @@ class MetadataRecords(APIView): return Response({'success': True}) -class MetadataRecordInfo(APIView): +class MetadataRecord(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated,) throttle_classes = (UserRateThrottle,) def get(self, request, repo_id): + record_id = request.GET.get('record_id') parent_dir = request.GET.get('parent_dir') - name = request.GET.get('name') - if not parent_dir: - error_msg = 'parent_dir invalid' - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + file_name = request.GET.get('file_name') + if not record_id: + if not parent_dir: + error_msg = 'parent_dir invalid' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) - if not name: - error_msg = 'name invalid' - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + if not file_name: + error_msg = 'file_name 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: @@ -502,9 +483,13 @@ class MetadataRecordInfo(APIView): 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}`=?;' - parameters = [parent_dir, name] + sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}`=?;' + parameters = [record_id] + + if not record_id: + sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE \ + `{METADATA_TABLE.columns.parent_dir.name}`=? AND `{METADATA_TABLE.columns.file_name.name}`=?;' + parameters = [parent_dir, file_name] try: query_result = metadata_server_api.query_rows(sql, parameters) @@ -521,6 +506,90 @@ class MetadataRecordInfo(APIView): return Response(query_result) + def put(self, request, repo_id): + record_id = request.data.get('record_id') + parent_dir = request.data.get('parent_dir') + file_name = request.data.get('file_name') + if not record_id: + if not parent_dir: + error_msg = 'parent_dir invalid' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not file_name: + error_msg = 'file_name invalid' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + update_data = request.data.get('data') + if not update_data: + error_msg = '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) + + if not can_read_metadata(request, repo_id): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = f'Library {repo_id} not found.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + metadata_server_api = MetadataServerAPI(repo_id, request.user.username) + + from seafevents.repo_metadata.constants import METADATA_TABLE + try: + columns_data = metadata_server_api.list_columns(METADATA_TABLE.id) + columns = columns_data.get('columns', []) + except Exception as e: + logger.exception(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE `{METADATA_TABLE.columns.id.name}`=?;' + parameters = [record_id] + + if not record_id: + sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE \ + `{METADATA_TABLE.columns.parent_dir.name}`=? AND `{METADATA_TABLE.columns.file_name.name}`=?;' + parameters = [parent_dir, file_name] + + try: + query_result = metadata_server_api.query_rows(sql, parameters) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + rows = query_result.get('results') + + if not rows: + error_msg = 'Record not found' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + unmodifiable_column_names = [column.get('name') for column in get_unmodifiable_columns()] + + record = rows[0] + update_record = get_update_record(update_data, columns, unmodifiable_column_names) + if not update_record: + return Response({'success': True}) + + record_id = record.get('_id') + update_record[METADATA_TABLE.columns.id.name] = record_id + update_records = [update_record] + if update_records: + try: + metadata_server_api.update_rows(METADATA_TABLE.id, update_records) + 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 MetadataColumns(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py index e96c7d5a06..571f4474c1 100644 --- a/seahub/repo_metadata/urls.py +++ b/seahub/repo_metadata/urls.py @@ -1,5 +1,5 @@ from django.urls import re_path -from .apis import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \ +from .apis import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecord, \ MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataDetailsSettingsView, MetadataOCRManageView @@ -7,7 +7,7 @@ from .apis import MetadataRecords, MetadataManage, MetadataColumns, MetadataReco 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'^record/$', MetadataRecord.as_view(), name='api-v2.1-metadata-record-info'), re_path(r'^columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'), # view diff --git a/seahub/repo_metadata/utils.py b/seahub/repo_metadata/utils.py index 2f90f63e4c..ac48127e9c 100644 --- a/seahub/repo_metadata/utils.py +++ b/seahub/repo_metadata/utils.py @@ -4,9 +4,11 @@ import requests import json import random from urllib.parse import urljoin +from datetime import datetime from seahub.settings import SECRET_KEY, SEAFEVENTS_SERVER_URL from seahub.views import check_folder_permission +from seahub.utils.timeutils import datetime_to_isoformat_timestr from seaserv import seafile_api @@ -293,3 +295,40 @@ def can_read_metadata(request, repo_id): if permission: return True return False + + +def get_column_valid_value(column, value): + from seafevents.repo_metadata.constants import PropertyTypes + if value and column['type'] == PropertyTypes.DATE: + column_data = column.get('data', {}) + format = column_data.get('format', 'YYYY-MM-DD') + saved_format = '%Y-%m-%d' + if 'HH:mm:ss' in format: + saved_format = '%Y-%m-%d %H:%M:%S' + elif 'HH:mm' in format: + saved_format = '%Y-%m-%d %H:%M' + + datetime_obj = datetime.strptime(value, saved_format) + return datetime_to_isoformat_timestr(datetime_obj) + + if column['type'] == PropertyTypes.SINGLE_SELECT and not value: + return None + + return value + + +def get_update_record(update={}, columns=[], unmodifiable_column_names=[]): + if not update: + return None + + update_record = {} + for column_name, value in update.items(): + if column_name not in unmodifiable_column_names: + try: + column = next(column for column in columns if column['name'] == column_name) + valid_value = get_column_valid_value(column, value) + update_record[column_name] = valid_value + except Exception as e: + pass + + return update_record