217 lines
6.0 KiB
TypeScript
217 lines
6.0 KiB
TypeScript
/**
|
|
* Folder Picker Utility
|
|
* =====================
|
|
*
|
|
* Handles directory selection with hybrid approach:
|
|
* - File System Access API for Chromium browsers (best UX)
|
|
* - webkitdirectory fallback for all other browsers
|
|
*
|
|
* Based on the ChatGPT recommendation for "it just works" UX.
|
|
*/
|
|
|
|
export interface FileWithPath extends File {
|
|
relativePath: string;
|
|
}
|
|
|
|
/**
|
|
* Pick a directory using the best available method
|
|
* @returns Promise<FileWithPath[]> - Array of files with relative paths
|
|
* @throws 'fallback-input' - Indicates fallback input should be used
|
|
*/
|
|
export async function pickDirectory(): Promise<FileWithPath[]> {
|
|
// Check if we have the modern File System Access API (Chromium)
|
|
if ('showDirectoryPicker' in window) {
|
|
try {
|
|
const handle = await (window as any).showDirectoryPicker({
|
|
mode: 'read'
|
|
});
|
|
|
|
const files: FileWithPath[] = [];
|
|
await walkDirectoryHandle(handle, '', files);
|
|
|
|
return files;
|
|
} catch (error: any) {
|
|
if (error.name === 'AbortError') {
|
|
throw new Error('user-cancelled');
|
|
}
|
|
// Fall back to input method
|
|
throw new Error('fallback-input');
|
|
}
|
|
}
|
|
|
|
// No native directory picker support, use fallback
|
|
throw new Error('fallback-input');
|
|
}
|
|
|
|
/**
|
|
* Recursively walk directory handle and collect files
|
|
* @param dirHandle - Directory handle from File System Access API
|
|
* @param prefix - Current path prefix
|
|
* @param files - Array to collect files
|
|
*/
|
|
async function walkDirectoryHandle(dirHandle: any, prefix: string, files: FileWithPath[]) {
|
|
for await (const entry of dirHandle.values()) {
|
|
if (entry.kind === 'file') {
|
|
try {
|
|
const file = await entry.getFile();
|
|
const relativePath = `${prefix}${entry.name}`;
|
|
|
|
// Add relative path property
|
|
const fileWithPath = file as FileWithPath;
|
|
fileWithPath.relativePath = relativePath;
|
|
|
|
files.push(fileWithPath);
|
|
} catch (error) {
|
|
console.warn(`Failed to read file ${entry.name}:`, error);
|
|
}
|
|
} else if (entry.kind === 'directory') {
|
|
// Recursively process subdirectory
|
|
await walkDirectoryHandle(entry, `${prefix}${entry.name}/`, files);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process files from webkitdirectory input
|
|
* @param fileList - FileList from input element
|
|
* @returns FileWithPath[] - Array of files with relative paths
|
|
*/
|
|
export function processDirectoryFiles(fileList: FileList): FileWithPath[] {
|
|
const files: FileWithPath[] = [];
|
|
|
|
for (let i = 0; i < fileList.length; i++) {
|
|
const file = fileList[i];
|
|
|
|
// webkitRelativePath preserves the directory structure
|
|
const relativePath = (file as any).webkitRelativePath || file.name;
|
|
|
|
// Skip empty files (usually directories)
|
|
if (file.size === 0 && relativePath.endsWith('/')) {
|
|
continue;
|
|
}
|
|
|
|
const fileWithPath = file as FileWithPath;
|
|
fileWithPath.relativePath = relativePath;
|
|
|
|
files.push(fileWithPath);
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Calculate total size and file count for a file array
|
|
* @param files - Array of files
|
|
* @returns Object with total size and count
|
|
*/
|
|
export function calculateDirectoryStats(files: FileWithPath[]) {
|
|
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
|
const fileCount = files.length;
|
|
|
|
// Get unique directories
|
|
const directories = new Set<string>();
|
|
files.forEach(file => {
|
|
const pathParts = file.relativePath.split('/');
|
|
for (let i = 1; i < pathParts.length; i++) {
|
|
directories.add(pathParts.slice(0, i).join('/'));
|
|
}
|
|
});
|
|
|
|
return {
|
|
fileCount,
|
|
totalSize,
|
|
directoryCount: directories.size,
|
|
formattedSize: formatFileSize(totalSize)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format file size in human-readable format
|
|
* @param bytes - Size in bytes
|
|
* @returns Formatted string
|
|
*/
|
|
export function formatFileSize(bytes: number): string {
|
|
if (bytes === 0) return '0 Bytes';
|
|
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Create a directory tree structure from files
|
|
* @param files - Array of files with relative paths
|
|
* @returns Tree structure object
|
|
*/
|
|
export function createDirectoryTree(files: FileWithPath[]) {
|
|
const tree: any = {};
|
|
|
|
files.forEach(file => {
|
|
const pathParts = file.relativePath.split('/');
|
|
let currentLevel = tree;
|
|
|
|
// Navigate through directory structure
|
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
const dirName = pathParts[i];
|
|
if (!currentLevel[dirName]) {
|
|
currentLevel[dirName] = {
|
|
type: 'directory',
|
|
children: {},
|
|
fileCount: 0,
|
|
totalSize: 0
|
|
};
|
|
}
|
|
currentLevel = currentLevel[dirName].children;
|
|
}
|
|
|
|
// Add file to the tree
|
|
const fileName = pathParts[pathParts.length - 1];
|
|
currentLevel[fileName] = {
|
|
type: 'file',
|
|
file: file,
|
|
size: file.size,
|
|
mimeType: file.type
|
|
};
|
|
|
|
// Update parent directory stats
|
|
let statsLevel = tree;
|
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
const dirName = pathParts[i];
|
|
if (statsLevel[dirName]) {
|
|
statsLevel[dirName].fileCount++;
|
|
statsLevel[dirName].totalSize += file.size;
|
|
statsLevel = statsLevel[dirName].children;
|
|
}
|
|
}
|
|
});
|
|
|
|
return tree;
|
|
}
|
|
|
|
/**
|
|
* Check if directory picker is supported
|
|
* @returns boolean
|
|
*/
|
|
export function isDirectoryPickerSupported(): boolean {
|
|
return 'showDirectoryPicker' in window;
|
|
}
|
|
|
|
/**
|
|
* Get browser compatibility info
|
|
* @returns Object with compatibility details
|
|
*/
|
|
export function getBrowserCompatibility() {
|
|
const isChromium = 'showDirectoryPicker' in window;
|
|
const supportsWebkitDirectory = HTMLInputElement.prototype.hasOwnProperty('webkitdirectory');
|
|
|
|
return {
|
|
hasNativeDirectoryPicker: isChromium,
|
|
hasWebkitDirectory: supportsWebkitDirectory,
|
|
recommendedMethod: isChromium ? 'native' : 'fallback',
|
|
browserType: isChromium ? 'chromium' : 'other'
|
|
};
|
|
}
|