feat(nextcloud-files): Hoch- und Herunterladen als Datenstrom, große Dateien in Stücken, ZIP

- Hochladen ohne Pufferung: ganze Datei oder Chunked Upload v2 in 8-MiB-Stücken, Zusammenbau im Hintergrund mit abfragbarem Zustand
- Überschreibschutz (If-None-Match / Entity-Tag-Prüfung), Download mit Range, Content-Disposition und Schutzkopfzeilen von Tessera selbst
- Ordner- und Auswahl-ZIP; jeder Nextcloud-Fehler wird vor dem ersten Byte abgebildet, nie 401/403
- Browser-Hochlader mit Fortschritt, Wiederholung, Abbruch und Aufräumen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:30:02 +02:00
parent 8bee65c306
commit 4d4a0b31cb
15 changed files with 3431 additions and 6 deletions
+29 -2
View File
@@ -65,7 +65,7 @@ export class NextcloudFilesRequestError extends Error {
}
}
async function failure(res: Response): Promise<NextcloudFilesRequestError> {
export async function requestFailure(res: Response): Promise<NextcloudFilesRequestError> {
let message = `Request failed (${res.status})`;
let code: string | null = null;
let extra: Record<string, unknown> = {};
@@ -91,6 +91,11 @@ async function failure(res: Response): Promise<NextcloudFilesRequestError> {
return new NextcloudFilesRequestError(res.status, code, message, extra);
}
/** Vollstaendige Adresse einer Route dieses Moduls (fuer Aufrufe, die nicht ueber `request` laufen). */
export function nextcloudFilesUrl(path: string): string {
return `${API_URL}${BASE}${path}`;
}
async function request<T>(
path: string,
init: { method?: string; json?: unknown } = {},
@@ -109,7 +114,7 @@ async function request<T>(
body,
...(method === 'GET' ? { cache: 'no-store' as const } : {}),
});
if (!res.ok) throw await failure(res);
if (!res.ok) throw await requestFailure(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
@@ -227,3 +232,25 @@ export function previewUrl(fileId: string, etag: string | null): string {
const version = etag ? `&v=${encodeURIComponent(etag)}` : '';
return `${API_URL}${BASE}/preview?fileId=${encodeURIComponent(fileId)}${version}`;
}
// --- Herunterladen (Aufgabe 4) ------------------------------------------------------------------
/**
* Adresse zum Herunterladen einer Datei — oder mit `zip` eines Ordners als ZIP. Ein
* Link in `<a href>` genuegt (Tessera-Cookie); die API setzt `Content-Disposition:
* attachment`, der Browser speichert also nur und zeigt nichts an. Der Pfad geht nur
* in der Query.
*/
export function downloadUrl(path: string, opts: { zip?: boolean } = {}): string {
const zip = opts.zip ? '&zip=1' : '';
return `${API_URL}${BASE}/download?path=${encodeURIComponent(path)}${zip}`;
}
/**
* Adresse zum Herunterladen mehrerer Eintraege eines Ordners als ein ZIP. Jeder Name
* steht als eigener `name`-Parameter (wiederholter Schluessel), einzeln codiert.
*/
export function zipUrl(dir: string, names: readonly string[]): string {
const list = names.map((n) => `name=${encodeURIComponent(n)}`).join('&');
return `${API_URL}${BASE}/download/zip?dir=${encodeURIComponent(dir)}&${list}`;
}