306 lines
8.5 KiB
TypeScript
306 lines
8.5 KiB
TypeScript
|
import type Stats from "browserfs/dist/node/core/node_fs_stats";
|
||
|
import type {
|
||
|
FileReaders,
|
||
|
ObjectReaders,
|
||
|
} from "components/system/Dialogs/Transfer/useTransferDialog";
|
||
|
import { getModifiedTime } from "components/system/Files/FileEntry/functions";
|
||
|
import type {
|
||
|
CompleteAction,
|
||
|
Files,
|
||
|
} from "components/system/Files/FileManager/useFolder";
|
||
|
import { COMPLETE_ACTION } from "components/system/Files/FileManager/useFolder";
|
||
|
import type { SortBy } from "components/system/Files/FileManager/useSortBy";
|
||
|
import { basename, dirname, extname, join } from "path";
|
||
|
import { ONE_TIME_PASSIVE_EVENT, ROOT_SHORTCUT } from "utils/constants";
|
||
|
import { haltEvent } from "utils/functions";
|
||
|
|
||
|
export type FileStat = Stats & {
|
||
|
systemShortcut?: boolean;
|
||
|
};
|
||
|
|
||
|
type FileStats = [string, FileStat];
|
||
|
|
||
|
type SortFunction = (a: FileStats, b: FileStats) => number;
|
||
|
|
||
|
export const sortByDate =
|
||
|
(directory: string) =>
|
||
|
([aPath, aStats]: FileStats, [bPath, bStats]: FileStats): number =>
|
||
|
getModifiedTime(join(directory, aPath), aStats) -
|
||
|
getModifiedTime(join(directory, bPath), bStats);
|
||
|
|
||
|
const sortByName = ([a]: FileStats, [b]: FileStats): number =>
|
||
|
a.localeCompare(b, "en", { sensitivity: "base" });
|
||
|
|
||
|
export const sortBySize = (
|
||
|
[, { size: aSize }]: FileStats,
|
||
|
[, { size: bSize }]: FileStats
|
||
|
): number => aSize - bSize;
|
||
|
|
||
|
const sortByType = ([a]: FileStats, [b]: FileStats): number =>
|
||
|
extname(a).localeCompare(extname(b), "en", { sensitivity: "base" });
|
||
|
|
||
|
const sortSystemShortcuts = (
|
||
|
[aName, { systemShortcut: aSystem = false }]: FileStats,
|
||
|
[bName, { systemShortcut: bSystem = false }]: FileStats
|
||
|
): number => {
|
||
|
if (aSystem === bSystem) {
|
||
|
if (bSystem && bName === ROOT_SHORTCUT) return 1;
|
||
|
|
||
|
return aSystem && aName === ROOT_SHORTCUT ? -1 : 0;
|
||
|
}
|
||
|
|
||
|
return aSystem ? -1 : 1;
|
||
|
};
|
||
|
|
||
|
export const sortContents = (
|
||
|
contents: Files,
|
||
|
sortOrder: string[],
|
||
|
sortFunction?: SortFunction,
|
||
|
ascending = true
|
||
|
): Files => {
|
||
|
if (sortOrder.length > 0) {
|
||
|
const contentOrder = Object.keys(contents);
|
||
|
|
||
|
return Object.fromEntries(
|
||
|
sortOrder
|
||
|
.filter((entry) => contentOrder.includes(entry))
|
||
|
// eslint-disable-next-line unicorn/prefer-spread
|
||
|
.concat(contentOrder.filter((entry) => !sortOrder.includes(entry)))
|
||
|
.map((entry) => [entry, contents[entry]])
|
||
|
);
|
||
|
}
|
||
|
|
||
|
const files: FileStats[] = [];
|
||
|
const folders: FileStats[] = [];
|
||
|
|
||
|
Object.entries(contents).forEach((entry) => {
|
||
|
const [, stat] = entry;
|
||
|
|
||
|
if (stat.isDirectory()) folders.push(entry);
|
||
|
else files.push(entry);
|
||
|
});
|
||
|
|
||
|
const sortContent = (fileStats: FileStats[]): FileStats[] => {
|
||
|
fileStats.sort(sortByName);
|
||
|
|
||
|
return sortFunction && sortFunction !== sortByName
|
||
|
? fileStats.sort(sortFunction)
|
||
|
: fileStats;
|
||
|
};
|
||
|
const sortedFolders = sortContent(folders);
|
||
|
const sortedFiles = sortContent(files);
|
||
|
|
||
|
if (!ascending) {
|
||
|
sortedFolders.reverse();
|
||
|
sortedFiles.reverse();
|
||
|
}
|
||
|
|
||
|
return Object.fromEntries(
|
||
|
(ascending
|
||
|
? [...sortedFolders, ...sortedFiles]
|
||
|
: [...sortedFiles, ...sortedFolders]
|
||
|
).sort(sortSystemShortcuts)
|
||
|
);
|
||
|
};
|
||
|
|
||
|
export const sortFiles = (
|
||
|
directory: string,
|
||
|
files: Files,
|
||
|
sortBy: SortBy,
|
||
|
ascending: boolean
|
||
|
): Files => {
|
||
|
const sortFunctionMap: Record<string, SortFunction> = {
|
||
|
date: sortByDate(directory),
|
||
|
name: sortByName,
|
||
|
size: sortBySize,
|
||
|
type: sortByType,
|
||
|
};
|
||
|
|
||
|
return sortBy in sortFunctionMap
|
||
|
? sortContents(files, [], sortFunctionMap[sortBy], ascending)
|
||
|
: files;
|
||
|
};
|
||
|
|
||
|
export const iterateFileName = (name: string, iteration: number): string => {
|
||
|
const extension = extname(name);
|
||
|
const fileName = basename(name, extension);
|
||
|
|
||
|
return `${fileName} (${iteration})${extension}`;
|
||
|
};
|
||
|
|
||
|
export const createFileReaders = async (
|
||
|
files: DataTransferItemList | FileList | never[],
|
||
|
directory: string,
|
||
|
callback: (
|
||
|
fileName: string,
|
||
|
buffer?: Buffer,
|
||
|
completeAction?: CompleteAction
|
||
|
) => void
|
||
|
): Promise<FileReaders> => {
|
||
|
const fileReaders: FileReaders = [];
|
||
|
const addFile = (file: File, subFolder = ""): void => {
|
||
|
const reader = new FileReader();
|
||
|
|
||
|
reader.addEventListener(
|
||
|
"load",
|
||
|
({ target }) => {
|
||
|
if (target?.result instanceof ArrayBuffer) {
|
||
|
callback(
|
||
|
join(subFolder, file.name),
|
||
|
Buffer.from(target.result),
|
||
|
files.length === 1 ? COMPLETE_ACTION.UPDATE_URL : undefined
|
||
|
);
|
||
|
}
|
||
|
},
|
||
|
ONE_TIME_PASSIVE_EVENT
|
||
|
);
|
||
|
|
||
|
fileReaders.push([file, join(directory, subFolder), reader]);
|
||
|
};
|
||
|
const addEntry = async (
|
||
|
fileSystemEntry: FileSystemEntry,
|
||
|
subFolder = ""
|
||
|
): Promise<void> =>
|
||
|
new Promise((resolve) => {
|
||
|
if (fileSystemEntry?.isDirectory) {
|
||
|
(fileSystemEntry as FileSystemDirectoryEntry)
|
||
|
.createReader()
|
||
|
.readEntries((entries) =>
|
||
|
Promise.all(
|
||
|
entries.map((entry) =>
|
||
|
addEntry(entry, join(subFolder, fileSystemEntry.name))
|
||
|
)
|
||
|
).then(() => resolve())
|
||
|
);
|
||
|
} else {
|
||
|
(fileSystemEntry as FileSystemFileEntry)?.file((file) => {
|
||
|
addFile(file, subFolder);
|
||
|
resolve();
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
if (files instanceof FileList) {
|
||
|
[...files].forEach((file) => addFile(file));
|
||
|
} else {
|
||
|
await Promise.all(
|
||
|
[...files].map(async (file) =>
|
||
|
addEntry(file.webkitGetAsEntry() as FileSystemEntry)
|
||
|
)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return fileReaders;
|
||
|
};
|
||
|
|
||
|
export type InputChangeEvent = Event & { target: HTMLInputElement };
|
||
|
|
||
|
type EventData = {
|
||
|
files: DataTransferItemList | FileList | never[];
|
||
|
text?: string;
|
||
|
};
|
||
|
|
||
|
export const getEventData = (
|
||
|
event: DragEvent | InputChangeEvent | never[] | React.DragEvent
|
||
|
): EventData => {
|
||
|
const dataTransfer =
|
||
|
(event as React.DragEvent).nativeEvent?.dataTransfer ||
|
||
|
(event as DragEvent).dataTransfer;
|
||
|
let files =
|
||
|
(event as InputChangeEvent).target?.files || dataTransfer?.items || [];
|
||
|
const text = dataTransfer?.getData("application/json");
|
||
|
|
||
|
if (files instanceof DataTransferItemList) {
|
||
|
files = [...files].filter(
|
||
|
(item) => !("kind" in item) || item.kind === "file"
|
||
|
) as unknown as DataTransferItemList;
|
||
|
}
|
||
|
|
||
|
return { files, text };
|
||
|
};
|
||
|
|
||
|
export const handleFileInputEvent = (
|
||
|
event: InputChangeEvent | React.DragEvent,
|
||
|
callback: (
|
||
|
fileName: string,
|
||
|
buffer?: Buffer,
|
||
|
completeAction?: CompleteAction
|
||
|
) => Promise<void>,
|
||
|
directory: string,
|
||
|
openTransferDialog: (fileReaders: FileReaders | ObjectReaders) => void,
|
||
|
hasUpdateId = false
|
||
|
): void => {
|
||
|
haltEvent(event);
|
||
|
|
||
|
const { files, text } = getEventData(event);
|
||
|
|
||
|
if (text) {
|
||
|
try {
|
||
|
const filePaths = JSON.parse(text) as string[];
|
||
|
|
||
|
if (!Array.isArray(filePaths)) return;
|
||
|
|
||
|
const isSingleFile = filePaths.length === 1;
|
||
|
const objectReaders = filePaths.map((filePath) => {
|
||
|
let aborted = false;
|
||
|
|
||
|
return {
|
||
|
abort: () => {
|
||
|
aborted = true;
|
||
|
},
|
||
|
directory,
|
||
|
name: filePath,
|
||
|
read: async () => {
|
||
|
if (aborted || dirname(filePath) === ".") return;
|
||
|
|
||
|
await callback(
|
||
|
filePath,
|
||
|
undefined,
|
||
|
isSingleFile ? COMPLETE_ACTION.UPDATE_URL : undefined
|
||
|
);
|
||
|
},
|
||
|
};
|
||
|
});
|
||
|
|
||
|
if (isSingleFile) {
|
||
|
const [singleFile] = objectReaders;
|
||
|
|
||
|
if (hasUpdateId) {
|
||
|
callback(singleFile.name, undefined, COMPLETE_ACTION.UPDATE_URL);
|
||
|
}
|
||
|
if (hasUpdateId || singleFile.directory === singleFile.name) return;
|
||
|
}
|
||
|
|
||
|
openTransferDialog(objectReaders);
|
||
|
} catch {
|
||
|
// Failed to parse text data to JSON
|
||
|
}
|
||
|
} else {
|
||
|
createFileReaders(files, directory, callback).then(openTransferDialog);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
export const findPathsRecursive = async (
|
||
|
paths: string[],
|
||
|
readdir: (path: string) => Promise<string[]>,
|
||
|
lstat: (path: string) => Promise<Stats>
|
||
|
): Promise<string[]> => {
|
||
|
const pathArrays = await Promise.all(
|
||
|
paths.map(
|
||
|
async (path): Promise<string[]> =>
|
||
|
(await lstat(path)).isDirectory()
|
||
|
? findPathsRecursive(
|
||
|
(await readdir(path)).map((file) => join(path, file)),
|
||
|
readdir,
|
||
|
lstat
|
||
|
)
|
||
|
: [path]
|
||
|
)
|
||
|
);
|
||
|
|
||
|
return pathArrays.flat();
|
||
|
};
|
||
|
|
||
|
export const removeInvalidFilenameCharacters = (name = ""): string =>
|
||
|
name.replace(/["*/:<>?\\|]/g, "");
|