diff --git a/backend/services/email.service.js b/backend/services/email.service.js index 92c9f63..8fce68f 100644 --- a/backend/services/email.service.js +++ b/backend/services/email.service.js @@ -21,18 +21,23 @@ class EmailService { } async createTestTransporter() { - const testAccount = await nodemailer.createTestAccount(); - this.transporter = nodemailer.createTransport({ - host: "smtp.ethereal.email", - port: 587, - secure: false, - auth: { - user: testAccount.user, - pass: testAccount.pass, - }, - }); - console.log("๐Ÿ“ง Using Ethereal email for development"); - console.log("Preview emails at: https://ethereal.email"); + try { + const testAccount = await nodemailer.createTestAccount(); + this.transporter = nodemailer.createTransport({ + host: "smtp.ethereal.email", + port: 587, + secure: false, + auth: { + user: testAccount.user, + pass: testAccount.pass, + }, + }); + console.log("๐Ÿ“ง Using Ethereal email for development"); + console.log("Preview emails at: https://ethereal.email"); + } catch (err) { + console.warn("โš ๏ธ Ethereal unavailable, email disabled in dev:", err.message); + this.transporter = null; + } } async sendVerificationEmail(user, token) { diff --git a/backend/src/routes/ollama.routes.js b/backend/src/routes/ollama.routes.js index be86d75..484f066 100644 --- a/backend/src/routes/ollama.routes.js +++ b/backend/src/routes/ollama.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const { proxyChat, getTags } = require("../controllers/ollama.controller"); const { requireAuth } = require("../middleware/auth.middleware"); -router.post("/chat", requireAuth, proxyChat); +router.post("/bidsify", requireAuth, proxyChat); // router.get("/tags", requireAuth, getTags); module.exports = router; diff --git a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx index 679d978..63307c9 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx @@ -1,12 +1,11 @@ // src/components/DatasetOrganizer/DropZone.tsx -import { processFile, processFolder, processZip } from "./utils/fileProcessors"; +import { processFile, processFolder, processFolderFromFiles, processZip } from "./utils/fileProcessors"; import { CloudUpload, Add, CheckCircle } from "@mui/icons-material"; import { Box, Typography, Paper, Button, - TextField, CircularProgress, } from "@mui/material"; import { Colors } from "design/theme"; @@ -38,6 +37,7 @@ const DropZone: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [isProcessing, setIsProcessing] = useState(false); // โ† add const fileInputRef = useRef(null); + const folderInputRef = useRef(null); // const [basePath, setBasePath] = useState(""); // change const handleDragOver = (e: React.DragEvent) => { @@ -117,6 +117,24 @@ const DropZone: React.FC = ({ } }; + const handleFolderSelect = async (e: React.ChangeEvent) => { + const selectedFiles = Array.from(e.target.files || []); + if (selectedFiles.length === 0) return; + setIsProcessing(true); + + // Auto-detect root folder name from webkitRelativePath (e.g. "myDataset/sub-01/file.nii") + const rootFolder = selectedFiles[0].webkitRelativePath.split("/")[0]; + if (rootFolder && !baseDirectoryPath) setBaseDirectoryPath(rootFolder); + + try { + const items = await processFolderFromFiles(selectedFiles, baseDirectoryPath); + setFiles((prev) => [...prev, ...items]); + } finally { + setIsProcessing(false); + e.target.value = ""; + } + }; + return ( {/* Show file count if files exist */} @@ -136,7 +154,7 @@ const DropZone: React.FC = ({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} + onClick={(e) => { if (e.target === e.currentTarget) fileInputRef.current?.click(); }} sx={{ border: `2px dashed ${isDragging ? Colors.purple : Colors.lightGray}`, borderRadius: 2, @@ -198,13 +216,24 @@ const DropZone: React.FC = ({ ๐Ÿ“ Folders โ€ข ๐Ÿ—œ๏ธ ZIP files โ€ข ๐Ÿ“„ Documents (.json, .txt, .md) โ€ข ๐Ÿ“Š Office (.docx, .pdf, .xlsx) - + + + + )} = ({ onChange={handleFileSelect} accept=".nii,.nii.gz,.snirf,.h5,.hdf5,.jnii,.jmsh,.json,.txt,.md,.zip,.docx,.pdf,.xlsx,.xls,.mat,.dcm,.nirs" /> + - setBasePath(e.target.value)} //change - value={baseDirectoryPath} // โœ… CHANGE: Use prop - onChange={(e) => setBaseDirectoryPath(e.target.value)} // โœ… CHANGE: Use prop setter - fullWidth - size="small" - sx={{ mb: 2 }} - helperText="Enter the folder path where these files are located" - /> ); }; diff --git a/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx b/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx index 0dbcf90..05b276f 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx @@ -20,6 +20,7 @@ import { Paper, Button, TextField, + Alert, Dialog, DialogTitle, DialogContent, @@ -37,6 +38,8 @@ interface FileTreeProps { setSelectedIds: React.Dispatch>>; expandedIds: Set; setExpandedIds: React.Dispatch>>; + baseDirectoryPath: string; + setBaseDirectoryPath: (path: string) => void; } const FileTree: React.FC = ({ @@ -46,6 +49,8 @@ const FileTree: React.FC = ({ setSelectedIds, expandedIds, setExpandedIds, + baseDirectoryPath, + setBaseDirectoryPath, }) => { const [noteDialogOpen, setNoteDialogOpen] = useState(false); const [editingNoteId, setEditingNoteId] = useState(null); @@ -521,6 +526,20 @@ const FileTree: React.FC = ({ // alignItems: "center", }} > + setBaseDirectoryPath(e.target.value)} + size="small" + sx={{ mb: 0.75 }} + fullWidth + /> + + {baseDirectoryPath + ? <>All dropped files must be inside {baseDirectoryPath} on your machine. + : "All dropped files must be inside this root folder on your machine."} + Virtual File System @@ -635,7 +654,7 @@ const FileTree: React.FC = ({ variant="body2" sx={{ color: Colors.darkPurple, fontWeight: 400 }} > - BIDS Conversion Package Preview + BIDS Conversion Bundle Preview {outputFiles diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index c574bf5..55c4cca 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -12,7 +12,7 @@ import { ContentCopy, Download, AutoAwesome, - DriveFileMove, + AccountTree, InfoOutlined, } from "@mui/icons-material"; import { @@ -29,6 +29,7 @@ import { IconButton, Alert, Tooltip, + Divider, } from "@mui/material"; import { Colors } from "design/theme"; import { dump as yamlDump } from "js-yaml"; @@ -74,20 +75,27 @@ const llmProviders: Record = { ], noApiKey: true, }, - "local-ollama": { + "local-ai": { name: "Local AI (Ollama / LM Studio / Jan)", baseUrl: "http://localhost:11434/v1/chat/completions", models: [ - { id: "llama3.2:latest", name: "Llama 3.2 (Ollama)" }, - { id: "llama3.1:latest", name: "Llama 3.1 (Ollama)" }, - { id: "qwen2.5-coder:latest", name: "Qwen 2.5 Coder (Ollama)" }, - { id: "mistral:latest", name: "Mistral (Ollama)" }, - { id: "gemma3:latest", name: "Gemma 3 (Ollama)" }, - { id: "llama-3.2-3b-instruct", name: "Llama 3.2 3B (LM Studio)" }, - { id: "llama-3.1-8b-instruct", name: "Llama 3.1 8B (LM Studio)" }, - { id: "mistral-7b-instruct-v0.3", name: "Mistral 7B (LM Studio)" }, - { id: "llama3.2:3b", name: "Llama 3.2 3B (Jan)" }, - { id: "mistral:7b", name: "Mistral 7B (Jan)" }, + // Ollama + { id: "qwen3.5:latest", name: "Qwen3.5 (Ollama)" }, + { id: "qwen3:latest", name: "Qwen3 (Ollama)" }, + { id: "qwen3:30b", name: "Qwen3 30B (Ollama)" }, + { id: "deepseek-r1:latest", name: "DeepSeek R1 (Ollama)" }, + { id: "llama4:latest", name: "Llama 4 Scout (Ollama)" }, + { id: "gemma4:latest", name: "Gemma 4 (Ollama)" }, + { id: "gemma4:e4b", name: "Gemma 4 E4B โ€” low RAM (Ollama)" }, + { id: "gpt-oss:20b", name: "GPT-OSS 20B (Ollama)" }, + { id: "qwen2.5-coder:32b", name: "Qwen2.5 Coder 32B (Ollama)" }, + // LM Studio / Jan โ€” use Custom Model Name field if ID differs + { id: "qwen3.5", name: "Qwen3.5 (LM Studio / Jan)" }, + { id: "qwen3-coder-next", name: "Qwen3 Coder Next (LM Studio / Jan)" }, + { id: "llama4-scout-17b", name: "Llama 4 Scout 17B (LM Studio / Jan)" }, + { id: "deepseek-r1", name: "DeepSeek R1 (LM Studio / Jan)" }, + { id: "gemma4", name: "Gemma 4 (LM Studio / Jan)" }, + { id: "phi-4", name: "Phi-4 (LM Studio / Jan)" }, ], noApiKey: true, }, @@ -145,8 +153,8 @@ const LLMPanel: React.FC = ({ onClose, isPrivateMode = false, }) => { - const [provider, setProvider] = useState(isPrivateMode ? "local-ollama" : "ollama"); - const [model, setModel] = useState(isPrivateMode ? "llama3.2:latest" : "qwen3-coder-next:latest"); + const [provider, setProvider] = useState(isPrivateMode ? "local-ai" : "ollama"); + const [model, setModel] = useState(isPrivateMode ? "qwen3.5:latest" : "qwen3-coder-next:latest"); const [localOllamaUrl, setLocalOllamaUrl] = useState("http://localhost:11434"); // const [ollamaUrl, setOllamaUrl] = useState( // "http://jin.neu.edu:11434" @@ -172,12 +180,13 @@ const LLMPanel: React.FC = ({ const [panelHeight, setPanelHeight] = useState(450); const [isResizing, setIsResizing] = useState(false); + // Build LLMConfig for all helper calls โ€” mirrors autobidsify CLI arg assembly const buildLLMConfig = (): LLMConfig => ({ provider, model, apiKey, - baseUrl: provider === "local-ollama" + baseUrl: provider === "local-ai" ? `${localOllamaUrl}/v1/chat/completions` : currentProvider.baseUrl, isAnthropic: currentProvider.isAnthropic, @@ -1427,6 +1436,7 @@ const LLMPanel: React.FC = ({ borderRight: 1, borderColor: "divider", overflow: "auto", + minHeight: 0, }} > @@ -1440,7 +1450,7 @@ const LLMPanel: React.FC = ({ }} > {Object.entries(llmProviders) - .filter(([key]) => isPrivateMode ? key !== "ollama" : key !== "local-ollama") + .filter(([key]) => isPrivateMode ? key !== "ollama" : key !== "local-ai") .map(([key, p]) => ( {p.name} @@ -1472,7 +1482,7 @@ const LLMPanel: React.FC = ({ )} - {provider === "local-ollama" && ( + {provider === "local-ai" && ( = ({ /> )} - {isPrivateMode && provider !== "local-ollama" && ( + {isPrivateMode && provider !== "local-ai" && ( Your file information will be sent to {currentProvider.name}, an external AI service. Switch to Local AI (Ollama / LM Studio / Jan) to keep everything local. )} - {provider === "local-ollama" && ( - setLocalOllamaUrl(e.target.value)} - placeholder="http://localhost:11434" - helperText="Ollama: port 11434 ยท LM Studio: port 1234 ยท Jan: port 1337" - sx={{ mb: 2 }} - /> + {provider === "local-ai" && ( + <> + setLocalOllamaUrl(e.target.value)} + placeholder="http://localhost:11434" + helperText="Ollama: port 11434 ยท LM Studio: port 1234 ยท Jan: port 1337" + sx={{ mb: 1 }} + /> + + To allow your local AI to accept requests from this website, add https://neurojson.io to its allowed origins list. + + )} {/* Base Directory Path field (shows for ALL providers) */} = ({ onClick={handleGeneratePlan} disabled={loading || !baseDirectoryPath.trim() || !trioGenerated} sx={{ + textTransform: "none", background: `linear-gradient(135deg, ${Colors.purple} 0%, ${Colors.secondaryPurple} 100%)`, "&:hover": { background: `linear-gradient(135deg, ${Colors.secondaryPurple} 0%, ${Colors.purple} 100%)`, @@ -1688,7 +1704,7 @@ const LLMPanel: React.FC = ({ "&.Mui-disabled": { background: "#e0e0e0", color: "#9e9e9e" }, }} > - {loading ? "Generating..." : "3. Generate Conversion Package"} + {loading ? "Generating..." : "3. Generate Conversion Bundle"} {/* + + + 2. + + + Skip if already downloaded + + + + 3. + + Open the Converter, select the unzipped conversion bundle folder and your raw data folder, then click Run. + + + + + )} + {/* */} + {/* Download zip file for convert โ€” moved to Next Steps card above + */} diff --git a/src/components/User/Dashboard/DatasetOrganizer/index.tsx b/src/components/User/Dashboard/DatasetOrganizer/index.tsx index 796fbaa..bb1d332 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/index.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/index.tsx @@ -2,7 +2,7 @@ import DropZone from "./DropZone"; import FileTree from "./FileTree"; import LLMPanel from "./LLMPanel"; import { generateId } from "./utils/fileProcessors"; -import { ArrowBack, Save, GetApp, Psychology } from "@mui/icons-material"; +import { ArrowBack, Save, GetApp, Psychology, InfoOutlined } from "@mui/icons-material"; import { Box, Button, @@ -16,6 +16,7 @@ import { DialogContentText, Chip, Tooltip, + IconButton, } from "@mui/material"; import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; @@ -314,6 +315,52 @@ const DatasetOrganizer: React.FC = () => { }} /> + + How to use: + +
  • Drop your dataset files into the workspace.
  • +
  • Enter the number of subjects, modality, and base directory path.
  • +
  • The AI will analyze your files and generate a BIDS conversion plan.
  • +
  • Download the conversion bundle and the AutoBIDSify Converter, then run the Converter locally to reorganize your data into BIDS format.
  • +
    + navigate("/about")} + sx={{ mt: 1, color: Colors.purple, cursor: "pointer", textDecoration: "underline" }} + > + Watch video tutorial + + + } + placement="bottom-start" + arrow + componentsProps={{ + tooltip: { + sx: { + backgroundColor: "white", + color: Colors.darkPurple, + border: `1px solid ${Colors.lightGray}`, + boxShadow: 3, + fontSize: "0.875rem", + lineHeight: 1.5, + p: 1.5, + maxWidth: 320, + }, + }, + arrow: { + sx: { + color: "white", + "&::before": { border: `1px solid ${Colors.lightGray}` }, + }, + }, + }} + > + + + +
    @@ -420,9 +467,11 @@ const DatasetOrganizer: React.FC = () => { files={files} selectedIds={selectedIds} expandedIds={expandedIds} - setFiles={updateFiles} // Pass wrapper instead - setSelectedIds={updateSelectedIds} // Pass wrapper - setExpandedIds={updateExpandedIds} // Pass wrapper + setFiles={updateFiles} + setSelectedIds={updateSelectedIds} + setExpandedIds={updateExpandedIds} + baseDirectoryPath={baseDirectoryPath} + setBaseDirectoryPath={updateBaseDirectoryPath} /> diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts index 8e66d22..aacea87 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts @@ -170,11 +170,8 @@ export const processFile = async ( entry.content = extractExcelContent(buffer); entry.contentType = "office"; } else if (fileType === "matlab") { - entry.content = `MATLAB File: ${file.name}\nSize: ${( - file.size / 1024 - ).toFixed( - 2 - )} KB\nFormat: .mat (fNIRS data โ€” will be converted to SNIRF by autobidsify)`; + const buffer = await file.arrayBuffer(); + entry.content = parseMatlabFile(buffer, file.name); entry.contentType = "matlab"; } else if (fileType === "dicom") { // entry.content = `DICOM File: ${file.name}\nSize: ${( @@ -216,6 +213,51 @@ export const processFile = async ( }; // Process ZIP files +// Builds a FileItem tree from File[] with webkitRelativePath (folder picker input) +export const processFolderFromFiles = async ( + files: File[], + basePath?: string +): Promise => { + const allItems: FileItem[] = []; + const pathMap: Record = {}; // folder path โ†’ id + + const sorted = [...files].sort((a, b) => + a.webkitRelativePath.localeCompare(b.webkitRelativePath) + ); + + for (const file of sorted) { + const relPath = file.webkitRelativePath || file.name; + const parts = relPath.split("/"); + + // Build folder hierarchy for each path segment except the filename + let parentId: string | null = null; + let currentPath = ""; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + currentPath = currentPath ? `${currentPath}/${part}` : part; + if (!pathMap[currentPath]) { + const folderId = generateId(); + pathMap[currentPath] = folderId; + allItems.push({ + id: folderId, + name: part, + type: "folder", + parentId, + sourcePath: currentPath, + } as FileItem); + } + parentId = pathMap[currentPath]; + } + + // Process the file itself + const fileItem = await processFile(file, basePath); + fileItem.parentId = parentId; + allItems.push(fileItem); + } + + return allItems; +}; + export const processZip = async ( file: File, basePath?: string @@ -399,11 +441,9 @@ export const processZip = async ( entry.content = `Binary NeuroJSON: ${fileName}\nSize: ${sizeKB} KB\nFormat: BJData`; entry.contentType = "neurojson"; } - // matlab placeholder else if (fileType === "matlab") { const arrayBuffer = await zipEntry.async("arraybuffer"); - const sizeKB = (arrayBuffer.byteLength / 1024).toFixed(2); - entry.content = `MATLAB File: ${fileName}\nSize: ${sizeKB} KB\nFormat: .mat (fNIRS data โ€” will be converted to SNIRF by autobidsify)`; + entry.content = parseMatlabFile(arrayBuffer, fileName); entry.contentType = "matlab"; } // dicom header extraction from ZIP @@ -626,6 +666,63 @@ export const parseNiftiHeader = (buffer: ArrayBuffer): any => { } }; +// Parse .mat file โ€” v7.3 files are HDF5; older v5 files are not supported +const parseMatlabFile = (buffer: ArrayBuffer, fileName: string): string => { + // Check magic bytes: v7.3 starts with "MATLAB 7.3" in the first 116 bytes + const header = new Uint8Array(buffer.slice(0, 116)); + const headerStr = String.fromCharCode(...header.slice(0, 10)); + const isV73 = headerStr.startsWith("MATLAB 7.3"); + + if (!isV73) { + // v5 .mat โ€” can't parse in browser, report what we know + const sizeKB = (buffer.byteLength / 1024).toFixed(2); + return `MATLAB File: ${fileName}\nSize: ${sizeKB} KB\nFormat: .mat v5 (older format โ€” variable names not readable in browser)\nNote: autobidsify will convert this to SNIRF locally`; + } + + // v7.3 is HDF5 โ€” parse with jsfive + try { + const tree = parseHDF5Tree(buffer); + if (tree.error) { + return `MATLAB File: ${fileName}\nFormat: .mat v7.3 (HDF5)\nError reading contents: ${tree.error}`; + } + + const sizeKB = (buffer.byteLength / 1024).toFixed(2); + let result = `MATLAB File: ${fileName}\nSize: ${sizeKB} KB\nFormat: .mat v7.3 (HDF5)\n\nVariables:\n`; + + const vars = tree.children || []; + for (const v of vars) { + if (v.name === "#refs#") continue; // internal HDF5 reference group + if (v.type === "dataset") { + result += ` ${v.name}: shape=[${(v.shape || []).join("ร—")}] dtype=${v.dtype || "?"}`; + if (v.value !== undefined) { + const valStr = Array.isArray(v.value) + ? `[${v.value.slice(0, 5).join(", ")}${v.value.length > 5 ? "..." : ""}]` + : String(v.value).slice(0, 60); + result += ` = ${valStr}`; + } + result += "\n"; + } else if (v.type === "group") { + result += ` ${v.name}/ (group with ${(v.children || []).length} fields)\n`; + for (const field of (v.children || []).slice(0, 10)) { + result += ` ${field.name}`; + if (field.shape) result += `: [${field.shape.join("ร—")}]`; + if (field.value !== undefined) { + const valStr = Array.isArray(field.value) + ? `[${field.value.slice(0, 5).join(", ")}${field.value.length > 5 ? "..." : ""}]` + : String(field.value).slice(0, 60); + result += ` = ${valStr}`; + } + result += "\n"; + } + if ((v.children || []).length > 10) result += ` ... (${v.children.length - 10} more)\n`; + } + } + return result; + } catch (e: any) { + return `MATLAB File: ${fileName}\nFormat: .mat v7.3\nError: ${e.message}`; + } +}; + // Parse HDF5/SNIRF tree structure const parseHDF5Tree = (buffer: ArrayBuffer): any => { try { diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts index 4e17c34..ca06785 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts @@ -123,11 +123,10 @@ export const callLLM = async ( ): Promise => { const { provider, model, apiKey, baseUrl, isAnthropic, noApiKey } = llmConfig; - // โ”€โ”€ Qwen via Ollama proxy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Mirrors _call_qwen() โ†’ _call_qwen_ollama() / _call_qwen_rest_api() - // Python supports local Ollama + REST API + DashScope. - // TS only supports the REST API proxy (OllamaService routes to jin.neu.edu:11434). - if (provider === "ollama" || isQwenModel(model)) { + // โ”€โ”€ Backend Ollama proxy (save mode only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Routes to OllamaService โ†’ jin.neu.edu:11434. + // "local-ai" falls through to the OpenAI-compatible block below. + if (provider === "ollama") { const temp = inferQwenTemperature(model, temperature); try { const res = await OllamaService.chat( diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index cb98db6..da25ba5 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -398,6 +398,12 @@ const AboutPage: React.FC = () => { videoUrl="https://neurojson.io/io/download/static/videos/convert.mp4" /> + + + diff --git a/src/pages/BidsConverterPage.tsx b/src/pages/BidsConverterPage.tsx index 54bc8aa..00f3993 100644 --- a/src/pages/BidsConverterPage.tsx +++ b/src/pages/BidsConverterPage.tsx @@ -90,6 +90,19 @@ const BidsConverterPage: React.FC = () => { const updateBaseDirectoryPath = (path: string) => setBaseDirectoryPath(path); const handleExportJSON = () => { + // Strip the leading folder name from sourcePath if it duplicates the last + // segment of baseDirectoryPath (e.g. user typed /Desktop/dataset1 and + // sourcePath is dataset1/file.dcm โ†’ result should be /Desktop/dataset1/file.dcm) + const resolveSourcePath = (sourcePath: string | undefined, fallback: string): string => { + const raw = sourcePath || fallback; + if (!baseDirectoryPath) return raw; + const base = baseDirectoryPath.replace(/\/+$/, ""); + const baseName = base.split("/").pop() || ""; + if (baseName && raw === baseName) return base; + if (baseName && raw.startsWith(baseName + "/")) return `${base}/${raw.slice(baseName.length + 1)}`; + return `${base}/${raw}`.replace(/\/+/g, "/"); + }; + const buildTree = (parentId: string | null): any => { const children = files.filter((f) => f.parentId === parentId); const result: any = {}; @@ -97,9 +110,7 @@ const BidsConverterPage: React.FC = () => { if (child.type === "folder" || child.type === "zip") { result[child.name] = { _type: child.type, - _sourcePath: baseDirectoryPath - ? `${baseDirectoryPath}/${child.sourcePath || child.name}`.replace(/\/+/g, "/") - : child.sourcePath || "", + _sourcePath: resolveSourcePath(child.sourcePath, child.name), _children: buildTree(child.id), }; } else { @@ -108,9 +119,7 @@ const BidsConverterPage: React.FC = () => { _fileType: child.fileType || "other", }; if (child.sourcePath || baseDirectoryPath) { - fileData._sourcePath = baseDirectoryPath - ? `${baseDirectoryPath}/${child.sourcePath || child.name}`.replace(/\/+/g, "/") - : child.sourcePath; + fileData._sourcePath = resolveSourcePath(child.sourcePath, child.name); } if (child.isUserMeta) fileData._isUserMeta = true; if (child.content) fileData._content = child.content; @@ -181,7 +190,14 @@ const BidsConverterPage: React.FC = () => {
  • Drop your dataset files into the workspace.
  • Enter the number of subjects, modality, and base directory path.
  • The AI will analyze your files and generate a BIDS conversion plan.
  • -
  • Download and run the script locally to reorganize your data into BIDS format.
  • +
  • Download the conversion bundle and the AutoBIDSify Converter, then run the Converter locally to reorganize your data into BIDS format.
  • + + navigate("/about")} + sx={{ mt: 1, color: Colors.purple, cursor: "pointer", textDecoration: "underline" }} + > + Watch video tutorial } @@ -227,7 +243,7 @@ const BidsConverterPage: React.FC = () => { - +