kv-music/js/bulk-download-writer.ts

180 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { triggerDownload } from './download-utils';
/**
* A single entry to be included in a ZIP archive or written directly to a folder.
*/
export interface WriterEntry {
name: string;
lastModified: Date;
input: Blob | string | ArrayBuffer | Uint8Array;
}
/** Minimal interface for the Neutralino bridge used by ZipNeutralinoWriter */
interface NeutralinoBridge {
os: {
showSaveDialog(
title: string,
options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> }
): Promise<string | null>;
};
filesystem: {
writeBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
appendBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
};
}
async function loadClientZip() {
try {
return await import('client-zip');
} catch (error) {
console.error('Failed to load client-zip:', error);
throw new Error('Failed to load ZIP library');
}
}
/**
* Interface for writing a collection of file entries to an output destination.
* Each implementation handles its own output selection (save dialog, directory picker, etc.)
* and throws a DOMException with name 'AbortError' if the user cancels.
*/
export interface IBulkDownloadWriter {
write(files: AsyncIterable<WriterEntry>): Promise<void>;
}
/**
* Streams a ZIP archive to a file via the File System Access API.
* Prompts the user to choose a save location with showSaveFilePicker.
*/
export class ZipStreamWriter implements IBulkDownloadWriter {
constructor(private readonly suggestedFilename: string) {}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
// showSaveFilePicker is part of the File System Access API (not yet in all TS DOM libs)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileHandle = await (window as any).showSaveFilePicker({
suggestedName: this.suggestedFilename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
});
const { downloadZip } = await loadClientZip();
const writable = await fileHandle.createWritable();
const response = downloadZip(files);
if (!response.body) throw new Error('ZIP response body is null');
await response.body.pipeTo(writable);
}
}
/**
* Collects a ZIP archive into a Blob and triggers a browser download.
* Works on all browsers without requiring the File System Access API.
*/
export class ZipBlobWriter implements IBulkDownloadWriter {
constructor(private readonly filename: string) {}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
const { downloadZip } = await loadClientZip();
const response = downloadZip(files);
const blob = await response.blob();
triggerDownload(blob, this.filename);
}
}
/**
* Writes a ZIP archive to the filesystem via the Neutralino desktop bridge,
* showing a native save dialog first.
*/
export class ZipNeutralinoWriter implements IBulkDownloadWriter {
constructor(private readonly folderName: string) {}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
const bridge = (await import('./desktop/neutralino-bridge.js')) as unknown as NeutralinoBridge;
const savePath = await bridge.os.showSaveDialog(`Select save location for ${this.folderName}.zip`, {
defaultPath: `${this.folderName}.zip`,
filters: [{ name: 'ZIP Archive', extensions: ['zip'] }],
});
if (!savePath) {
throw new DOMException('User cancelled save dialog', 'AbortError');
}
const { downloadZip } = await loadClientZip();
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
const response = downloadZip(files);
if (!response.body) throw new Error('ZIP response body is null');
const reader = response.body.getReader();
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
await bridge.filesystem.appendBinaryFile(savePath, chunk);
receivedLength += value.length;
}
console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`);
}
}
/**
* Writes files directly into a user-chosen folder using the standard browser
* File System Access API (showDirectoryPicker). Subdirectories embedded in
* file entry names are created automatically.
*
* Use the static {@link FolderPickerWriter.create} method to obtain an instance;
* the constructor is private so the directory handle is always set before use.
*/
export class FolderPickerWriter implements IBulkDownloadWriter {
private constructor(private readonly dirHandle: FileSystemDirectoryHandle) {}
/**
* Prompts the user to pick a writable directory.
* Returns a new {@link FolderPickerWriter} bound to the chosen directory.
* If the user dismisses the picker, the promise rejects with a DOMException
* whose name is "AbortError".
*/
static async create(): Promise<FolderPickerWriter> {
// showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({
mode: 'readwrite',
});
return new FolderPickerWriter(dirHandle);
}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
for await (const file of files) {
const parts = file.name.split('/').filter(Boolean);
if (parts.length === 0) continue;
let currentDir: FileSystemDirectoryHandle = this.dirHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentDir = await currentDir.getDirectoryHandle(parts[i], { create: true });
}
const filename = parts[parts.length - 1];
const fileHandle = await currentDir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
const { input } = file;
if (input instanceof Blob) {
await writable.write(input);
} else if (typeof input === 'string') {
await writable.write(new Blob([input], { type: 'text/plain' }));
} else {
// ArrayBuffer or Uint8Array wrap in a Blob to guarantee strict typing.
// Use byteOffset/byteLength so only the view's range is included, not the
// whole backing ArrayBuffer (which may be larger due to pooling).
const buf =
input instanceof Uint8Array
? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)
: input;
await writable.write(new Blob([buf as ArrayBuffer]));
}
await writable.close();
}
}
}