@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
713 lines (653 loc) • 22.6 kB
text/typescript
import { isBrowserWindow, isDeno, isNodeLike } from "../env.ts";
import { platform } from "../runtime.ts";
import { readAsObjectURL } from "../reader.ts";
import { asyncTask } from "../async.ts";
import { getExtensions } from "../filetype.ts";
import { readDir, readFileAsFile, writeFile } from "../fs.ts";
import { fixFileType } from "../fs/util.ts";
import { as, pick } from "../object.ts";
import { basename, join } from "../path.ts";
import { createProgressEvent } from "../event.ts";
import progress, { ProgressState } from "./progress.ts";
/**
* Options for file dialog functions, such as {@link pickFile} and
* {@link openFile}.
*/
export interface FileDialogOptions {
/**
* Customize the dialog's title. This option is ignored in the browser.
*/
title?: string | undefined;
/**
* Filter files by providing a MIME type or suffix, multiple types can be
* separated via `,`.
*/
type?: string | undefined;
}
/**
* Options for the {@link pickFile} function.
*/
export interface PickFileOptions extends FileDialogOptions {
/** Open the dialog in save mode. */
forSave?: boolean;
/** The default name of the file to save when `forSave` is set. */
defaultName?: string | undefined;
}
/**
* Opens the file picker dialog and pick a file, this function returns the
* file's path or a `FileSystemFileHandle` in the browser.
*
* NOTE: Browser support is limited to the chromium-based browsers.
*
* @example
* ```ts
* // default usage
* import { pickFile } from "@ayonli/jsext/dialog";
*
* // Node.js, Deno, Bun
* const filename = await pickFile() as string | null;
*
* // Browser (Chrome)
* const handle = await pickFile() as FileSystemFileHandle | null;
* ```
*
* @example
* ```ts
* // filter by MIME type
* import { pickFile } from "@ayonli/jsext/dialog";
*
* // Node.js, Deno, Bun
* const filename = await pickFile({ type: "image/*" }) as string | null;
*
* // Browser (Chrome)
* const handle = await pickFile({ type: "image/*" }) as FileSystemFileHandle | null;
* ```
*
* @example
* ```ts
* // pick for save
* import { pickFile } from "@ayonli/jsext/dialog";
*
* // Node.js, Deno, Bun
* const filename = await pickFile({
* forSave: true,
* defaultName: "hello.txt",
* }) as string | null;
*
* // Browser (Chrome)
* const handle = await pickFile({
* forSave: true,
* defaultName: "hello.txt",
* }) as FileSystemFileHandle | null;
* ```
*/
export async function pickFile(
options: PickFileOptions = {}
): Promise<string | FileSystemFileHandle | null> {
if (typeof (globalThis as any)["showOpenFilePicker"] === "function") {
const { browserPickFile } = await import("./browser/file.ts");
return await browserPickFile(options.type, {
forSave: options.forSave,
defaultName: options.defaultName,
});
} else if (isDeno || isNodeLike) {
const { isWSL, which } = await import("../cli.ts");
const _platform = platform();
if (_platform === "darwin") {
const { macPickFile } = await import("./terminal/file/mac.ts");
return await macPickFile(options.title, {
type: options.type,
forSave: options?.forSave,
defaultName: options?.defaultName,
});
} else if (_platform === "windows" || isWSL()) {
const { windowsPickFile } = await import("./terminal/file/windows.ts");
return await windowsPickFile(options.title, {
type: options.type,
forSave: options?.forSave,
defaultName: options?.defaultName,
});
} else if (_platform === "linux" || await which("zenity")) {
const { linuxPickFile } = await import("./terminal/file/linux.ts");
return await linuxPickFile(options.title, {
type: options.type,
forSave: options?.forSave,
defaultName: options?.defaultName,
});
}
}
throw new Error("Unsupported platform");
}
/**
* Opens the file picker dialog and pick multiple files, this function returns
* the paths or `FileSystemFileHandle` objects in the browser of the files
* selected.
*
* NOTE: Browser support is limited to the chromium-based browsers.
*
* @example
* ```ts
* // default usage
* import { pickFiles } from "@ayonli/jsext/dialog";
*
* // Node.js, Deno, Bun
* const filenames = await pickFiles() as string[];
*
* // Browser (Chrome)
* const handles = await pickFiles() as FileSystemFileHandle[];
* ```
*
* @example
* ```ts
* // filter by MIME type
* import { pickFiles } from "@ayonli/jsext/dialog";
*
* // Node.js, Deno, Bun
* const filenames = await pickFiles({ type: "image/*" }) as string[];
*
* // Browser (Chrome)
* const handles = await pickFiles({ type: "image/*" }) as FileSystemFileHandle[];
* ```
*/
export async function pickFiles(
options: FileDialogOptions = {}
): Promise<string[] | FileSystemFileHandle[]> {
if (typeof (globalThis as any)["showOpenFilePicker"] === "function") {
const { browserPickFiles } = await import("./browser/file.ts");
return await browserPickFiles(options.type);
} else if (isDeno || isNodeLike) {
const { isWSL, which } = await import("../cli.ts");
const _platform = platform();
if (_platform === "darwin") {
const { macPickFiles } = await import("./terminal/file/mac.ts");
return await macPickFiles(options.title, options.type);
} else if (_platform === "windows" || isWSL()) {
const { windowsPickFiles } = await import("./terminal/file/windows");
return await windowsPickFiles(options.title, options.type);
} else if (_platform === "linux" || await which("zenity")) {
const { linuxPickFiles } = await import("./terminal/file/linux.ts");
return await linuxPickFiles(options.title, options.type);
}
}
throw new Error("Unsupported platform");
}
/**
* Opens the file picker dialog and pick a directory, this function returns the
* directory's path or `FileSystemDirectoryHandle` in the browser.
*
* NOTE: Browser support is limited to the chromium-based browsers.
*
* @example
* ```ts
* import { pickDirectory } from "@ayonli/jsext/dialog";
*
* // Node.js, Deno, Bun
* const dirname = await pickDirectory() as string | null;
*
* // Browser (Chrome)
* const handle = await pickDirectory() as FileSystemDirectoryHandle | null;
* ```
*/
export async function pickDirectory(
options: Pick<FileDialogOptions, "title"> = {}
): Promise<string | FileSystemDirectoryHandle | null> {
if (typeof (globalThis as any)["showDirectoryPicker"] === "function") {
const { browserPickFolder } = await import("./browser/file.ts");
return await browserPickFolder();
} else if (isDeno || isNodeLike) {
const { isWSL, which } = await import("../cli.ts");
const _platform = platform();
if (_platform === "darwin") {
const { macPickFolder } = await import("./terminal/file/mac.ts");
return await macPickFolder(options.title);
} else if (_platform === "windows" || isWSL()) {
const { windowsPickFolder } = await import("./terminal/file/windows.ts");
return await windowsPickFolder(options.title);
} else if (_platform === "linux" || await which("zenity")) {
const { linuxPickFolder } = await import("./terminal/file/linux.ts");
return await linuxPickFolder(options.title);
}
}
throw new Error("Unsupported platform");
}
/**
* Opens the file picker dialog and selects a file to open.
*
* @example
* ```ts
* // default usage
* import { openFile } from "@ayonli/jsext/dialog";
*
* const file = await openFile();
*
* if (file) {
* console.log(`You selected: ${file.name}`);
* }
* ```
*
* @example
* ```ts
* // filter by MIME type
* import { openFile } from "@ayonli/jsext/dialog";
*
* const file = await openFile({ type: "image/*" });
*
* if (file) {
* console.log(`You selected: ${file.name}`);
* console.assert(file.type.startsWith("image/"));
* }
* ```
*/
export function openFile(options?: FileDialogOptions): Promise<File | null>;
/**
* Opens the file picker dialog and selects multiple files to open.
*
* @deprecated use {@link openFiles} instead.
*/
export function openFile(options: FileDialogOptions & {
multiple: true;
}): Promise<File[]>;
/**
* Opens the directory picker dialog and selects all its files.
*
* @deprecated use {@link openDirectory} instead.
*/
export function openFile(options: Pick<FileDialogOptions, "title"> & {
directory: true;
}): Promise<File[]>;
export async function openFile(options: FileDialogOptions & {
multiple?: boolean;
directory?: boolean;
} = {}): Promise<File | File[] | null> {
const { title = "", type = "", multiple = false, directory = false } = options;
if (directory) {
return await openDirectory({ title });
} else if (multiple) {
return await openFiles({ title, type });
}
if (typeof (globalThis as any)["showOpenFilePicker"] === "function") {
const { browserPickFile } = await import("./browser/file.ts");
const handle = await browserPickFile(type);
return handle ? await handle.getFile().then(fixFileType) : null;
} else if (isBrowserWindow) {
const input = document.createElement("input");
input.type = "file";
input.accept = type ?? "";
return await new Promise<File | File[] | null>(resolve => {
input.onchange = () => {
const file = input.files?.[0];
resolve(file ? fixFileType(file) : null);
};
input.oncancel = () => {
resolve(null);
};
if (typeof input.showPicker === "function") {
input.showPicker();
} else {
input.click();
}
});
} else if (isDeno || isNodeLike) {
let filename = await pickFile({ title, type }) as string | null;
if (filename) {
return await readFileAsFile(filename);
} else {
return null;
}
} else {
throw new Error("Unsupported runtime");
}
}
/**
* Opens the file picker dialog and selects multiple files to open.
*
* @example
* ```ts
* // default usage
* import { openFiles } from "@ayonli/jsext/dialog";
*
* const files = await openFiles();
*
* if (files.length > 0) {
* console.log(`You selected: ${files.map(file => file.name).join(", ")}`);
* }
* ```
*
* @example
* ```ts
* // filter by MIME type
* import { openFiles } from "@ayonli/jsext/dialog";
*
* const files = await openFiles({ type: "image/*" });
*
* if (files.length > 0) {
* console.log(`You selected: ${files.map(file => file.name).join(", ")}`);
* console.assert(files.every(file => file.type.startsWith("image/")));
* }
* ```
*/
export async function openFiles(options: FileDialogOptions = {}): Promise<File[]> {
if (typeof (globalThis as any)["showOpenFilePicker"] === "function") {
const { browserPickFiles } = await import("./browser/file.ts");
const handles = await browserPickFiles(options.type);
const files: File[] = [];
for (const handle of handles) {
const file = await handle.getFile();
files.push(fixFileType(file));
}
return files;
} else if (isBrowserWindow) {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = options.type || "";
return await new Promise<File[]>(resolve => {
input.onchange = () => {
const files = input.files;
resolve(files ? [...files].map(fixFileType) : []);
};
input.oncancel = () => {
resolve([]);
};
if (typeof input.showPicker === "function") {
input.showPicker();
} else {
input.click();
}
});
} else if (isDeno || isNodeLike) {
const filenames = await pickFiles(options) as string[];
return await Promise.all(filenames.map(path => readFileAsFile(path)));
} else {
throw new Error("Unsupported runtime");
}
}
/**
* Opens the directory picker dialog and selects all its files to open.
*
* @example
* ```ts
* import { openDirectory } from "@ayonli/jsext/dialog";
*
* const files = await openDirectory();
*
* for (const file of files) {
* console.log(`File name: ${file.name}, path: ${file.webkitRelativePath}`);
* }
* ```
*/
export async function openDirectory(
options: Pick<FileDialogOptions, "title"> = {}
): Promise<File[]> {
if (typeof (globalThis as any)["showDirectoryPicker"] === "function") {
const { browserPickFolder } = await import("./browser/file.ts");
const dir = await browserPickFolder();
const files: File[] = [];
if (!dir) {
return files;
}
for await (const entry of readDir(dir, { recursive: true })) {
if (entry.kind === "file") {
const file = await (entry.handle as FileSystemFileHandle).getFile();
Object.defineProperty(file, "webkitRelativePath", {
configurable: true,
enumerable: true,
writable: false,
value: entry.relativePath.replace(/\\/g, "/"),
});
files.push(fixFileType(file));
}
}
return files;
} else if (isBrowserWindow) {
const input = document.createElement("input");
input.type = "file";
input.webkitdirectory = true;
return await new Promise<File[]>(resolve => {
input.onchange = () => {
const files = input.files;
resolve(files ? [...files].map(fixFileType) : []);
};
input.oncancel = () => {
resolve([]);
};
if (typeof input.showPicker === "function") {
input.showPicker();
} else {
input.click();
}
});
} else if (isDeno || isNodeLike) {
const dirname = await pickDirectory(options) as string | null;
if (dirname) {
const files: File[] = [];
for await (const entry of readDir(dirname, { recursive: true })) {
if (entry.kind === "file") {
const path = join(dirname, entry.relativePath);
const file = await readFileAsFile(path);
Object.defineProperty(file, "webkitRelativePath", {
configurable: true,
enumerable: true,
writable: false,
value: entry.relativePath.replace(/\\/g, "/"),
});
files.push(fixFileType(file));
}
}
return files;
} else {
return [];
}
} else {
throw new Error("Unsupported runtime");
}
}
/**
* Options for the {@link saveFile} function.
*/
export interface SaveFileOptions {
/**
* Customize the dialog's title. This option is ignored in the browser.
*/
title?: string;
/** The suggested name of the file. */
name?: string;
/** The MIME type of the file. */
type?: string;
signal?: AbortSignal;
}
/**
* Saves a file to the file system.
*
* In the CLI, this function will open a dialog to let the user choose the
* location where the file will be saved. In the browser, the file will be saved
* to the default download location, or the browser will prompt the user to
* choose a location.
*
* @example
* ```ts
* import { saveFile } from "@ayonli/jsext/dialog";
*
* const file = new File(["Hello, World!"], "hello.txt", { type: "text/plain" });
*
* await saveFile(file);
* ```
*/
export async function saveFile(file: File, options?: Pick<SaveFileOptions, "title">): Promise<void>;
/**
* @example
* ```ts
* import { saveFile } from "@ayonli/jsext/dialog";
* import bytes from "@ayonli/jsext/bytes";
*
* const data = bytes("Hello, World!");
*
* await saveFile(data, { name: "hello.txt", type: "text/plain" });
* ```
*/
export async function saveFile(
file: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>,
options?: SaveFileOptions
): Promise<void>;
export async function saveFile(
file: File | Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>,
options: SaveFileOptions = {}
): Promise<void> {
if (isBrowserWindow) {
const a = document.createElement("a");
if (file instanceof ReadableStream) {
const type = options.type || "application/octet-stream";
a.href = await readAsObjectURL(file, type);
a.download = options.name || "Unnamed" + (getExtensions(type)[0] || "");
} else if (file instanceof File) {
a.href = URL.createObjectURL(file);
a.download = options.name || file.name || "Unnamed" + (getExtensions(file.type)[0] || "");
} else if (file instanceof Blob) {
a.href = URL.createObjectURL(file);
a.download = options.name || "Unnamed" + (getExtensions(file.type)[0] || "");
} else {
const type = options.type || "application/octet-stream";
const blob = new Blob([file], { type });
a.href = URL.createObjectURL(blob);
a.download = options.name || "Unnamed" + (getExtensions(type)[0] || "");
}
a.click();
} else if (isDeno || isNodeLike) {
const { title } = options;
let filename: string | null | undefined;
if (typeof Blob === "function" && file instanceof Blob) {
filename = await pickFile({
title,
type: options.type || file.type,
forSave: true,
defaultName: options.name || as(file, File)?.name,
}) as string | null;
} else {
filename = await pickFile({
title,
type: options.type,
forSave: true,
defaultName: options.name,
}) as string | null;
}
if (filename) {
await writeFile(filename, file, pick(options, ["signal"]));
}
} else {
throw new Error("Unsupported runtime");
}
}
/**
* Options for the {@link downloadFile} function.
*/
export interface DownloadFileOptions extends SaveFileOptions {
/**
* A callback function that will be called when the download progress
* changes.
*/
onProgress?: (event: ProgressEvent) => void;
/**
* Displays a progress bar during the download process. This option shadows
* the `signal` option if provided, as the progress bar has its own
* cancellation mechanism.
*/
showProgress?: boolean;
}
/**
* Downloads the file of the given URL to the file system.
*
* In the CLI, this function will open a dialog to let the user choose the
* location where the file will be saved. In the browser, the file will be saved
* to the default download location, or the browser will prompt the user to
* choose a location.
*
* NOTE: This function depends on the Fetch API and Web Streams API, in Node.js,
* it requires Node.js v18.0 or above.
*
* @example
* ```ts
* import { downloadFile } from "@ayonli/jsext/dialog";
*
* await downloadFile("https://ayonli.github.io/jsext/README.md");
* ```
*/
export async function downloadFile(
url: string | URL,
options: DownloadFileOptions = {}
): Promise<void> {
const src = typeof url === "object" ? url.href : url;
const name = options.name || basename(src);
if (isBrowserWindow) {
const a = document.createElement("a");
a.href = src;
a.download = name;
a.click();
return;
} else if (!isDeno && !isNodeLike || typeof fetch !== "function") {
throw new Error("Unsupported runtime");
}
const dest = await pickFile({
title: options.title,
type: options.type,
forSave: true,
defaultName: name,
}) as string | null;
if (!dest) // user canceled
return;
const task = asyncTask<void>();
let signal = options.signal ?? null;
let result: Promise<void | null>;
let updateProgress: ((state: ProgressState) => void) | undefined;
if (options.showProgress) {
const ctrl = new AbortController();
signal = ctrl.signal;
result = progress("Downloading...", async (set) => {
updateProgress = set;
return await task;
}, () => {
ctrl.abort();
throw new Error("Download canceled");
});
} else {
result = task;
}
const res = await fetch(src, { signal });
if (!res.ok) {
throw new Error(`Failed to download: ${src}`);
}
const size = parseInt(res.headers.get("Content-Length") || "0", 10);
let stream = res.body!;
if (options.onProgress || options.showProgress) {
const { onProgress } = options;
let loaded = 0;
const transform = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
controller.enqueue(chunk);
loaded += chunk.byteLength;
if (onProgress) {
try {
onProgress?.(createProgressEvent("progress", {
lengthComputable: !!size,
loaded,
total: size ?? 0,
}));
} catch {
// ignore
}
}
if (updateProgress && size) {
updateProgress({
percent: loaded / size,
});
}
},
});
stream = stream.pipeThrough(transform);
}
writeFile(dest, stream, { signal: signal! }).then(() => {
task.resolve();
}).catch(err => {
task.reject(err);
});
await result;
}