UNPKG

@knide/fs-prober

Version:

fs-prober is a browser-friendly NPM package for extracting file and folder structures from user-selected files or directories.

331 lines (288 loc) 10.9 kB
import type { FileWithPath } from "react-dropzone" import { getNameId } from "./probers" import { getFile } from "./proberWebApi" import { type FileNode, type FileNodeWithoutHandle, type FolderNode, type FolderNodeWithoutHandle, type HierarchyDetailsWithoutHandles, isFolderNode, } from "./types" import { normalizedPath as nP } from "./utils" export const getFilesArrFromHierarchyFiles = async ( hierarchyFiles: FileNode[] | undefined, ): Promise<File[] | null> => { if (!hierarchyFiles) return null const proberFilesData = [] for (const file of hierarchyFiles) { const fileObj = await getFile(file.handle) Object.defineProperty(fileObj, "path", { value: file.path }) // This is the original path. path property becomes read-only here. proberFilesData.push(fileObj) } return proberFilesData } const isRootFile = (f: { path?: string; name: string }) => { try { if (!f.path) throw new Error("isRootFile: no path in file") const path = f.path.replace("/", "") const name = f.name.replace("/", "") return path === name } catch (e) { console.error(e) console.warn("isRootFile: ", f) throw e } } const generateFolders = ( allFiles: FileNodeWithoutHandle[], nameMap: HierarchyDetailsWithoutHandles["nameMap"], objectMap: HierarchyDetailsWithoutHandles["objectMap"], ) => { const rootFolders: HierarchyDetailsWithoutHandles["rootFolders"] = [] const allFolders: HierarchyDetailsWithoutHandles["allFolders"] = [] const reservedFolderPaths: Map<string, string> = new Map() // {path: nameId} const createFoldersRecursively = ( pathParts: string[], child: FileNodeWithoutHandle | FolderNodeWithoutHandle<FileNodeWithoutHandle>, ) => { if (!pathParts.length) return { pathIds: [] } // this is a root file const path = pathParts.join("/") if (reservedFolderPaths.has(path)) { const nameId = reservedFolderPaths.get(path) if (nameId) { const hierarchyDetailsNode = objectMap.get(nameId) if (child && hierarchyDetailsNode && isFolderNode(hierarchyDetailsNode)) { hierarchyDetailsNode.children.push(child) } return { pathIds: [nameId] } } else { throw new Error(`Unreachable code: nameId not found for path: ${path}`) } } const folderName = pathParts[pathParts.length - 1] || "" const nameId = getNameId(folderName, nameMap) const folder: FolderNodeWithoutHandle<FileNodeWithoutHandle> = { name: folderName, nameId, kind: "directory", isBranch: true, path, children: child ? [child] : [], pathIds: [], } objectMap.set(nameId, folder) allFolders.push(folder) reservedFolderPaths.set(path, nameId) if (pathParts.length > 1) { const nextPathParts = pathParts.slice(0, -1) // Remove the last part (processed folder name) const { pathIds } = createFoldersRecursively(nextPathParts, folder) folder.pathIds = [...pathIds, nameId] return { pathIds: folder.pathIds } } else { // This is a root folder rootFolders.push(folder) folder.pathIds = [nameId] return { pathIds: folder.pathIds } } } for (const file of allFiles) { if (isRootFile(file)) continue const path = nP(file.path) const pathParts = path.split("/").filter(Boolean).slice(0, -1) const { pathIds } = createFoldersRecursively(pathParts, file) file.pathIds = [...pathIds, file.nameId] } return [allFolders, rootFolders] as const } export const getHierarchyDetailsFromFiles = ( filesArr: FileWithPath[], ): HierarchyDetailsWithoutHandles | null => { if (!filesArr) return null const nameMap: HierarchyDetailsWithoutHandles["nameMap"] = new Map() const objectMap: Map<string, FileNodeWithoutHandle | FolderNode> = new Map() const rootFiles: FileNodeWithoutHandle[] = [] const allFiles = filesArr.map((f) => { const nameId = getNameId(f.name, nameMap) const path = nP(f.webkitRelativePath || f.path) const file: FileNodeWithoutHandle = { path, name: path.split("/").pop() || f.name, kind: "file", isBranch: false, pathIds: [nameId], nameId: nameId, // handle: undefined, // TODO: find a way to get FileEntry handle from File object (how to get a ev.dataTransfer from <Dropzone /> component?) } if (isRootFile(f)) rootFiles.push(file) objectMap.set(nameId, file) return file }) const [allFolders, rootFolders] = // generateFolders(allFiles, nameMap, objectMap) const hierarchyDetails: HierarchyDetailsWithoutHandles = { emptyFolders: [], allFolders, rootFolders, allFiles, rootFiles, nameMap, objectMap, // rootHandles: [...rootFiles, ...rootFolders].map(() => null), // TODO } return hierarchyDetails } export const filterFiles = (itemsArray: (FileWithPath | DataTransferItem)[]): FileWithPath[] => { return itemsArray.filter((item): item is FileWithPath => { // Check if the item is a FileWithPath if (item instanceof File) { return true } // Check if the item is a DataTransferItem and contains a File if (item instanceof DataTransferItem && item.kind === "file") { const file = item.getAsFile() return file instanceof File } return false }) } const isEmptyFolderAsFolderNode = ( folderNode: FileWithPath | FolderNode, ): folderNode is FolderNode => { if (!("kind" in folderNode)) return false return folderNode.kind === "directory" && folderNode.children.length === 0 } const isFile = (file: FileWithPath | FolderNode): file is FileWithPath => { return "path" in file } /** Converts an array of react-dropzone `File` objects and HierarchyDetails objects to a DataTransfer `FileList` */ export const convertToFileList = (fileArray: readonly FileWithPath[] | FolderNode[]): FileList => { const dataTransfer = new DataTransfer() fileArray.forEach((file) => { let newDtFile: File = new File([], file.name) if (!(file instanceof File) && isEmptyFolderAsFolderNode(file)) { // TODO: Is this block even needed?? // If the object is not a File instance, then it must be an "empty directory" object from hierarchyDetails.emptyFolders. So create one const newFile = new File([], file.name) // Copy additional properties from the original object to the new File instance for (const key in file) { // @ts-ignore const shouldCopyProperty = file[key] && !newFile[key] if (shouldCopyProperty && key !== "name") { // @ts-ignore newFile[key] = file[key] } } newDtFile = newFile } // Check if the 'path' property exists and then set it to webkitRelativePath if its empty if (isFile(file) && file.path !== "" && file.webkitRelativePath === "") { const value = getWebkitRelativePath(file.path) const newFile = updateWebkitRelativePath(file, value) newDtFile = newFile } dataTransfer.items.add(newDtFile) }) const fileListObj = dataTransfer.files return fileListObj } const getWebkitRelativePath = (str?: string) => { if (!str) return "" const path = str.startsWith("/") ? str.substring(1) : str // Remove leading slash const isFile = !path.includes("/") return isFile ? "" : path } /** Returns the original file path which is derived from `path` and `file.name` */ export const getOriginalFilePath = (file: File, path: string): string => { const pathParts = path.split("/").filter(Boolean) // /a/b/cd (1).mp4 => ['a', 'b', 'cd (1).mp4'] // replace the last pathPart with the original file name pathParts[pathParts.length - 1] = file.name return pathParts.join("/") } export const updateWebkitRelativePath = (file: FileWithPath, path: string): File => { const origPath = getOriginalFilePath(file, path) try { Object.defineProperty(file, "webkitRelativePath", { value: path }) // Now, webkitRelativePath's read-only if (file.path === undefined) Object.defineProperty(file, "path", { value: origPath }) return file } catch (e) { if (e instanceof TypeError && e.message.includes("Cannot redefine property")) { // If it's not possible to redefine the path/webkitRelativePath property, create a new File object const newFile = addFileProperties(file, { webkitRelativePath: path, path: origPath, }) return newFile } else { console.error("Unexpected error: ", e) throw e } } } export const addFileProperties = ( file: FileWithPath, // biome-ignore lint/suspicious/noExplicitAny: Any key value pair is allowed propertiesObject: Record<string, any>, ): File => { try { for (const [property, value] of Object.entries(propertiesObject)) { Object.defineProperty(file, property, { value }) } return file } catch (e) { const isRedefineErr = e instanceof TypeError && (e.message.includes("Cannot redefine property") || // chrome e.message.includes("can't redefine non-configurable property") || // firefox e.message.includes("Attempting to change value of a readonly property")) // safari if (isRedefineErr) { // If it's not possible to redefine the `property`, create a new File object const newFile = new File([file], file.name, { type: file.type, lastModified: file.lastModified, }) const allPrevKeys = [ ...Object.getOwnPropertyNames(file), ...Object.getOwnPropertyNames(Object.getPrototypeOf(file)), ] const prevPropertiesObject = {} for (const key of allPrevKeys) { // @ts-ignore const val = file[key] if (typeof val === "function" || val === undefined) continue // @ts-ignore prevPropertiesObject[key] = val } const fileProps = { ...prevPropertiesObject, path: file.path, webkitRelativePath: file.webkitRelativePath, ...propertiesObject, } for (const [prop, value] of Object.entries(fileProps)) { Object.defineProperty(newFile, prop, { value }) } return newFile } else { console.error("Unexpected error: ", e) throw e } } } const removeLeadingSlash = (path: string | undefined) => { if (!path) return "" let newPath = path if (path.startsWith("./")) newPath = path.substring(2) // Remove leading ./ if (path.startsWith("/")) newPath = path.substring(1) // Remove leading slash return newPath } export const fixFilePathLeadingSlashes = (files: FileWithPath[]): File[] => { return files.map((file) => { const path = removeLeadingSlash(file.path || file.relativePath || file.webkitRelativePath) const newFile = addFileProperties(file, { webkitRelativePath: path, path: getOriginalFilePath(file, path), }) return newFile }) }