mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
import type { FileNode } from '@/lib/jmap/types';
|
|
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
|
|
|
|
/*
|
|
* Downloading folders: the server has no archive download, so the browser
|
|
* fetches every file below the folders and packs them into one zip (jszip),
|
|
* with the folders' own names at the top.
|
|
*/
|
|
|
|
export interface ZipEntry {
|
|
/** Path inside the zip, e.g. "Taxes/2025/return.pdf". */
|
|
path: string;
|
|
node: FileNode;
|
|
}
|
|
|
|
export interface ZipPlan {
|
|
files: ZipEntry[];
|
|
/** Folders with nothing in them, kept so the zip has the same shape. */
|
|
emptyFolders: string[];
|
|
/** Bytes to fetch, for the progress. */
|
|
totalBytes: number;
|
|
}
|
|
|
|
/** What goes into the zip of some items: files as they are, folders with everything below them. */
|
|
export function planZip(nodes: FileNode[], ids: string[]): ZipPlan {
|
|
const byParent = new Map<string | null, FileNode[]>();
|
|
for (const n of nodes) {
|
|
const key = n.parentId ?? null;
|
|
const list = byParent.get(key) ?? [];
|
|
list.push(n);
|
|
byParent.set(key, list);
|
|
}
|
|
const plan: ZipPlan = { files: [], emptyFolders: [], totalBytes: 0 };
|
|
const used = new Set<string>();
|
|
const unique = (path: string) => {
|
|
if (!used.has(path)) {
|
|
used.add(path);
|
|
return path;
|
|
}
|
|
const slash = path.lastIndexOf('/');
|
|
const dir = path.slice(0, slash + 1);
|
|
const name = path.slice(slash + 1);
|
|
const dot = name.lastIndexOf('.');
|
|
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
const ext = dot > 0 ? name.slice(dot) : '';
|
|
let n = 1;
|
|
while (used.has(`${dir}${base} (${n})${ext}`)) n++;
|
|
const next = `${dir}${base} (${n})${ext}`;
|
|
used.add(next);
|
|
return next;
|
|
};
|
|
const seen = new Set<string>();
|
|
const add = (node: FileNode, prefix: string) => {
|
|
if (seen.has(node.id)) return;
|
|
seen.add(node.id);
|
|
const kind = fileNodeKind(node);
|
|
// A symlink (Stalwart 1.0) has no content to pack.
|
|
if (kind === 'symlink') return;
|
|
const path = unique(prefix + node.name.replace(/[\\/]/g, '_'));
|
|
if (kind === 'directory') {
|
|
const children = byParent.get(node.id) ?? [];
|
|
if (children.length === 0) plan.emptyFolders.push(path);
|
|
for (const child of children) add(child, `${path}/`);
|
|
return;
|
|
}
|
|
if (!node.blobId) return;
|
|
plan.files.push({ path, node });
|
|
plan.totalBytes += node.size || 0;
|
|
};
|
|
for (const id of ids) {
|
|
const node = nodes.find(n => n.id === id);
|
|
if (node) add(node, '');
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
export interface ZipProgress {
|
|
/** 0..1 over fetching and packing. */
|
|
progress: number;
|
|
/** The file being fetched, while fetching. */
|
|
current?: string;
|
|
}
|
|
|
|
/** Fetch the plan's files and pack them. Throws an AbortError when `signal` aborts. */
|
|
export async function buildZip(
|
|
plan: ZipPlan,
|
|
fetchFile: (node: FileNode) => Promise<Blob>,
|
|
options: { signal?: AbortSignal; onProgress?: (p: ZipProgress) => void } = {},
|
|
): Promise<Blob> {
|
|
const { signal, onProgress } = options;
|
|
const { default: JSZip } = await import('jszip');
|
|
const zip = new JSZip();
|
|
const abortIfAsked = () => {
|
|
if (signal?.aborted) throw new DOMException('The download was canceled', 'AbortError');
|
|
};
|
|
for (const folder of plan.emptyFolders) zip.folder(folder);
|
|
// Fetching is most of the work; packing gets the last tenth.
|
|
let fetched = 0;
|
|
for (const entry of plan.files) {
|
|
abortIfAsked();
|
|
onProgress?.({ progress: plan.totalBytes > 0 ? (fetched / plan.totalBytes) * 0.9 : 0, current: entry.path });
|
|
zip.file(entry.path, await fetchFile(entry.node), { date: entry.node.modified ? new Date(entry.node.modified) : undefined });
|
|
fetched += entry.node.size || 0;
|
|
}
|
|
abortIfAsked();
|
|
const blob = await zip.generateAsync(
|
|
// Photos, videos and office files are compressed already; a light level
|
|
// keeps big folders quick.
|
|
{ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 1 } },
|
|
(meta) => onProgress?.({ progress: 0.9 + (meta.percent / 100) * 0.1 }),
|
|
);
|
|
abortIfAsked();
|
|
return blob;
|
|
}
|
|
|
|
/** Hand a blob to the browser as a download. */
|
|
export function saveBlob(blob: Blob, name: string): void {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
}
|