@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
text/typescript
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
})
}