diff --git a/frontend/src/assets/icons/ai.svg b/frontend/src/assets/icons/ai.svg new file mode 100644 index 0000000000..783e1867e8 --- /dev/null +++ b/frontend/src/assets/icons/ai.svg @@ -0,0 +1,21 @@ + + + + +ai + + + + + + diff --git a/frontend/src/components/dialog/image-dialog.js b/frontend/src/components/dialog/image-dialog.js index f1fb09f61b..d3ee19a5cf 100644 --- a/frontend/src/components/dialog/image-dialog.js +++ b/frontend/src/components/dialog/image-dialog.js @@ -2,13 +2,13 @@ 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 { useMetadataAIOperations } from '../../hooks/metadata-ai-operation'; import { SYSTEM_FOLDERS } from '../../constants'; import '@seafile/react-image-lightbox/style.css'; const ImageDialog = ({ enableRotate: oldEnableRotate, imageItems, imageIndex, closeImagePopup, moveToPrevImage, moveToNextImage, onDeleteImage, onRotateImage }) => { - const { onOCR } = useMetadataOperations(); + const { enableOCR, enableMetadata, canModify, onOCR: onOCRAPI, OCRSuccessCallBack } = useMetadataAIOperations(); const downloadImage = useCallback((url) => { location.href = url; @@ -33,6 +33,10 @@ const ImageDialog = ({ enableRotate: oldEnableRotate, imageItems, imageIndex, cl } const isSystemFolder = SYSTEM_FOLDERS.find(folderPath => mainImg.parentDir.startsWith(folderPath)); + let onOCR = null; + if (enableOCR && enableMetadata && canModify && !isSystemFolder) { + onOCR = () => onOCRAPI({ parentDir: mainImg.parentDir, fileName: mainImg.name }, { success_callback: OCRSuccessCallBack }); + } return ( onRotateImage(imageIndex, angle) : null} - onOCR={onOCR && !isSystemFolder ? () => onOCR(mainImg.parentDir, mainImg.name) : null} + onOCR={onOCR} OCRLabel={gettext('OCR')} /> ); diff --git a/frontend/src/components/dirent-detail/dirent-details/index.js b/frontend/src/components/dirent-detail/dirent-details/index.js index fd06d91995..23e3ffe71f 100644 --- a/frontend/src/components/dirent-detail/dirent-details/index.js +++ b/frontend/src/components/dirent-detail/dirent-details/index.js @@ -10,7 +10,7 @@ import DirDetails from './dir-details'; import FileDetails from './file-details'; import ObjectUtils from '../../../metadata/utils/object-utils'; import { MetadataDetailsProvider } from '../../../metadata/hooks'; -import Settings from '../../../metadata/components/metadata-details/settings'; +import { Settings, AI } from '../../../metadata/components/metadata-details'; import { getDirentPath } from './utils'; import './index.css'; @@ -129,6 +129,7 @@ class DirentDetails extends React.Component { > + diff --git a/frontend/src/components/dirent-detail/embedded-file-details/index.js b/frontend/src/components/dirent-detail/embedded-file-details/index.js index ea94d317f6..26dcdc6d4f 100644 --- a/frontend/src/components/dirent-detail/embedded-file-details/index.js +++ b/frontend/src/components/dirent-detail/embedded-file-details/index.js @@ -8,7 +8,7 @@ import { Header, Body } from '../detail'; import FileDetails from './file-details'; import { MetadataContext } from '../../../metadata'; import { MetadataDetailsProvider } from '../../../metadata/hooks'; -import Settings from '../../../metadata/components/metadata-details/settings'; +import { AI, Settings } from '../../../metadata/components/metadata-details'; import './index.css'; @@ -54,6 +54,7 @@ const EmbeddedFileDetails = ({ repoID, repoInfo, dirent, path, onClose, width = style={{ width }} > + diff --git a/frontend/src/hooks/metadata-ai-operation.js b/frontend/src/hooks/metadata-ai-operation.js new file mode 100644 index 0000000000..3b540f99a3 --- /dev/null +++ b/frontend/src/hooks/metadata-ai-operation.js @@ -0,0 +1,109 @@ +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, lang } from '../utils/constants'; + +// This hook provides content related to metadata ai operation +const MetadataAIOperationsContext = React.createContext(null); + +export const MetadataAIOperationsProvider = ({ + repoID, + enableMetadata = false, + enableOCR = false, + enableTags = false, + tagsLang, + repoInfo, + children +}) => { + const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]); + const canModify = useMemo(() => permission === 'rw', [permission]); + + const OCRSuccessCallBack = useCallback(({ parentDir, fileName, ocrResult } = {}) => { + toaster.success(gettext('Successfully OCR')); + if (!ocrResult) return; + const update = { [PRIVATE_COLUMN_KEY.OCR]: ocrResult }; + metadataAPI.modifyRecord(repoID, { parentDir, fileName }, update).then(res => { + const eventBus = window?.sfMetadataContext?.eventBus; + eventBus && eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { parentDir, fileName }, update); + }); + }, [repoID]); + + const onOCR = useCallback(({ parentDir, fileName }, { success_callback, fail_callback } = {}) => { + 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; + success_callback && success_callback({ parentDir, fileName, ocrResult: validResult }); + }).catch(error => { + const errorMessage = gettext('OCR failed'); + toaster.danger(errorMessage); + fail_callback && fail_callback(); + }); + }, [repoID]); + + const generateDescription = useCallback(({ parentDir, fileName }, { success_callback, fail_callback } = {}) => { + const filePath = Utils.joinPath(parentDir, fileName); + const isImage = Utils.imageCheck(fileName); + let APIName = ''; + if (Utils.isDescriptionSupportedFile(fileName)) { + APIName = isImage ? 'imageCaption' : 'generateDescription'; + } + if (!APIName) return; + + metadataAPI[APIName](repoID, filePath, lang).then(res => { + const description = res?.data?.summary || res.data.desc || ''; + success_callback && success_callback({ parentDir, fileName, description }); + }).catch(error => { + const errorMessage = isImage ? gettext('Failed to generate image description') : gettext('Failed to generate description'); + toaster.danger(errorMessage); + fail_callback && fail_callback(); + }); + }, [repoID]); + + const extractFilesDetails = useCallback((objIds, { success_callback, fail_callback } = {}) => { + metadataAPI.extractFileDetails(repoID, objIds).then(res => { + const details = res?.data?.details || []; + success_callback && success_callback({ details }); + }).catch(error => { + const errorMessage = gettext('Failed to extract file details'); + toaster.danger(errorMessage); + fail_callback && fail_callback(); + }); + }, [repoID]); + + const extractFileDetails = useCallback((objId, { success_callback, fail_callback } = {}) => { + extractFilesDetails([objId], { + success_callback: ({ details }) => { + success_callback && success_callback({ detail: details[0] }); + }, + fail_callback + }); + }, [extractFilesDetails]); + + return ( + + {children} + + ); +}; + +export const useMetadataAIOperations = () => { + const context = useContext(MetadataAIOperationsContext); + if (!context) { + throw new Error('\'MetadataAIOperationsContext\' is null'); + } + return context; +}; diff --git a/frontend/src/hooks/metadata-operation.js b/frontend/src/hooks/metadata-operation.js deleted file mode 100644 index a4b98fb6fe..0000000000 --- a/frontend/src/hooks/metadata-operation.js +++ /dev/null @@ -1,54 +0,0 @@ -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 6c9181b797..e304ef7914 100644 --- a/frontend/src/hooks/metadata-status.js +++ b/frontend/src/hooks/metadata-status.js @@ -2,7 +2,7 @@ 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'; +import { MetadataAIOperationsProvider } from './metadata-ai-operation'; // This hook provides content related to seahub interaction, such as whether to enable extended attributes const MetadataStatusContext = React.createContext(null); @@ -119,14 +119,16 @@ export const MetadataStatusProvider = ({ repoID, currentRepoInfo, hideMetadataVi }} > {!isLoading && ( - {children} - + )} ); diff --git a/frontend/src/metadata/components/detail-editor/long-text-editor/index.js b/frontend/src/metadata/components/detail-editor/long-text-editor/index.js index 3879d729c8..b4434f274d 100644 --- a/frontend/src/metadata/components/detail-editor/long-text-editor/index.js +++ b/frontend/src/metadata/components/detail-editor/long-text-editor/index.js @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { LongTextFormatter } from '@seafile/sf-metadata-ui-component'; import Editor from '../../cell-editors/long-text-editor'; @@ -10,6 +10,8 @@ const LongTextEditor = ({ field, value: oldValue, onChange }) => { const [value, setValue] = useState(oldValue); const [showEditor, setShowEditor] = useState(false); + const valueRef = useRef(null); + const openEditor = useCallback(() => { setShowEditor(true); }, []); @@ -23,6 +25,13 @@ const LongTextEditor = ({ field, value: oldValue, onChange }) => { setShowEditor(false); }, []); + useEffect(() => { + if (showEditor) return; + if (valueRef.current === oldValue) return; + setValue(oldValue); + valueRef.current = oldValue; + }, [showEditor, oldValue]); + const isEmpty = !value || !value.trim(); return ( diff --git a/frontend/src/metadata/components/metadata-details/ai/index.css b/frontend/src/metadata/components/metadata-details/ai/index.css new file mode 100644 index 0000000000..20ab875310 --- /dev/null +++ b/frontend/src/metadata/components/metadata-details/ai/index.css @@ -0,0 +1,3 @@ +.sf-metadata-ai-dropdown-menu .dropdown-menu { + left: -8px !important; +} diff --git a/frontend/src/metadata/components/metadata-details/ai/index.js b/frontend/src/metadata/components/metadata-details/ai/index.js new file mode 100644 index 0000000000..1f0b6e9185 --- /dev/null +++ b/frontend/src/metadata/components/metadata-details/ai/index.js @@ -0,0 +1,180 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap'; +import { ModalPortal } from '@seafile/sf-metadata-ui-component'; +import Icon from '../../../../components/icon'; +import { useMetadataDetails } from '../../../hooks'; +import { useMetadataStatus } from '../../../../hooks'; +import { gettext } from '../../../../utils/constants'; +import { Utils } from '../../../../utils/utils'; +import { getFileNameFromRecord, getFileObjIdFromRecord, getParentDirFromRecord, getRecordIdFromRecord } from '../../../utils/cell'; +import { getColumnByKey } from '../../../utils/column'; +import { PRIVATE_COLUMN_KEY } from '../constants'; +import { useMetadataAIOperations } from '../../../../hooks/metadata-ai-operation'; +import FileTagsDialog from '../../dialog/file-tags-dialog'; +import { checkIsDir } from '../../../utils/row'; + +import './index.css'; + +const OPERATION = { + GENERATE_DESCRIPTION: 'generate-description', + OCR: 'ocr', + FILE_TAGS: 'file-tags', + FILE_DETAIL: 'file-detail', +}; + +const AI = () => { + const [isMenuShow, setMenuShow] = useState(false); + const [isFileTagsDialogShow, setFileTagsDialogShow] = useState(false); + + const { enableMetadata, enableTags, enableOCR } = useMetadataStatus(); + const { canModifyRecord, columns, record, onChange, onLocalRecordChange, updateFileTags } = useMetadataDetails(); + const { onOCR, generateDescription, extractFileDetails } = useMetadataAIOperations(); + + const options = useMemo(() => { + if (!canModifyRecord) return []; + if (!record) return []; + if (checkIsDir(record)) return []; + const descriptionColumn = getColumnByKey(columns, PRIVATE_COLUMN_KEY.FILE_DESCRIPTION); + const fileName = getFileNameFromRecord(record); + const isImage = Utils.imageCheck(fileName); + const isVideo = Utils.videoCheck(fileName); + const isDescribableDoc = Utils.isDescriptionSupportedFile(fileName); + let list = []; + + if (descriptionColumn && isDescribableDoc) { + list.push({ + value: OPERATION.GENERATE_DESCRIPTION, + label: isImage ? gettext('Generate image description') : gettext('Generate description'), + record + }); + } + + if (enableOCR && isImage) { + list.push({ value: OPERATION.OCR, label: gettext('OCR'), record }); + } + + if (isImage || isVideo) { + list.push({ value: OPERATION.FILE_DETAIL, label: gettext('Extract file detail'), record }); + } + + if (enableTags && isDescribableDoc && !isVideo) { + list.push({ value: OPERATION.FILE_TAGS, label: gettext('Generate file tags'), record }); + } + return list; + }, [enableOCR, enableTags, canModifyRecord, columns, record]); + + const onToggle = useCallback((event) => { + event && event.preventDefault(); + event && event.stopPropagation(); + setMenuShow(!isMenuShow); + }, [isMenuShow]); + + const toggleFileTagsDialog = useCallback(() => { + setFileTagsDialogShow(!isFileTagsDialogShow); + }, [isFileTagsDialogShow]); + + const handelOperation = useCallback((op) => { + const { value: opType, record } = op; + const recordId = getRecordIdFromRecord(record); + const parentDir = getParentDirFromRecord(record); + const fileName = getFileNameFromRecord(record); + const objId = getFileObjIdFromRecord(record); + + switch (opType) { + case OPERATION.GENERATE_DESCRIPTION: { + generateDescription({ parentDir, fileName }, { + success_callback: ({ description }) => { + if (!description) return; + onChange && onChange(PRIVATE_COLUMN_KEY.FILE_DESCRIPTION, description); + }, + }); + break; + } + case OPERATION.OCR: { + onOCR({ parentDir, fileName }, { + success_callback: ({ ocrResult }) => { + if (!ocrResult) return; + onChange && onChange(PRIVATE_COLUMN_KEY.OCR, JSON.stringify(ocrResult)); + }, + }); + break; + } + case OPERATION.FILE_TAGS: { + setFileTagsDialogShow(true); + break; + } + case OPERATION.FILE_DETAIL: { + extractFileDetails(objId, { + success_callback: ({ detail }) => { + if (!detail) return; + const captureColumn = getColumnByKey(columns, PRIVATE_COLUMN_KEY.CAPTURE_TIME); + if (captureColumn) { + const value = detail[PRIVATE_COLUMN_KEY.CAPTURE_TIME]; + value && onChange && onChange(PRIVATE_COLUMN_KEY.CAPTURE_TIME, value); + } + const fileDetails = detail[PRIVATE_COLUMN_KEY.FILE_DETAILS]; + const location = detail[PRIVATE_COLUMN_KEY.LOCATION]; + let update = {}; + if (fileDetails) { + update[PRIVATE_COLUMN_KEY.FILE_DETAILS] = fileDetails; + } + if (location) { + update[PRIVATE_COLUMN_KEY.LOCATION] = location; + } + Object.keys(update).length > 0 && onLocalRecordChange(recordId, update); + }, + }); + break; + } + default: { + setMenuShow(false); + break; + } + } + }, [columns, generateDescription, onOCR, extractFileDetails, onChange, onLocalRecordChange]); + + const renderDropdown = useCallback(() => { + if (!enableMetadata) return null; + if (!canModifyRecord) return null; + if (!record) return null; + if (options.length === 0) return null; + + return ( + + + + + + + {isMenuShow && ( + + + + {options.map(op => ( handelOperation(op)}>{op.label}))} + + + + )} + + ); + }, [isMenuShow, enableMetadata, canModifyRecord, record, options, onToggle, handelOperation]); + + return ( + <> + {renderDropdown()} + {isFileTagsDialogShow && ( + + )} + > + ); +}; + +export default AI; diff --git a/frontend/src/metadata/components/metadata-details/index.js b/frontend/src/metadata/components/metadata-details/index.js index 9705e53fb6..043ff5f277 100644 --- a/frontend/src/metadata/components/metadata-details/index.js +++ b/frontend/src/metadata/components/metadata-details/index.js @@ -9,6 +9,8 @@ import { PRIVATE_COLUMN_KEY } from '../../constants'; import Location from './location'; import { useMetadataDetails } from '../../hooks'; import { checkIsDir } from '../../utils/row'; +import AI from './ai'; +import Settings from './settings'; import './index.css'; @@ -64,3 +66,7 @@ const MetadataDetails = () => { }; export default MetadataDetails; +export { + AI, + Settings, +}; diff --git a/frontend/src/metadata/components/metadata-details/settings/index.js b/frontend/src/metadata/components/metadata-details/settings/index.js index a3f99b8464..315d26dbf5 100644 --- a/frontend/src/metadata/components/metadata-details/settings/index.js +++ b/frontend/src/metadata/components/metadata-details/settings/index.js @@ -10,7 +10,7 @@ const Settings = () => { const [isShowSetter, setShowSetter] = useState(false); const { enableMetadata } = useMetadataStatus(); - const { modifyColumnOrder, modifyHiddenColumns, columns, canModifyDetails } = useMetadataDetails(); + const { modifyColumnOrder, modifyHiddenColumns, record, columns, canModifyDetails } = useMetadataDetails(); const hiddenColumns = useMemo(() => columns.filter(c => !c.shown).map(c => c.key), [columns]); const onSetterToggle = useCallback(() => { @@ -20,6 +20,7 @@ const Settings = () => { if (!enableMetadata) return null; if (!canModifyDetails) return null; + if (!record) return null; return ( <> diff --git a/frontend/src/metadata/hooks/metadata-details.js b/frontend/src/metadata/hooks/metadata-details.js index 1ad4967b2c..a0bc03b105 100644 --- a/frontend/src/metadata/hooks/metadata-details.js +++ b/frontend/src/metadata/hooks/metadata-details.js @@ -49,7 +49,7 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent return [...exitColumnsOrder, ...newColumns]; }, [originColumns, detailsSettings]); - const localRecordChanged = useCallback((recordId, updates) => { + const onLocalRecordChange = useCallback((recordId, updates) => { if (getRecordIdFromRecord(record) !== recordId) return; const newRecord = { ...record, ...updates }; setRecord(newRecord); @@ -180,11 +180,11 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent useEffect(() => { const eventBus = window?.sfMetadataContext?.eventBus; if (!eventBus) return; - const unsubscribeLocalRecordChanged = eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_RECORD_DETAIL_CHANGED, localRecordChanged); + const unsubscribeLocalRecordChanged = eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_RECORD_DETAIL_CHANGED, onLocalRecordChange); return () => { unsubscribeLocalRecordChanged(); }; - }, [localRecordChanged]); + }, [onLocalRecordChange]); return ( { + const checkIsDescribableFile = useCallback((record) => { const fileName = getFileNameFromRecord(record); return checkCanModifyRow(record) && Utils.isDescriptionSupportedFile(fileName); }, []); @@ -142,6 +144,10 @@ const ContextMenu = ({ list.push({ value: OPERATION.DELETE_RECORDS, label: gettext('Delete'), records: ableDeleteRecords }); } const imageOrVideoRecords = records.filter(record => { + const isFolder = checkIsDir(record); + if (isFolder) return false; + const canModifyRow = checkCanModifyRow(record); + if (!canModifyRow) return false; const fileName = getFileNameFromRecord(record); return Utils.imageCheck(fileName) || Utils.videoCheck(fileName); }); @@ -165,24 +171,29 @@ const ContextMenu = ({ list.push({ value: OPERATION.OPEN_PARENT_FOLDER, label: gettext('Open parent folder'), record }); const fileName = getFileNameFromRecord(record); - if (descriptionColumn) { - if (checkIsDescribableDoc(record)) { - list.push({ value: OPERATION.GENERATE_DESCRIPTION, label: gettext('Generate description'), record }); - } else if (canModifyRow && Utils.imageCheck(fileName)) { - list.push({ value: OPERATION.IMAGE_CAPTION, label: gettext('Generate image description'), record }); + if (!isFolder && canModifyRow) { + const isDescribableFile = checkIsDescribableFile(record); + const isImage = Utils.imageCheck(fileName); + const isVideo = Utils.videoCheck(fileName); + if (descriptionColumn && isDescribableFile) { + list.push({ + value: OPERATION.GENERATE_DESCRIPTION, + label: Utils.imageCheck(fileName) ? gettext('Generate image description') : gettext('Generate description'), + record + }); } - } - if (enableOCR && canModifyRow && Utils.imageCheck(fileName)) { - list.push({ value: OPERATION.OCR, label: gettext('OCR'), record }); - } + if (enableOCR && isImage) { + list.push({ value: OPERATION.OCR, label: gettext('OCR'), record }); + } - if (canModifyRow && (Utils.imageCheck(fileName) || Utils.videoCheck(fileName))) { - list.push({ value: OPERATION.FILE_DETAIL, label: gettext('Extract file detail'), record: record }); - } + if (isImage || isVideo) { + list.push({ value: OPERATION.FILE_DETAIL, label: gettext('Extract file detail'), record: record }); + } - if (tagsColumn && canModifyRow && (Utils.imageCheck(fileName) || checkIsDescribableDoc(record))) { - list.push({ value: OPERATION.FILE_TAGS, label: gettext('Generate file tags'), record: record }); + if (tagsColumn && isDescribableFile && !isVideo) { + list.push({ value: OPERATION.FILE_TAGS, label: gettext('Generate file tags'), record: record }); + } } // handle delete folder/file @@ -199,7 +210,7 @@ const ContextMenu = ({ } return list; - }, [visible, isGroupView, selectedPosition, recordMetrics, selectedRange, metadata, recordGetterByIndex, checkIsDescribableDoc, enableOCR, getAbleDeleteRecords]); + }, [visible, isGroupView, selectedPosition, recordMetrics, selectedRange, metadata, recordGetterByIndex, checkIsDescribableFile, enableOCR, getAbleDeleteRecords]); const handleHide = useCallback((event) => { if (!menuRef.current && visible) { @@ -212,83 +223,46 @@ const ContextMenu = ({ } }, [menuRef, visible]); - const generateDescription = useCallback((record) => { - const descriptionColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION; - let path = ''; - let idOldRecordData = {}; - let idOriginalOldRecordData = {}; + const handelGenerateDescription = useCallback((record) => { + if (!checkCanModifyRow(record)) return; + const parentDir = getParentDirFromRecord(record); const fileName = getFileNameFromRecord(record); - if (Utils.isDescriptionSupportedFile(fileName) && checkCanModifyRow(record)) { - const parentDir = getParentDirFromRecord(record); - path = Utils.joinPath(parentDir, fileName); - idOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [descriptionColumnKey]: record[descriptionColumnKey] }; - idOriginalOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [descriptionColumnKey]: record[descriptionColumnKey] }; - } - if (path === '') return; - window.sfMetadataContext.generateDescription(path).then(res => { - const description = res.data.summary; - const updateRecordId = record[PRIVATE_COLUMN_KEY.ID]; - const recordIds = [updateRecordId]; - let idRecordUpdates = {}; - let idOriginalRecordUpdates = {}; - idRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description }; - idOriginalRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description }; - updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); - }).catch(error => { - const errorMessage = gettext('Failed to generate description'); - toaster.danger(errorMessage); - }); - }, [updateRecords]); + if (!fileName || !parentDir) return; + const checkIsDescribableFile = Utils.isDescriptionSupportedFile(fileName); + if (!checkIsDescribableFile) return; - const imageCaption = useCallback((record) => { - const summaryColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION; - let path = ''; - let idOldRecordData = {}; - let idOriginalOldRecordData = {}; - const fileName = getFileNameFromRecord(record); - if (Utils.imageCheck(fileName) && checkCanModifyRow(record)) { - const parentDir = getParentDirFromRecord(record); - path = Utils.joinPath(parentDir, fileName); - idOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [summaryColumnKey]: record[summaryColumnKey] }; - idOriginalOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [summaryColumnKey]: record[summaryColumnKey] }; - } - if (path === '') return; - window.sfMetadataContext.imageCaption(path).then(res => { - const desc = res.data.desc; - const updateRecordId = record[PRIVATE_COLUMN_KEY.ID]; - const recordIds = [updateRecordId]; - let idRecordUpdates = {}; - let idOriginalRecordUpdates = {}; - idRecordUpdates[updateRecordId] = { [summaryColumnKey]: desc }; - idOriginalRecordUpdates[updateRecordId] = { [summaryColumnKey]: desc }; - updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); - }).catch(error => { - const errorMessage = gettext('Failed to generate image description'); - toaster.danger(errorMessage); + const descriptionColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION; + let idOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [descriptionColumnKey]: record[descriptionColumnKey] } }; + let idOriginalOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [descriptionColumnKey]: record[descriptionColumnKey] } }; + generateDescription({ parentDir, fileName }, { + success_callback: ({ description }) => { + const updateRecordId = record[PRIVATE_COLUMN_KEY.ID]; + const recordIds = [updateRecordId]; + let idRecordUpdates = {}; + let idOriginalRecordUpdates = {}; + idRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description }; + idOriginalRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description }; + updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); + } }); - }, [updateRecords]); + }, [updateRecords, generateDescription]); const toggleFileTagsRecord = useCallback((record = null) => { setFileTagsRecord(record); }, []); const ocr = useCallback((record) => { - const ocrResultColumnKey = PRIVATE_COLUMN_KEY.OCR; - let path = ''; - let idOldRecordData = {}; - let idOriginalOldRecordData = {}; + if (!checkCanModifyRow(record)) return; + const parentDir = getParentDirFromRecord(record); const fileName = getFileNameFromRecord(record); - if (Utils.imageCheck(fileName) && checkCanModifyRow(record)) { - const parentDir = getParentDirFromRecord(record); - path = Utils.joinPath(parentDir, fileName); - idOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [ocrResultColumnKey]: record[ocrResultColumnKey] }; - idOriginalOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [ocrResultColumnKey]: record[ocrResultColumnKey] }; - } - if (path === '') return; - window.sfMetadataContext.ocr(path).then(res => { - const ocrResult = res.data.ocr_result; - const validResult = Array.isArray(ocrResult) && ocrResult.length > 0 ? JSON.stringify(ocrResult) : null; - if (validResult) { + if (!Utils.imageCheck(fileName)) return; + + const ocrResultColumnKey = PRIVATE_COLUMN_KEY.OCR; + let idOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [ocrResultColumnKey]: record[ocrResultColumnKey] } }; + let idOriginalOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [ocrResultColumnKey]: record[ocrResultColumnKey] } }; + onOCR({ parentDir, fileName }, { + success_callback: ({ ocrResult }) => { + if (!ocrResult) return; const updateRecordId = record[PRIVATE_COLUMN_KEY.ID]; const recordIds = [updateRecordId]; let idRecordUpdates = {}; @@ -296,12 +270,9 @@ const ContextMenu = ({ 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); + }, }); - }, [updateRecords]); + }, [updateRecords, onOCR]); const updateFileDetails = useCallback((records) => { const recordObjIds = records.map(record => getFileObjIdFromRecord(record)); @@ -311,10 +282,10 @@ const ContextMenu = ({ } const recordIds = records.map(record => getRecordIdFromRecord(record)); - window.sfMetadataContext.extractFileDetails(recordObjIds).then(res => { - const captureColumn = getColumnByKey(metadata.columns, PRIVATE_COLUMN_KEY.CAPTURE_TIME); - - if (captureColumn) { + extractFilesDetails(recordObjIds, { + success_callback: ({ details }) => { + const captureColumn = getColumnByKey(metadata.columns, PRIVATE_COLUMN_KEY.CAPTURE_TIME); + if (!captureColumn) return; let idOldRecordData = {}; let idOriginalOldRecordData = {}; const captureColumnKey = PRIVATE_COLUMN_KEY.CAPTURE_TIME; @@ -324,18 +295,15 @@ const ContextMenu = ({ }); let idRecordUpdates = {}; let idOriginalRecordUpdates = {}; - res.data.details.forEach(detail => { + details.forEach(detail => { const updateRecordId = detail[PRIVATE_COLUMN_KEY.ID]; idRecordUpdates[updateRecordId] = { [captureColumnKey]: detail[captureColumnKey] }; idOriginalRecordUpdates[updateRecordId] = { [captureColumnKey]: detail[captureColumnKey] }; }); updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); } - }).catch(error => { - const errorMessage = gettext('Failed to extract file details'); - toaster.danger(errorMessage); }); - }, [metadata, updateRecords]); + }, [metadata, extractFilesDetails, updateRecords]); const handleOptionClick = useCallback((event, option) => { event.stopPropagation(); @@ -363,13 +331,7 @@ const ContextMenu = ({ case OPERATION.GENERATE_DESCRIPTION: { const { record } = option; if (!record) break; - generateDescription(record); - break; - } - case OPERATION.IMAGE_CAPTION: { - const { record } = option; - if (!record) break; - imageCaption(record); + handelGenerateDescription(record); break; } case OPERATION.FILE_TAGS: { @@ -433,7 +395,7 @@ const ContextMenu = ({ } } setVisible(false); - }, [repoID, onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord, toggleMoveDialog]); + }, [repoID, onCopySelected, onClearSelected, handelGenerateDescription, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord, toggleMoveDialog]); const getMenuPosition = useCallback((x = 0, y = 0) => { let menuStyles = { diff --git a/frontend/src/pages/sdoc/sdoc-editor/index.css b/frontend/src/pages/sdoc/sdoc-editor/index.css index d152a3dd18..2b73477fc0 100644 --- a/frontend/src/pages/sdoc/sdoc-editor/index.css +++ b/frontend/src/pages/sdoc/sdoc-editor/index.css @@ -15,13 +15,15 @@ background-color: inherit; } -.sdoc-content-right-panel .detail-header .seafile-multicolor-icon-set-up { +.sdoc-content-right-panel .detail-header .seafile-multicolor-icon-set-up, +.sdoc-content-right-panel .detail-header .seafile-multicolor-icon-ai { fill: #999; font-weight: 700; font-size: 16px; } -.sdoc-content-right-panel .detail-header .detail-control:hover .seafile-multicolor-icon-set-up { +.sdoc-content-right-panel .detail-header .detail-control:hover .seafile-multicolor-icon-set-up, +.sdoc-content-right-panel .detail-header .detail-control:hover .seafile-multicolor-icon-ai { fill: #5a5a5a; } diff --git a/frontend/src/shared-dir-view.js b/frontend/src/shared-dir-view.js index 4fbebf6c06..32f52c6805 100644 --- a/frontend/src/shared-dir-view.js +++ b/frontend/src/shared-dir-view.js @@ -20,7 +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 { MetadataAIOperationsProvider } from './hooks/metadata-ai-operation'; import './css/shared-dir-view.css'; import './css/grid-view.css'; @@ -441,7 +441,7 @@ class SharedDirView extends React.Component { const isDesktop = Utils.isDesktop(); const modeBaseClass = 'btn btn-secondary btn-icon sf-view-mode-btn'; return ( - + @@ -581,7 +581,7 @@ class SharedDirView extends React.Component { /> } - + ); } } diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index 5f92a385da..995983be1f 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -1055,7 +1055,12 @@ export const Utils = { }, isDescriptionSupportedFile: function (filePath) { - return Utils.isSdocFile(filePath) || Utils.isMarkdownFile(filePath) || Utils.pdfCheck(filePath) || Utils.isDocxFile(filePath) || Utils.isPptxFile(filePath); + return Utils.isSdocFile(filePath) || + Utils.isMarkdownFile(filePath) || + Utils.pdfCheck(filePath) || + Utils.isDocxFile(filePath) || + Utils.isPptxFile(filePath) || + Utils.imageCheck(filePath); }, isFileMetadata: function (type) {