/** * File loading and format detection utilities for Niivue. * This module provides pure functions for file extension handling, * format detection, custom loaders, and media URL management. */ import type { NVImage } from '@/nvimage' import type { NVMesh } from '@/nvmesh' import { log } from '@/logger' /** * DICOM loader input types */ export type DicomLoaderInput = ArrayBuffer | ArrayBuffer[] | File[] /** * DICOM loader configuration */ export type DicomLoader = { loader: (data: DicomLoaderInput) => Promise> toExt: string } /** * Mesh data returned by custom mesh loaders */ export interface MeshLoaderResult { positions: Float32Array indices: Uint32Array colors?: Float32Array | null } /** * Custom file loader configuration. * The loader function can return either: * - ArrayBuffer for volume data * - MeshLoaderResult for mesh data with positions and indices */ export interface CustomLoader { // eslint-disable-next-line @typescript-eslint/no-explicit-any loader: (data: string | Uint8Array | ArrayBuffer) => Promise toExt: string } /** * Collection of registered custom loaders by file extension */ export interface LoaderRegistry { [extension: string]: CustomLoader } /** * Mesh file extensions supported by Niivue */ export const MESH_EXTENSIONS = [ 'ASC', 'BYU', 'DFS', 'FSM', 'PIAL', 'ORIG', 'INFLATED', 'SMOOTHWM', 'SPHERE', 'WHITE', 'G', 'GEO', 'GII', 'ICO', 'MZ3', 'NV', 'OBJ', 'OFF', 'PLY', 'SRF', 'STL', 'TCK', 'TRACT', 'TRI', 'TRK', 'TT', 'TRX', 'VTK', 'WRL', 'X3D', 'JCON', 'JSON' ] /** * Options for getFileExt function */ export interface GetFileExtOptions { fullname: string upperCase?: boolean } /** * Extracts and normalizes the file extension, handling special cases like .gz and .cbor. * @param options - Options containing fullname and optional upperCase flag * @returns The normalized file extension */ export function getFileExt(options: GetFileExtOptions): string /** * Extracts and normalizes the file extension, handling special cases like .gz and .cbor. * @param fullname - The full filename or path * @param upperCase - Whether to return uppercase extension (default: true) * @returns The normalized file extension */ export function getFileExt(fullname: string, upperCase?: boolean): string export function getFileExt(fullnameOrOptions: string | GetFileExtOptions, upperCase = true): string { let fullname: string if (typeof fullnameOrOptions === 'object') { fullname = fullnameOrOptions.fullname upperCase = fullnameOrOptions.upperCase ?? true } else { fullname = fullnameOrOptions } log.debug('fullname: ', fullname) const re = /(?:\.([^.]+))?$/ let ext = re.exec(fullname)![1] ext = ext.toUpperCase() if (ext === 'GZ') { // Handle gzipped files: img.trk.gz -> trk ext = re.exec(fullname.slice(0, -3))![1] ext = ext.toUpperCase() } else if (ext === 'CBOR') { // Handle cbor files: img.iwi.cbor -> IWI.CBOR const endExt = ext ext = re.exec(fullname.slice(0, -5))![1] ext = ext.toUpperCase() ext = `${ext}.${endExt}` } return upperCase ? ext : ext.toLowerCase() } /** * Check if a URL/filename has a mesh file extension * @param url - The URL or filename to check * @returns True if the extension indicates a mesh file */ export function isMeshExt(url: string): boolean { const ext = getFileExt(url) log.debug('checking mesh ext:', ext) return MESH_EXTENSIONS.includes(ext) } /** * Get media (volume or mesh) by URL from a media URL map * @param url - URL to search for * @param mediaUrlMap - Map of media objects to URLs * @returns The media object if found, undefined otherwise */ export function getMediaByUrl(url: string, mediaUrlMap: Map): NVImage | NVMesh | undefined { return [...mediaUrlMap.entries()] .filter((v) => v[1] === url) .map((v) => v[0]) .pop() } /** * Parameters for registerLoader */ export interface RegisterLoaderParams { loaders: LoaderRegistry // eslint-disable-next-line @typescript-eslint/no-explicit-any loader: (data: string | Uint8Array | ArrayBuffer) => Promise fileExt: string toExt: string } /** * Register a custom loader for a specific file extension * @param params - Parameters object * @returns New loader registry with the added loader */ export function registerLoader(params: RegisterLoaderParams): LoaderRegistry { const { loaders, loader, fileExt, toExt } = params return { ...loaders, [fileExt.toUpperCase()]: { loader, toExt } } } /** * Get a loader for a specific file extension * @param loaders - Loader registry * @param ext - File extension to look up * @returns The loader configuration if found, undefined otherwise */ export function getLoader(loaders: LoaderRegistry, ext: string): CustomLoader | undefined { return loaders[ext.toUpperCase()] } /** * Check if a DICOM loader error should be thrown * @param ext - File extension * @returns True if extension is DCM */ export function isDicomExtension(ext: string): boolean { return ext.toUpperCase() === 'DCM' } /** * Recursively traverse a file tree and collect all files * @param item - FileSystemEntry to traverse * @param path - Current path prefix * @param fileArray - Array to accumulate files * @returns Promise resolving to array of files with fullPath and _webkitRelativePath properties */ export async function traverseFileTree(item: FileSystemEntry, path = '', fileArray: File[]): Promise { return new Promise((resolve) => { if (item.isFile) { ;(item as FileSystemFileEntry).file((file: File & { fullPath?: string; _webkitRelativePath?: string }) => { file.fullPath = path + file.name // IMPORTANT: _webkitRelativePath is required for dcm2niix to work. // We need to add this property so we can parse multiple directories correctly. // the "webkitRelativePath" property on File objects is read-only, so we can't set it directly. file._webkitRelativePath = path + file.name fileArray.push(file) resolve(fileArray) }) } else if (item.isDirectory) { const dirReader = (item as FileSystemDirectoryEntry).createReader() const readAllEntries = (): void => { dirReader.readEntries((entries) => { if (entries.length > 0) { const promises = entries.map((entry) => traverseFileTree(entry, path + item.name + '/', fileArray)) Promise.all(promises) .then(readAllEntries) .catch((e) => { throw e }) } else { resolve(fileArray) } }) } readAllEntries() } }) } /** * Read all entries from a directory * @param directory - FileSystemDirectoryEntry to read * @returns Array of file system entries (accumulated asynchronously) */ export function readDirectory(directory: FileSystemDirectoryEntry): FileSystemEntry[] { const reader = directory.createReader() let allEntriesInDir: FileSystemEntry[] = [] const getFileObjects = async (fileSystemEntries: FileSystemEntry[]): Promise => { const allFileObjects: File[] = [] const getFile = async (fileEntry: FileSystemFileEntry): Promise => { return new Promise((resolve, reject) => fileEntry.file(resolve, reject)) } for (let i = 0; i < fileSystemEntries.length; i++) { allFileObjects.push(await getFile(fileSystemEntries[i] as FileSystemFileEntry)) } return allFileObjects } const readEntries = (): void => { reader.readEntries((entries) => { if (entries.length) { allEntriesInDir = allEntriesInDir.concat(entries) readEntries() } else { getFileObjects(allEntriesInDir) .then(async () => {}) .catch((e) => { throw e }) } }) } readEntries() return allEntriesInDir } /** * Read a file as a data URL * @param entry - FileSystemFileEntry to read * @returns Promise resolving to data URL string */ export function readFileAsDataURL(entry: FileSystemFileEntry): Promise { return new Promise((resolve, reject) => { entry.file( (file) => { const reader = new FileReader() reader.onload = (): void => resolve(reader.result as string) reader.onerror = (): void => reject(new Error('Failed to read file as data URL')) reader.readAsDataURL(file) }, (err) => reject(err) ) }) } /** * Simple drag enter event handler that prevents default behavior * @param e - Mouse event */ export function handleDragEnter(e: MouseEvent): void { e.stopPropagation() e.preventDefault() } /** * Simple drag over event handler that prevents default behavior * @param e - Mouse event */ export function handleDragOver(e: MouseEvent): void { e.stopPropagation() e.preventDefault() }