You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

363 lines
12 KiB

"use client";
import type { WorkflowFile } from "@/store/workflowStore";
const PREFIX = "browserfs://";
const DB_NAME = "popiart-browser-file-system";
const DB_VERSION = 1;
const STORE_NAME = "directory-handles";
const MAX_WORKFLOW_SCAN_DEPTH = 3;
const SKIP_DIRS = new Set([".git", "node_modules", "__pycache__", ".next"]);
export interface BrowserWorkflowListEntry {
name: string;
directoryPath: string;
relativePath: string;
lastModified: number;
}
type BrowserDirectoryRecord = {
id: string;
handle: FileSystemDirectoryHandle;
};
type PermissionMode = "read" | "readwrite";
type FileSystemPermissionDescriptor = {
mode?: PermissionMode;
};
type PermissionedDirectoryHandle = FileSystemDirectoryHandle & {
queryPermission?: (descriptor?: FileSystemPermissionDescriptor) => Promise<PermissionState>;
requestPermission?: (descriptor?: FileSystemPermissionDescriptor) => Promise<PermissionState>;
};
declare global {
interface Window {
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
}
}
const memoryHandles = new Map<string, FileSystemDirectoryHandle>();
export function isBrowserFileSystemPath(path: string | null | undefined): boolean {
return typeof path === "string" && path.startsWith(PREFIX);
}
export function isBrowserDirectoryPickerSupported(): boolean {
return typeof window !== "undefined" && typeof window.showDirectoryPicker === "function";
}
export function joinBrowserFileSystemPath(basePath: string, folderName: string): string {
return `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`;
}
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"] as const;
function extensionFromDataUrl(dataUrl: string): string {
const mime = dataUrl.match(/^data:([^;]+);base64,/)?.[1];
if (mime === "image/jpeg") return "jpg";
if (mime === "image/webp") return "webp";
if (mime === "image/gif") return "gif";
return "png";
}
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
const response = await fetch(dataUrl);
return response.blob();
}
async function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export async function pickBrowserWorkflowDirectory(): Promise<string | null> {
if (!isBrowserDirectoryPickerSupported() || !window.showDirectoryPicker) return null;
const handle = await window.showDirectoryPicker();
await ensurePermission(handle, "readwrite");
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
memoryHandles.set(id, handle);
await putDirectoryRecord({ id, handle });
return `${PREFIX}${id}`;
}
export async function writeBrowserWorkflowFile(
directoryPath: string,
filename: string,
workflow: WorkflowFile
): Promise<{ filePath: string }> {
const directoryHandle = await resolveDirectoryHandle(directoryPath, true);
await ensurePermission(directoryHandle, "readwrite");
await directoryHandle.getDirectoryHandle("inputs", { create: true });
await directoryHandle.getDirectoryHandle("generations", { create: true });
const safeName = filename.replace(/[^a-zA-Z0-9-_]/g, "_");
const fileName = `${safeName}.json`;
const fileHandle = await directoryHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(workflow, null, 2));
await writable.close();
return { filePath: `${directoryPath}/${fileName}` };
}
export async function writeBrowserGenerationFile(
directoryPath: string,
imageId: string,
imageData: string
): Promise<{ filePath: string }> {
const directoryHandle = await resolveDirectoryHandle(directoryPath, true);
await ensurePermission(directoryHandle, "readwrite");
const extension = extensionFromDataUrl(imageData);
const fileName = `${imageId}.${extension}`;
const fileHandle = await directoryHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(await dataUrlToBlob(imageData));
await writable.close();
return { filePath: `${directoryPath}/${fileName}` };
}
export async function loadBrowserGenerationFile(
directoryPath: string,
imageId: string
): Promise<string | null> {
const directoryHandle = await resolveDirectoryHandle(directoryPath, false);
await ensurePermission(directoryHandle, "readwrite");
for (const extension of IMAGE_EXTENSIONS) {
try {
const fileHandle = await directoryHandle.getFileHandle(`${imageId}.${extension}`);
return fileToDataUrl(await fileHandle.getFile());
} catch {
continue;
}
}
return null;
}
export async function listBrowserWorkflows(parentPath: string): Promise<BrowserWorkflowListEntry[]> {
const rootHandle = await resolveDirectoryHandle(parentPath, false);
await ensurePermission(rootHandle, "readwrite");
const dirs = await collectBrowserDirectories(parentPath, rootHandle, "", 0);
const results = await Promise.all(
dirs.map((dir) => probeBrowserWorkflow(dir.path, dir.handle, dir.relativePath))
);
return results
.filter((entry): entry is BrowserWorkflowListEntry => entry !== null)
.sort((a, b) => b.lastModified - a.lastModified);
}
export async function loadBrowserWorkflowFromDirectory(directoryPath: string): Promise<WorkflowFile> {
const directoryHandle = await resolveDirectoryHandle(directoryPath, false);
await ensurePermission(directoryHandle, "readwrite");
const jsonFiles: { name: string; file: File }[] = [];
for await (const [name, handle] of directoryEntries(directoryHandle)) {
if (handle.kind !== "file" || !name.endsWith(".json")) continue;
const file = await (handle as FileSystemFileHandle).getFile();
jsonFiles.push({ name, file });
}
jsonFiles.sort((a, b) => b.file.lastModified - a.file.lastModified);
for (const { file } of jsonFiles) {
try {
const parsed = JSON.parse(await file.text()) as WorkflowFile;
if (
typeof parsed.version === "number" &&
Array.isArray(parsed.nodes) &&
Array.isArray(parsed.edges)
) {
return parsed;
}
} catch {
continue;
}
}
throw new Error("No workflow file found in directory");
}
async function collectBrowserDirectories(
rootPath: string,
currentHandle: FileSystemDirectoryHandle,
relativePath: string,
depth: number
): Promise<Array<{ path: string; handle: FileSystemDirectoryHandle; relativePath: string }>> {
if (depth > MAX_WORKFLOW_SCAN_DEPTH) return [];
const dirs: Array<{ path: string; handle: FileSystemDirectoryHandle; relativePath: string }> = [];
for await (const [name, handle] of directoryEntries(currentHandle)) {
if (handle.kind !== "directory") continue;
if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
const childHandle = handle as FileSystemDirectoryHandle;
const childRelativePath = relativePath ? `${relativePath}/${name}` : name;
const childPath = joinBrowserFileSystemPath(rootPath, childRelativePath);
dirs.push({ path: childPath, handle: childHandle, relativePath: childRelativePath });
if (depth < MAX_WORKFLOW_SCAN_DEPTH) {
try {
dirs.push(
...(await collectBrowserDirectories(rootPath, childHandle, childRelativePath, depth + 1))
);
} catch {
continue;
}
}
}
return dirs;
}
async function probeBrowserWorkflow(
directoryPath: string,
directoryHandle: FileSystemDirectoryHandle,
relativePath: string
): Promise<BrowserWorkflowListEntry | null> {
const jsonFiles: { file: File }[] = [];
for await (const [name, handle] of directoryEntries(directoryHandle)) {
if (handle.kind !== "file" || !name.endsWith(".json")) continue;
try {
jsonFiles.push({ file: await (handle as FileSystemFileHandle).getFile() });
} catch {
continue;
}
}
jsonFiles.sort((a, b) => b.file.lastModified - a.file.lastModified);
for (const { file } of jsonFiles) {
try {
const parsed = JSON.parse(await file.text()) as Partial<WorkflowFile>;
if (
typeof parsed.version === "number" &&
Array.isArray(parsed.nodes) &&
Array.isArray(parsed.edges)
) {
return {
name: typeof parsed.name === "string" && parsed.name ? parsed.name : dirBasename(relativePath),
directoryPath,
relativePath,
lastModified: file.lastModified,
};
}
} catch {
continue;
}
}
return null;
}
function dirBasename(dirPath: string): string {
return dirPath.split("/").filter(Boolean).pop() || dirPath;
}
function directoryEntries(
handle: FileSystemDirectoryHandle
): AsyncIterable<[string, FileSystemHandle]> {
return (handle as FileSystemDirectoryHandle & {
entries: () => AsyncIterable<[string, FileSystemHandle]>;
}).entries();
}
async function resolveDirectoryHandle(path: string, create: boolean): Promise<FileSystemDirectoryHandle> {
const parsed = parseBrowserFileSystemPath(path);
let handle = await getDirectoryHandleById(parsed.id);
await ensurePermission(handle, "readwrite");
for (const segment of parsed.segments) {
handle = await handle.getDirectoryHandle(segment, { create });
}
return handle;
}
function parseBrowserFileSystemPath(path: string): { id: string; segments: string[] } {
if (!isBrowserFileSystemPath(path)) throw new Error("Not a browser file-system path");
const parts = path
.slice(PREFIX.length)
.split("/")
.filter(Boolean)
.map((part) => decodeURIComponent(part));
const id = parts[0];
if (!id) throw new Error("Browser directory handle is missing");
return { id, segments: parts.slice(1) };
}
async function getDirectoryHandleById(id: string): Promise<FileSystemDirectoryHandle> {
const cached = memoryHandles.get(id);
if (cached) return cached;
const record = await getDirectoryRecord(id);
if (!record) {
throw new Error("Browser folder permission was lost. Choose the folder again.");
}
memoryHandles.set(id, record.handle);
return record.handle;
}
async function ensurePermission(handle: FileSystemDirectoryHandle, mode: PermissionMode): Promise<void> {
const permissioned = handle as PermissionedDirectoryHandle;
if (!permissioned.queryPermission || !permissioned.requestPermission) return;
const descriptor = { mode };
const current = await permissioned.queryPermission(descriptor);
if (current === "granted") return;
const requested = await permissioned.requestPermission(descriptor);
if (requested !== "granted") {
throw new Error("Folder permission was not granted.");
}
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, { keyPath: "id" });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function putDirectoryRecord(record: BrowserDirectoryRecord): Promise<void> {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
}
async function getDirectoryRecord(id: string): Promise<BrowserDirectoryRecord | null> {
const db = await openDb();
const record = await new Promise<BrowserDirectoryRecord | null>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const request = tx.objectStore(STORE_NAME).get(id);
request.onsuccess = () => resolve((request.result as BrowserDirectoryRecord | undefined) ?? null);
request.onerror = () => reject(request.error);
});
db.close();
return record;
}