diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 0614e005d..20db42bf5 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -14,6 +14,7 @@ "openWithLocalAndPublicGateway": "Try opening it instead with your <1>local gateway or <3>public gateway.", "cantBePreviewed": "Sorry, this file can’t be previewed", "addByPath": "From IPFS", + "bulkImport": "Bulk import", "newFolder": "New folder", "generating": "Generating…", "actions": { @@ -59,6 +60,14 @@ "namePlaceholder": "Name (optional)", "examples": "Examples:" }, + "bulkImportModal": { + "title": "Bulk import with text file", + "description": "Upload a text file with a list of CIDs (names are optional). Example:", + "select": "Select file", + "selectedFile": "Selected file", + "invalidCids": "*Invalid CID(s) found", + "failedToReadFile": "*Failed to read file contents" + }, "newFolderModal": { "title": "New folder", "description": "Insert the name of the folder you want to create." diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index d3f349a33..7381ff92b 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -398,6 +398,53 @@ const actions = () => ({ } }), + /** + * Reads a text file containing CIDs and adds each one to IPFS at the given root path. + * @param {FileStream[]} source - The text file containing CIDs + * @param {string} root - Destination directory in IPFS + */ + doFilesBulkCidImport: (source, root) => perform(ACTIONS.BULK_CID_IMPORT, async function (ipfs, { store }) { + ensureMFS(store) + + if (!source?.[0]?.content) { + console.error('Invalid file format provided to doFilesBulkCidImport') + return + } + + try { + const file = source[0] + const content = await new Response(file.content).text() + const lines = content.split('\n').map(line => line.trim()).filter(Boolean) + + const cidObjects = lines.map((line) => { + let actualCid = line + let name = line + const cidParts = line.split(' ') + if (cidParts.length > 1) { + actualCid = cidParts[0] + name = cidParts.slice(1).join(' ') + } + return { + name, + cid: actualCid + } + }) + + for (const { cid, name } of cidObjects) { + try { + const src = `/ipfs/${cid}` + const dst = realMfsPath(join(root || '/files', name || cid)) + + await ipfs.files.cp(src, dst) + } catch (err) { + console.error(`Failed to add CID ${cid}:`, err) + } + } + } finally { + await store.doFilesFetch() + } + }), + /** * Creates a download link for the provided files. * @param {FileStat[]} files diff --git a/src/bundles/files/consts.js b/src/bundles/files/consts.js index b5fdac0dc..67383694a 100644 --- a/src/bundles/files/consts.js +++ b/src/bundles/files/consts.js @@ -23,6 +23,8 @@ export const ACTIONS = { SHARE_LINK: ('FILES_SHARE_LINK'), /** @type {'FILES_ADDBYPATH'} */ ADD_BY_PATH: ('FILES_ADDBYPATH'), + /** @type {'FILES_BULK_CID_IMPORT'} */ + BULK_CID_IMPORT: ('FILES_BULK_CID_IMPORT'), /** @type {'FILES_PIN_ADD'} */ PIN_ADD: ('FILES_PIN_ADD'), /** @type {'FILES_PIN_REMOVE'} */ diff --git a/src/bundles/files/protocol.ts b/src/bundles/files/protocol.ts index c6ca742e2..60076a255 100644 --- a/src/bundles/files/protocol.ts +++ b/src/bundles/files/protocol.ts @@ -63,6 +63,7 @@ export type Message = | Move | Write | AddByPath + | BulkCidImport | DownloadLink | Perform<'FILES_SHARE_LINK', Error, string, void> | Perform<'FILES_COPY', Error, void, void> @@ -76,6 +77,7 @@ export type MakeDir = Perform<'FILES_MAKEDIR', Error, void, void> export type WriteProgress = { paths: string[], progress: number } export type Write = Spawn<'FILES_WRITE', WriteProgress, Error, void, void> export type AddByPath = Perform<'FILES_ADDBYPATH', Error, void, void> +export type BulkCidImport = Perform<'FILES_BULK_CID_IMPORT', Error, void, void> export type Move = Perform<'FILES_MOVE', Error, void, void> export type Delete = Perform<'FILES_DELETE', Error, void, void> export type DownloadLink = Perform<'FILES_DOWNLOADLINK', Error, FileDownload, void> diff --git a/src/components/modal/Modal.js b/src/components/modal/Modal.js index 778d30ae2..05695bb3d 100644 --- a/src/components/modal/Modal.js +++ b/src/components/modal/Modal.js @@ -2,7 +2,7 @@ import React from 'react' import PropTypes from 'prop-types' import CancelIcon from '../../icons/GlyphSmallCancel.js' -export const ModalActions = ({ justify, className, children, ...props }) => ( +export const ModalActions = ({ justify = 'between', className = '', children, ...props }) => (
{ children }
@@ -13,12 +13,7 @@ ModalActions.propTypes = { className: PropTypes.string } -ModalActions.defaultProps = { - justify: 'between', - className: '' -} - -export const ModalBody = ({ className, Icon, title, children, ...props }) => ( +export const ModalBody = ({ className = '', Icon, title, children, ...props }) => (
{ Icon && (
@@ -40,10 +35,6 @@ ModalBody.propTypes = { ]) } -ModalBody.defaultProps = { - className: '' -} - export const Modal = ({ onCancel, children, className, ...props }) => { return (
diff --git a/src/files/FilesPage.js b/src/files/FilesPage.js index d9a837330..b61678f16 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.js @@ -15,13 +15,13 @@ import FilesList from './files-list/FilesList.js' import { getJoyrideLocales } from '../helpers/i8n.js' // Icons -import Modals, { DELETE, NEW_FOLDER, SHARE, RENAME, ADD_BY_PATH, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js' +import Modals, { DELETE, NEW_FOLDER, SHARE, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js' import Header from './header/Header.js' import FileImportStatus from './file-import-status/FileImportStatus.js' import { useExplore } from 'ipld-explorer-components/providers' const FilesPage = ({ - doFetchPinningServices, doFilesFetch, doPinsFetch, doFilesSizeGet, doFilesDownloadLink, doFilesDownloadCarLink, doFilesWrite, doFilesAddPath, doUpdateHash, + doFetchPinningServices, doFilesFetch, doPinsFetch, doFilesSizeGet, doFilesDownloadLink, doFilesDownloadCarLink, doFilesWrite, doFilesBulkCidImport, doFilesAddPath, doUpdateHash, doFilesUpdateSorting, doFilesNavigateTo, doFilesMove, doSetCliOptions, doFetchRemotePins, remotePins, pendingPins, failedPins, ipfsProvider, ipfsConnected, doFilesMakeDir, doFilesShareLink, doFilesDelete, doSetPinning, onRemotePinClick, doPublishIpnsKey, files, filesPathInfo, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, t @@ -72,6 +72,12 @@ const FilesPage = ({ doFilesWrite(raw, root) } + const onBulkCidImport = (raw, root = '') => { + if (root === '') root = files.path + + doFilesBulkCidImport(raw, root) + } + const onAddByPath = (path, name) => doFilesAddPath(files.path, path, name) const onInspect = (cid) => doUpdateHash(`/explore/${cid}`) const showModal = (modal, files = null) => setModals({ show: modal, files }) @@ -206,6 +212,7 @@ const FilesPage = ({ onAddFiles={onAddFiles} onMove={doFilesMove} onAddByPath={(files) => showModal(ADD_BY_PATH, files)} + onBulkCidImport={(files) => showModal(BULK_CID_IMPORT, files)} onNewFolder={(files) => showModal(NEW_FOLDER, files)} onCliTutorMode={() => showModal(CLI_TUTOR_MODE)} handleContextMenu={(...args) => handleContextMenu(...args, true)} /> @@ -226,6 +233,7 @@ const FilesPage = ({ onShareLink={doFilesShareLink} onRemove={doFilesDelete} onAddByPath={onAddByPath} + onBulkCidImport={onBulkCidImport} onPinningSet={doSetPinning} onPublish={doPublishIpnsKey} cliOptions={cliOptions} @@ -277,6 +285,7 @@ export default connect( 'selectFilesSorting', 'selectToursEnabled', 'doFilesWrite', + 'doFilesBulkCidImport', 'doFilesDownloadLink', 'doFilesDownloadCarLink', 'doFilesSizeGet', diff --git a/src/files/file-input/FileInput.js b/src/files/file-input/FileInput.js index c83ddfbc1..cd41f0623 100644 --- a/src/files/file-input/FileInput.js +++ b/src/files/file-input/FileInput.js @@ -50,6 +50,11 @@ class FileInput extends React.Component { this.toggleDropdown() } + onBulkCidImport = () => { + this.props.onBulkCidImport() + this.toggleDropdown() + } + onNewFolder = () => { this.props.onNewFolder() this.toggleDropdown() @@ -92,6 +97,10 @@ class FileInput extends React.Component { {t('newFolder')} + @@ -120,7 +129,8 @@ FileInput.propTypes = { t: PropTypes.func.isRequired, onAddFiles: PropTypes.func.isRequired, onAddByPath: PropTypes.func.isRequired, - onNewFolder: PropTypes.func.isRequired + onNewFolder: PropTypes.func.isRequired, + onBulkCidImport: PropTypes.func.isRequired } export default connect( diff --git a/src/files/header/Header.js b/src/files/header/Header.js index ce9142109..9c438025c 100644 --- a/src/files/header/Header.js +++ b/src/files/header/Header.js @@ -93,6 +93,7 @@ class Header extends React.Component { onNewFolder={this.props.onNewFolder} onAddFiles={this.props.onAddFiles} onAddByPath={this.props.onAddByPath} + onBulkCidImport={this.props.onBulkCidImport} onCliTutorMode={this.props.onCliTutorMode} /> :
{ this.dotsWrapper = el }}> diff --git a/src/files/modals/Modals.js b/src/files/modals/Modals.js index 33208190b..5ccebac50 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -10,6 +10,7 @@ import RenameModal from './rename-modal/RenameModal.js' import PinningModal from './pinning-modal/PinningModal.js' import RemoveModal from './remove-modal/RemoveModal.js' import AddByPathModal from './add-by-path-modal/AddByPathModal.js' +import BulkImportModal from './bulk-import-modal/bulk-import-modal.tsx' import PublishModal from './publish-modal/PublishModal.js' import CliTutorMode from '../../components/cli-tutor-mode/CliTutorMode.js' import { cliCommandList, cliCmdKeys } from '../../bundles/files/consts.js' @@ -20,6 +21,7 @@ const SHARE = 'share' const RENAME = 'rename' const DELETE = 'delete' const ADD_BY_PATH = 'add_by_path' +const BULK_CID_IMPORT = 'bulk_cid_import' const CLI_TUTOR_MODE = 'cli_tutor_mode' const PINNING = 'pinning' const PUBLISH = 'publish' @@ -30,6 +32,7 @@ export { RENAME, DELETE, ADD_BY_PATH, + BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, PUBLISH @@ -63,6 +66,11 @@ class Modals extends React.Component { this.leave() } + onBulkCidImport = (files, root) => { + this.props.onBulkCidImport(files, root) + this.leave() + } + makeDir = (path) => { this.props.onMakeDir(join(this.props.root, path)) this.leave() @@ -152,6 +160,9 @@ class Modals extends React.Component { case ADD_BY_PATH: this.setState({ readyToShow: true }) break + case BULK_CID_IMPORT: + this.setState({ readyToShow: true }) + break case CLI_TUTOR_MODE: this.setState({ command: this.cliCommand(cliOptions, files, root) }, () => { this.setState({ readyToShow: true }) @@ -254,6 +265,13 @@ class Modals extends React.Component { onCancel={this.leave} /> + + + + diff --git a/src/files/modals/bulk-import-modal/bulk-import-modal.stories.js b/src/files/modals/bulk-import-modal/bulk-import-modal.stories.js new file mode 100644 index 000000000..17d585019 --- /dev/null +++ b/src/files/modals/bulk-import-modal/bulk-import-modal.stories.js @@ -0,0 +1,21 @@ +import React from 'react' +import { action } from '@storybook/addon-actions' +import i18n from '../../../i18n-decorator.js' +import BulkImportModal from './bulk-import-modal.tsx' + +/** + * @type {import('@storybook/react').Meta} + */ +export default { + title: 'Files/Modals', + decorators: [i18n] +} + +/** + * @type {import('@storybook/react').StoryObj} + */ +export const BulkImport = () => ( +
+ +
+) diff --git a/src/files/modals/bulk-import-modal/bulk-import-modal.tsx b/src/files/modals/bulk-import-modal/bulk-import-modal.tsx new file mode 100644 index 000000000..6702a32c3 --- /dev/null +++ b/src/files/modals/bulk-import-modal/bulk-import-modal.tsx @@ -0,0 +1,104 @@ +import React, { useState, useRef } from 'react' +import Button from '../../../components/button/button.tsx' +import { Modal, ModalActions, ModalBody } from '../../../components/modal/Modal.js' +import { useTranslation } from 'react-i18next' +import * as isIPFS from 'is-ipfs' +import Icon from '../../../icons/StrokeDocument.js' +import { normalizeFiles } from '../../../lib/files.js' + +const BulkImportModal = ({ onCancel, className = '', onBulkCidImport, ...props }) => { + const [selectedFile, setSelectedFile] = useState(null) + const [validationError, setValidationError] = useState(undefined) + const bulkCidInputRef = useRef(null) + const { t } = useTranslation('files') + + const validateFileContents = async (file) => { + try { + const text = await file.text() + const lines = text.split('\n').filter(line => line.trim()) + + for (const line of lines) { + const [cid] = line.trim().split(' ') + if (!isIPFS.cid(cid)) { + return { isValid: false, error: t('bulkImportModal.invalidCids') } + } + } + + return { isValid: true } + } catch (err) { + return { isValid: false, error: t('bulkImportModal.failedToReadFile') } + } + } + + const onChange = async (event) => { + const file = event.target.files[0] + if (!file) return + + const validation = await validateFileContents(file) + setSelectedFile(validation.isValid ? file : null) + setValidationError(validation.error) + } + + const selectFile = () => { + if (bulkCidInputRef.current) { + bulkCidInputRef.current.click() + } + } + + const onSubmit = async () => { + if (selectedFile) { + const normalizedFiles = normalizeFiles([selectedFile]) + await onBulkCidImport(normalizedFiles) + } + } + + const isDisabled = !selectedFile + const codeClass = 'w-100 mb1 pa1 tl bg-snow f7 charcoal-muted truncate' + + return ( + + +
+

{t('bulkImportModal.description')}

+ bafkreibm6jg3ux5qumhcn2b3flc3tyu6dmlb4xa7u5bf44yegnrjhc4yeq
QmawceGscqN4o8Y8Fv26UUmB454kn2bnkXV5tEQYc4jBd6 barrel.png
QmbvrHYWXAU1BuxMPNRtfeF4DS2oPmo5hat7ocqAkNPr74 pi equals.png
+
+ + + + + {selectedFile && ( +

+ {`${t('bulkImportModal.selectedFile')}: ${selectedFile.name}`} +

+ )} + + {validationError && ( +

+ {validationError} +

+ )} +
+ + + + + +
+ ) +} + +export default BulkImportModal diff --git a/test/e2e/files.test.js b/test/e2e/files.test.js index 9ea7c9fbc..33fcbd578 100644 --- a/test/e2e/files.test.js +++ b/test/e2e/files.test.js @@ -19,6 +19,7 @@ test.describe('Files screen', () => { await page.waitForSelector('text=Folder') await page.waitForSelector('text=From IPFS') await page.waitForSelector('text=New folder') + await page.waitForSelector('text=Bulk import') await page.click(button, { force: true }) })