/
vshmidt
/
label-studio
Обзор
Документация
Войти
/
vshmidt
/
label-studio
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
web/apps/labelstudio/src/pages/CreateProject/Import/Import.jsx
671 строка
24 KB
Raul Martin
feat: Migrate SCSS to plain CSS with PostCSS, replace Stylelint with Biome (#9584)
12 мар 2026, 20:57
Не верифицирован
12 мар 2026, 20:57
3f31128
Код
Авторство
О чём код?
import { SampleDatasetSelect } from "@humansignal/app-common/blocks/SampleDatasetSelect/SampleDatasetSelect"; import { ff, formatFileSize } from "@humansignal/core"; import { IconCode, IconErrorAlt, IconFileUpload, IconInfoOutline, IconTrash, IconUpload } from "@humansignal/icons"; import { cn as scn } from "@humansignal/shad/utils"; import { useAtomValue } from "jotai"; import Input from "libs/datamanager/src/components/Common/Input/Input"; import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { useAPI } from "../../../providers/ApiProvider"; import { cn } from "../../../utils/bem"; import { unique } from "../../../utils/helpers"; import { sampleDatasetAtom } from "../utils/atoms"; import "./Import.prefix.css"; import { Button, CodeBlock, SimpleCard, Spinner, Tooltip, Typography, Badge } from "@humansignal/ui"; import truncate from "truncate-middle"; import samples from "./samples.json"; import { importFiles } from "./utils"; const importClass = cn("upload_page"); const dropzoneClass = cn("dropzone"); // Constants for file display and animation const FLASH_ANIMATION_DURATION = 2000; // 2 seconds const FILENAME_TRUNCATE_START = 24; const FILENAME_TRUNCATE_END = 24; function flatten(nested) { return [].concat(...nested); } // Keep in sync with core.settings.SUPPORTED_EXTENSIONS on the BE. const supportedExtensions = { text: ["txt"], audio: ["wav", "mp3", "flac", "m4a", "ogg"], video: ["mp4", "webm"], image: ["bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"], html: ["html", "htm", "xml"], pdf: ["pdf"], structuredData: ["csv", "tsv", "json"], }; const allSupportedExtensions = flatten(Object.values(supportedExtensions)); function getFileExtension(fileName) { if (!fileName) { return fileName; } return fileName.split(".").pop().toLowerCase(); } function traverseFileTree(item, path) { return new Promise((resolve) => { path = path || ""; if (item.isFile) { // Avoid hidden files if (item.name[0] === ".") return resolve([]); resolve([item]); } else if (item.isDirectory) { // Get folder contents const dirReader = item.createReader(); const dirPath = `${path + item.name}/`; dirReader.readEntries((entries) => { Promise.all(entries.map((entry) => traverseFileTree(entry, dirPath))) .then(flatten) .then(resolve); }); } }); } function getFiles(files) { // @todo this can be not a files, but text or any other draggable stuff return new Promise((resolve) => { if (!files.length) return resolve([]); if (!files[0].webkitGetAsEntry) return resolve(files); // Use DataTransferItemList interface to access the file(s) const entries = Array.from(files).map((file) => file.webkitGetAsEntry()); Promise.all(entries.map(traverseFileTree)) .then(flatten) .then((fileEntries) => fileEntries.map((fileEntry) => new Promise((res) => fileEntry.file(res)))) .then((filePromises) => Promise.all(filePromises)) .then(resolve); }); } const Upload = ({ children, sendFiles }) => { const [hovered, setHovered] = useState(false); const onHover = (e) => { e.preventDefault(); setHovered(true); }; const onLeave = setHovered.bind(null, false); const dropzoneRef = useRef(); const onDrop = useCallback( (e) => { e.preventDefault(); onLeave(); getFiles(e.dataTransfer.items).then((files) => sendFiles(files)); }, [onLeave, sendFiles], ); return ( <div id="holder" className={dropzoneClass.mod({ hovered }).toClassName()} ref={dropzoneRef} onDragStart={onHover} onDragOver={onHover} onDragLeave={onLeave} onDrop={onDrop} // {...getRootProps} > {children} </div> ); }; const ErrorMessage = ({ error }) => { if (!error) return null; let extra = error.validation_errors ?? error.extra; // support all possible responses if (extra && typeof extra === "object" && !Array.isArray(extra)) { extra = extra.non_field_errors ?? Object.values(extra); } if (Array.isArray(extra)) extra = extra.join("; "); return ( <div className={importClass.elem("error").toClassName()}> <IconErrorAlt width="24" height="24" /> {error.id && `[${error.id}] `} {error.detail || error.message} {extra && ` (${extra})`} </div> ); }; export const ImportPage = ({ project, sample, show = true, onWaiting, onFileListUpdate, onSampleDatasetSelect, highlightCsvHandling, dontCommitToProject = false, csvHandling, setCsvHandling, addColumns, openLabelingConfig, }) => { const [error, setError] = useState(); const [newlyUploadedFiles, setNewlyUploadedFiles] = useState(new Set()); const prevUploadedRef = useRef(new Set()); const api = useAPI(); const projectConfigured = project?.label_config !== "<View></View>"; const sampleConfig = useAtomValue(sampleDatasetAtom); const processFiles = (state, action) => { if (action.sending) { return { ...state, uploading: [...action.sending, ...state.uploading] }; } if (action.sent) { return { ...state, uploading: state.uploading.filter((f) => !action.sent.includes(f)), }; } if (action.uploaded) { return { ...state, uploaded: unique([...state.uploaded, ...action.uploaded], (a, b) => a.id === b.id), }; } if (action.ids) { const ids = unique([...state.ids, ...action.ids]); onFileListUpdate?.(ids); return { ...state, ids }; } return state; }; const [files, dispatch] = useReducer(processFiles, { uploaded: [], uploading: [], ids: [], }); const showList = Boolean(files.uploaded?.length || files.uploading?.length || sample); const loadFilesList = useCallback( async (file_upload_ids) => { const query = {}; if (file_upload_ids) { // should be stringified array "[1,2]" query.ids = JSON.stringify(file_upload_ids); } const files = await api.callApi("fileUploads", { params: { pk: project.id, ...query }, }); dispatch({ uploaded: files ?? [] }); if (files?.length) { dispatch({ ids: files.map((f) => f.id) }); } return files; }, [project?.id], ); const onError = (err) => { console.error(err); // @todo workaround for error about input size in a wrong html format if (typeof err === "string" && err.includes("RequestDataTooBig")) { const message = "Imported file is too big"; const extra = err.match(/"exception_value">(.*)<\/pre>/)?.[1]; err = { message, extra }; } setError(err); onWaiting?.(false); }; const onFinish = useCallback( async (res) => { const { could_be_tasks_list, data_columns, file_upload_ids } = res; dispatch({ ids: file_upload_ids }); if (could_be_tasks_list && !csvHandling) setCsvHandling("choose"); onWaiting?.(false); addColumns(data_columns); await loadFilesList(file_upload_ids); return res; }, [addColumns, loadFilesList], ); // Track newly uploaded files for flash animation useEffect(() => { const currentUploadedIds = new Set(files.uploaded.map((f) => f.id)); const previousUploadedIds = prevUploadedRef.current; // Find files that were just uploaded (in current but not in previous) const justUploaded = new Set([...currentUploadedIds].filter((id) => !previousUploadedIds.has(id))); // Update the ref immediately after comparison to ensure it's available for next run prevUploadedRef.current = new Set(currentUploadedIds); // Clean up animation state for files that are no longer in the uploaded list setNewlyUploadedFiles((prev) => { const filtered = new Set([...prev].filter((id) => currentUploadedIds.has(id))); return filtered; }); // Animate newly uploaded files (including first upload) if (justUploaded.size > 0) { // Apply animation class immediately for better responsiveness setNewlyUploadedFiles((prev) => new Set([...prev, ...justUploaded])); // Remove animation class after animation completes (CSS handles the animation timing) const timeoutId = setTimeout(() => { setNewlyUploadedFiles((prev) => { const updated = new Set(prev); justUploaded.forEach((id) => updated.delete(id)); return updated; }); }, FLASH_ANIMATION_DURATION); // Cleanup timeout on unmount or dependency change return () => clearTimeout(timeoutId); } }, [files.uploaded]); const importFilesImmediately = useCallback( async (files, body) => { importFiles({ files, body, project, onError, onFinish, onUploadStart: (files) => dispatch({ sending: files }), onUploadFinish: (files) => dispatch({ sent: files }), dontCommitToProject, }); }, [project, onFinish], ); const sendFiles = useCallback( (files) => { setError(null); onWaiting?.(true); files = [...files]; // they can be array-like object const fd = new FormData(); for (const f of files) { if (!allSupportedExtensions.includes(getFileExtension(f.name))) { onError(new Error(`The filetype of file "${f.name}" is not supported.`)); return; } fd.append(f.name, f); } return importFilesImmediately(files, fd); }, [importFilesImmediately], ); const onUpload = useCallback( (e) => { sendFiles(e.target.files); e.target.value = ""; }, [sendFiles], ); const onLoadURL = useCallback( (e) => { e.preventDefault(); setError(null); const url = urlRef.current?.value; if (!url) { return; } urlRef.current.value = ""; onWaiting?.(true); const body = new URLSearchParams({ url }); importFilesImmediately([{ name: url }], body); }, [importFilesImmediately], ); const openConfig = useCallback( (e) => { e.preventDefault(); e.stopPropagation(); openLabelingConfig?.(); }, [openLabelingConfig], ); useEffect(() => { if (project?.id !== undefined) { loadFilesList().then((files) => { if (csvHandling) return; // empirical guess on start if we have some possible tasks list/structured data problem if (Array.isArray(files) && files.some(({ file }) => /\.[ct]sv$/.test(file))) { setCsvHandling("choose"); } }); } }, [project?.id, loadFilesList]); const urlRef = useRef(); if (!project) return null; if (!show) return null; const csvProps = { name: "csv", type: "radio", onChange: (e) => setCsvHandling(e.target.value), }; return ( <div className={importClass}> {highlightCsvHandling && <div className={importClass.elem("csv-splash").toClassName()} />} <input id="file-input" type="file" name="file" multiple onChange={onUpload} style={{ display: "none" }} /> <header className="flex gap-4"> <form className={`${importClass.elem("url-form")} inline-flex items-stretch`} method="POST" onSubmit={onLoadURL} > <Input placeholder="Dataset URL" name="url" ref={urlRef} rawClassName="h-[40px]" /> <Button variant="primary" look="outlined" type="submit" aria-label="Add URL"> Add URL </Button> </form> <span>or</span> <Button variant="primary" look="outlined" type="button" onClick={() => document.getElementById("file-input").click()} leading={<IconUpload />} aria-label="Upload file" > Upload {files.uploaded.length ? "More " : ""}Files </Button> {ff.isActive(ff.FF_SAMPLE_DATASETS) && ( <SampleDatasetSelect samples={samples} sample={sample} onSampleApplied={onSampleDatasetSelect} /> )} <div className={importClass .elem("csv-handling") .mod({ highlighted: highlightCsvHandling, hidden: !csvHandling }) .toClassName()} > <span>Treat CSV/TSV as</span> <label> <input {...csvProps} value="tasks" checked={csvHandling === "tasks"} /> List of tasks </label> <label> <input {...csvProps} value="ts" checked={csvHandling === "ts"} /> Time Series or Whole Text File </label> </div> <div className={importClass.elem("status").toClassName()}> {files.uploaded.length ? `${files.uploaded.length} files uploaded` : ""} </div> </header> <ErrorMessage error={error} /> <main> <Upload sendFiles={sendFiles} project={project}> <div className={scn("flex gap-4 w-full min-h-full", { "justify-center": !showList, })} > {!showList && ( <div className="flex gap-4 justify-center items-start w-full h-full"> <label htmlFor="file-input" className="w-full h-full"> <div className={`${dropzoneClass.elem("content")} w-full`}> <IconFileUpload height="64" className={dropzoneClass.elem("icon").toClassName()} /> <header> Drag & drop files here <br /> or click to browse </header> <dl> <dt>Images</dt> <dd>{supportedExtensions.image.join(", ")}</dd> <dt>Audio</dt> <dd>{supportedExtensions.audio.join(", ")}</dd> <dt> <div className="flex items-center gap-1"> Video <Tooltip title="Video format support depends on your browser. Click to learn more."> <a href="https://labelstud.io/tags/video#Video-format" target="_blank" rel="noopener noreferrer" className="inline-flex items-center" aria-label="Learn more about video format support (opens in a new tab)" > <IconInfoOutline className="w-4 h-4 text-primary-content hover:text-primary-content-hover" /> </a> </Tooltip> </div> </dt> <dd>{supportedExtensions.video.join(", ")}</dd> <dt>HTML / HyperText</dt> <dd>{supportedExtensions.html.join(", ")}</dd> <dt>Text</dt> <dd>{supportedExtensions.text.join(", ")}</dd> <dt>Structured data</dt> <dd>{supportedExtensions.structuredData.join(", ")}</dd> <dt>PDF</dt> <dd>{supportedExtensions.pdf.join(", ")}</dd> </dl> <div className="tips"> <b>Important:</b> <ul className="mt-2 ml-4 list-disc font-normal"> <li> We recommend{" "} <a href="https://labelstud.io/guide/storage.html" target="_blank" rel="noopener noreferrer" aria-label="Cloud Storage documentation (opens in a new tab)" > Cloud Storage </a>{" "} over direct uploads due to{" "} <a href="https://labelstud.io/guide/tasks.html#Import-data-from-the-Label-Studio-UI" target="_blank" rel="noopener noreferrer" aria-label="Upload limitations documentation (opens in a new tab)" > upload limitations </a> . </li> <li> For PDFs, use{" "} <a href="https://labelstud.io/templates/multi-page-document-annotation" target="_blank" rel="noopener noreferrer" aria-label="Multi-image labeling documentation (opens in a new tab)" > multi-image labeling </a> . JSONL or Parquet (Enterprise only) files require cloud storage. </li> <li> Check the documentation to{" "} <a target="_blank" href="https://labelstud.io/guide/predictions.html" rel="noreferrer"> import preannotated data </a> . </li> </ul> </div> </div> </label> </div> )} {showList && ( <div className="w-full"> <SimpleCard title="Files" className="w-full h-full" contentClassName="overflow-y-auto h-[calc(100%-48px)]" > <table className="w-full"> <tbody> {sample && ( <tr key={sample.url}> <td> <div className="flex items-center gap-2"> {sample.title} <Badge>Sample</Badge> </div> </td> <td>{sample.description}</td> <td> <Button size="smaller" variant="negative" onClick={() => onSampleDatasetSelect(undefined)}> <IconTrash className="w-4 h-4" /> </Button> </td> </tr> )} {files.uploaded.map((file) => { const truncatedFilename = truncate( file.file, FILENAME_TRUNCATE_START, FILENAME_TRUNCATE_END, "...", ); return ( <tr key={file.file} className={newlyUploadedFiles.has(file.id) ? importClass.elem("upload-flash") : ""} > <td className={importClass.elem("file-name").toClassName()}> <Tooltip title={file.file}> <Typography variant="body" size="small" className="truncate"> {truncatedFilename} </Typography> </Tooltip> </td> <td> <span className={importClass.elem("file-status").toClassName()} /> </td> <td className={importClass.elem("file-size").toClassName()}> <Typography variant="body" size="smaller" className="text-nowrap text-neutral-content-subtle text-right" > {file.size ? formatFileSize(file.size) : ""} </Typography> </td> </tr> ); })} {files.uploading.map((file, idx) => { const truncatedFilename = truncate( file.name, FILENAME_TRUNCATE_START, FILENAME_TRUNCATE_END, "...", ); return ( <tr key={`${idx}-${file.name}`}> <td className={importClass.elem("file-name").toClassName()}> <Tooltip title={file.name}> <Typography variant="body" size="small" className="truncate"> {truncatedFilename} </Typography> </Tooltip> </td> <td> <span className={importClass.elem("file-status").mod({ uploading: true }).toClassName()} /> </td> <td className={importClass.elem("file-size").toClassName()}> </td> </tr> ); })} </tbody> </table> </SimpleCard> </div> )} {ff.isFF(ff.FF_JSON_PREVIEW) && ( <div className="w-full h-full flex flex-col min-h-[400px]"> {projectConfigured ? ( <SimpleCard title="Expected Input Preview" className="w-full h-full overflow-hidden flex flex-col" contentClassName="h-[calc(100%-48px)]" flushContent > {sampleConfig.data ? ( <div className={importClass.elem("code-wrapper").toClassName()}> <CodeBlock title="Expected Input Preview" code={sampleConfig?.data ?? ""} className="w-full h-full" /> </div> ) : sampleConfig.isLoading ? ( <div className="w-full flex justify-center py-12"> <Spinner className="h-6 w-6" /> </div> ) : sampleConfig.isError ? ( <div className="w-[calc(100%-24px)] text-lg text-negative-content bg-negative-background border m-3 rounded-md border-negative-border-subtle p-4"> Something went wrong, the sample data could not be loaded. </div> ) : null} </SimpleCard> ) : ( <SimpleCard className="w-full h-full flex flex-col items-center justify-center text-center p-wide"> <div className="flex flex-col items-center gap-tight"> <div className="bg-primary-background rounded-largest p-tight flex items-center justify-center"> <IconCode className="w-6 h-6 text-primary-icon" /> </div> <div className="flex flex-col items-center gap-tighter"> <div className="text-label-small text-neutral-content font-medium">View JSON input format</div> <div className="text-body-small text-neutral-content-subtler text-center"> Setup your{" "} <Button type="button" look="string" onClick={openConfig} className="border-none bg-none p-0 m-0 text-primary-content underline" > labeling configuration </Button>{" "} first to preview the expected JSON data format </div> </div> </div> </SimpleCard> )} </div> )} </div> </Upload> </main> </div> ); };