fix(nextcloud-files): WR-08/IN-06 Auswahl-ZIP per POST, Downloads mit Vorabpruefung
- WR-08: POST download/zip mit den Namen im Koerper (JSON oder unsichtbares Formular mit names als JSON-Text) statt im Query-String; hoechstens 1000 Namen; die Weboberflaeche laedt per Formular in einen unsichtbaren Rahmen (Strom direkt auf die Platte, Cookie und Content-Disposition bleiben); alles ausgewaehlt -> ganzer Ordner als ZIP - Nextcloud nimmt die Auswahl nur in ihrer Adresse an (gemessen: Apache lehnt ueber 8190 Zeichen mit 414 ab); Tessera prueft die Laenge und antwortet vorher mit 413 selectionTooLarge, die Weboberflaeche meldet es schon vor dem Absenden - IN-06: Vorabpruefung (check=1 bzw. check: true, ein PROPFIND Depth 0) vor jedem Download; Fehler stehen in der Statuszeile, connectionExpired fuehrt zum Anmeldebildschirm; auch der Klick auf einen Dateinamen geht ueber die Pruefung - e2e-transfer auf POST umgestellt (JSON, Formular ueber /api-proxy, 300 Namen, Pruefung), Anwenderhandbuch Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,21 +112,51 @@ export class DownloadQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['1'])
|
||||
zip?: string;
|
||||
|
||||
/**
|
||||
* `1`: nur pruefen (Verbindung, Eintrag vorhanden), keinen Datenstrom liefern (IN-06). Die
|
||||
* Weboberflaeche fragt so vor dem eigentlichen Herunterladen, weil sie Fehler eines
|
||||
* Downloads im Browser sonst nicht sieht.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(['1'])
|
||||
check?: string;
|
||||
}
|
||||
|
||||
export class ZipQueryDto {
|
||||
/** Hoechstzahl gewaehlter Eintraege fuer ein Auswahl-ZIP (WR-08). */
|
||||
export const ZIP_MAX_NAMES = 1000;
|
||||
|
||||
/**
|
||||
* Auswahl-ZIP (WR-08): die Namen kommen im KOERPER eines POST, nicht in der Adresse — mehrere
|
||||
* hundert Namen sprengten sonst die Kopfzeilen-Grenze von Node (431) oder den Proxy (414).
|
||||
* Als JSON (`names` ist eine Liste) oder aus einem unsichtbaren Formular der Weboberflaeche
|
||||
* (`names` ist dieselbe Liste als JSON-Text; so laedt der Browser das ZIP als Strom direkt
|
||||
* auf die Platte, statt es im Arbeitsspeicher zu sammeln).
|
||||
*/
|
||||
export class ZipBodyDto {
|
||||
/** Ordner, aus dem die gewaehlten Eintraege kommen (`/` = Wurzel). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
dir?: string;
|
||||
|
||||
/** Namen der gewaehlten Eintraege; ein einzelnes `name=...` wird zur Liste. */
|
||||
@Transform(({ value }) => (Array.isArray(value) ? value : value === undefined ? [] : [value]))
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ArrayMaxSize(ZIP_MAX_NAMES)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(255, { each: true })
|
||||
name!: string[];
|
||||
names!: string[];
|
||||
|
||||
/** `true` (JSON) oder `1` (Formular): nur pruefen, kein ZIP (IN-06). */
|
||||
@IsOptional()
|
||||
@IsIn([true, '1'])
|
||||
check?: boolean | string;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,12 @@ class FakeRes extends Writable {
|
||||
code = 200;
|
||||
headers: Record<string, string> = {};
|
||||
written: Buffer[] = [];
|
||||
jsonBody: unknown = undefined;
|
||||
json(body: unknown) {
|
||||
this.jsonBody = body;
|
||||
this.end();
|
||||
return this;
|
||||
}
|
||||
status(c: number) {
|
||||
this.code = c;
|
||||
return this;
|
||||
@@ -687,6 +693,85 @@ describe('Eingaben: replaceEtag (IN-01)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudFilesTransferService — Auswahl-ZIP im Koerper, Vorabpruefung (WR-08, IN-06)', () => {
|
||||
it('ZipBodyDto: Namen als JSON-Liste oder als JSON-Text aus einem Formular; mehr als 1000 ist 400', async () => {
|
||||
const { plainToInstance } = await import('class-transformer');
|
||||
const { validate } = await import('class-validator');
|
||||
const { ZipBodyDto } = await import('./dto/nextcloud-files-transfer.dto');
|
||||
const asJson = plainToInstance(ZipBodyDto, { dir: '/A', names: ['a', 'ä b'] });
|
||||
expect(await validate(asJson)).toHaveLength(0);
|
||||
const asForm = plainToInstance(ZipBodyDto, { dir: '/A', names: '["a","ä b"]', check: '1' });
|
||||
expect(await validate(asForm)).toHaveLength(0);
|
||||
expect(asForm.names).toEqual(['a', 'ä b']);
|
||||
const tooMany = plainToInstance(ZipBodyDto, {
|
||||
names: Array.from({ length: 1001 }, (_, i) => `n${i}`),
|
||||
});
|
||||
expect((await validate(tooMany)).length).toBeGreaterThan(0);
|
||||
const notJson = plainToInstance(ZipBodyDto, { names: 'a' });
|
||||
expect((await validate(notJson)).length).toBeGreaterThan(0);
|
||||
const thousand = plainToInstance(ZipBodyDto, {
|
||||
names: Array.from({ length: 1000 }, (_, i) => `Datei mit einem längeren Namen ${i}.pdf`),
|
||||
});
|
||||
expect(await validate(thousand)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('check: ein PROPFIND Depth 0 auf den Ordner, Antwort { ok: true }, kein ZIP', async () => {
|
||||
const { service, calls } = setup(() => ({ status: 207, text: statXml('e1') }));
|
||||
const res = new FakeRes();
|
||||
await service.downloadZip(res as never, 't1', 'u1', '/Projekte', ['a.txt'], { check: true });
|
||||
expect(res.jsonBody).toEqual({ ok: true });
|
||||
expect(calls.map((c) => c.method)).toEqual(['PROPFIND']);
|
||||
expect(calls[0].headers.depth).toBe('0');
|
||||
});
|
||||
|
||||
it('check auf eine verschwundene Datei: notFound; mit totem Zugang: connectionExpired', async () => {
|
||||
const gone = setup(() => ({ status: 404 }));
|
||||
const e = parts(
|
||||
await caught(
|
||||
gone.service.download(new FakeRes() as never, 't1', 'u1', '/weg.txt', { check: true }),
|
||||
),
|
||||
);
|
||||
expect(e.body.code).toBe('notFound');
|
||||
const dead = setup(() => ({ status: 401 }));
|
||||
const e2 = parts(
|
||||
await caught(
|
||||
dead.service.download(new FakeRes() as never, 't1', 'u1', '/a.txt', { check: true }),
|
||||
),
|
||||
);
|
||||
expect(e2.body.code).toBe('connectionExpired');
|
||||
});
|
||||
|
||||
it('Auswahl, deren Adresse an Nextcloud zu lang wuerde: 413 selectionTooLarge, auch bei der Pruefung', async () => {
|
||||
const { service, calls } = setup(() => ({ status: 207, text: statXml('e1') }));
|
||||
const names = Array.from({ length: 300 }, (_, i) => `Datei mit Namen ${i}.pdf`);
|
||||
for (const check of [false, true]) {
|
||||
const e = parts(
|
||||
await caught(
|
||||
service.downloadZip(new FakeRes() as never, 't1', 'u1', '/Projekte', names, { check }),
|
||||
),
|
||||
);
|
||||
expect(e).toMatchObject({ status: 413, body: { code: 'selectionTooLarge' } });
|
||||
}
|
||||
expect(calls).toHaveLength(0);
|
||||
// 100 solche Namen passen
|
||||
const ok = new FakeRes();
|
||||
await service.downloadZip(ok as never, 't1', 'u1', '/Projekte', names.slice(0, 100), {
|
||||
check: true,
|
||||
});
|
||||
expect(ok.jsonBody).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('mehr als 1000 Namen: 400 ohne Aufruf', async () => {
|
||||
const { service, calls } = setup();
|
||||
const names = Array.from({ length: 1001 }, (_, i) => `n${i}`);
|
||||
const e = parts(
|
||||
await caught(service.downloadZip(new FakeRes() as never, 't1', 'u1', '/', names)),
|
||||
);
|
||||
expect(e.status).toBe(400);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextcloudFilesTransferService — Herunterladen', () => {
|
||||
const FILE = {
|
||||
status: 200,
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { Readable } from 'node:stream';
|
||||
import { HttpException, Inject, Injectable } from '@nestjs/common';
|
||||
import { NEXTCLOUD_FILES_CHUNK_SIZE, NEXTCLOUD_FILES_MAX_CHUNKS } from '@tessera/shared';
|
||||
import type { Response } from 'express';
|
||||
import { ZIP_MAX_NAMES } from './dto/nextcloud-files-transfer.dto';
|
||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import * as dav from './nextcloud-dav';
|
||||
import * as transfer from './nextcloud-dav-transfer';
|
||||
import { type NcSession, ncError, ncErrorDefault } from './nextcloud-files.types';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import {
|
||||
buildNcUrl,
|
||||
type NcResult,
|
||||
NEXTCLOUD_TRANSPORT,
|
||||
type NextcloudTransport,
|
||||
@@ -67,6 +69,12 @@ export const MAX_UPLOAD_BYTES = NEXTCLOUD_FILES_MAX_CHUNKS * CHUNK_SIZE;
|
||||
export const ASSEMBLY_WAIT_MS = 20_000;
|
||||
/** So lange bleibt der Ausgang eines Zusammenbaus abfragbar. */
|
||||
export const ASSEMBLY_STATE_TTL_MS = 10 * 60_000;
|
||||
/**
|
||||
* Laengste Adresse fuer ein Auswahl-ZIP an Nextcloud (WR-08). Nextcloud nimmt die Auswahl nur
|
||||
* in der Adresse an (`files=<JSON>`), und ein Apache davor lehnt Anfragezeilen ueber 8190
|
||||
* Zeichen mit 414 ab (gemessen gegen nextcloud:stable: 6150 Zeichen gehen, 8200 nicht).
|
||||
*/
|
||||
export const ZIP_MAX_URL_CHARS = 8000;
|
||||
/** Obergrenze fuer gleichzeitig gemerkte Zusammenbauten (Schutz des Arbeitsspeichers). */
|
||||
const ASSEMBLY_STATE_MAX = 2000;
|
||||
|
||||
@@ -555,17 +563,23 @@ export class NextcloudFilesTransferService {
|
||||
|
||||
// --- Herunterladen ----------------------------------------------------------------------------
|
||||
|
||||
/** Datei (oder mit `zip` ein Ordner als ZIP) als Strom an den Browser; `range` geht mit. */
|
||||
/**
|
||||
* Datei (oder mit `zip` ein Ordner als ZIP) als Strom an den Browser; `range` geht mit. Mit
|
||||
* `check` wird nur geprueft, ob Verbindung und Eintrag da sind (IN-06): Antwort `{ ok: true }`
|
||||
* oder der uebliche Fehler — so kann die Weboberflaeche einen Fehler anzeigen, den sie bei
|
||||
* einem Download im Browser nicht saehe.
|
||||
*/
|
||||
async download(
|
||||
res: Response,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
rawPath: string,
|
||||
opts: { zip?: boolean; range?: string } = {},
|
||||
opts: { zip?: boolean; range?: string; check?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const segments = parseUserPath(rawPath);
|
||||
if (segments.length === 0 && !opts.zip) throw ncErrorDefault('invalidPath');
|
||||
const session = await this.session(tenantId, userId);
|
||||
if (opts.check) return this.sendCheck(res, tenantId, userId, session, segments);
|
||||
const abort = this.abortOnResponseClose(res);
|
||||
|
||||
const upstream = opts.zip
|
||||
@@ -582,19 +596,33 @@ export class NextcloudFilesTransferService {
|
||||
await this.send(res, upstream, name, tenantId, userId);
|
||||
}
|
||||
|
||||
/** Nur die gewaehlten Eintraege eines Ordners als ZIP. */
|
||||
/**
|
||||
* Nur die gewaehlten Eintraege eines Ordners als ZIP (WR-08): hoechstens 1000 Namen, und die
|
||||
* Adresse an Nextcloud darf `ZIP_MAX_URL_CHARS` nicht ueberschreiten (sonst 413
|
||||
* `selectionTooLarge`, auch schon bei der Vorabpruefung).
|
||||
*/
|
||||
async downloadZip(
|
||||
res: Response,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
rawDir: string | undefined,
|
||||
names: readonly string[],
|
||||
opts: { check?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const dir = parseUserPath(rawDir);
|
||||
// Alle Namen vor dem ersten Aufruf pruefen (`..` -> 400 invalidPath).
|
||||
for (const name of names) validateSegment(name);
|
||||
if (names.length === 0) throw ncErrorDefault('invalidPath');
|
||||
if (names.length === 0 || names.length > ZIP_MAX_NAMES) throw ncErrorDefault('invalidPath');
|
||||
const session = await this.session(tenantId, userId);
|
||||
const upstreamUrl = buildNcUrl(
|
||||
session.baseUrl,
|
||||
'/remote.php/dav/files/',
|
||||
[session.ncUserId, ...dir],
|
||||
{ accept: 'zip', files: JSON.stringify(names) },
|
||||
true,
|
||||
);
|
||||
if (upstreamUrl.length > ZIP_MAX_URL_CHARS) throw ncErrorDefault('selectionTooLarge');
|
||||
if (opts.check) return this.sendCheck(res, tenantId, userId, session, dir);
|
||||
const abort = this.abortOnResponseClose(res);
|
||||
const upstream = await transfer.downloadSelectionZip(
|
||||
this.transport,
|
||||
@@ -608,6 +636,22 @@ export class NextcloudFilesTransferService {
|
||||
await this.send(res, upstream, name, tenantId, userId);
|
||||
}
|
||||
|
||||
/** Vorabpruefung eines Downloads (IN-06): ein PROPFIND Depth 0, Antwort `{ ok: true }`. */
|
||||
private async sendCheck(
|
||||
res: Response,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
session: NcSession,
|
||||
segments: readonly string[],
|
||||
): Promise<void> {
|
||||
const probe = await dav.stat(this.transport, this.gate, session, segments);
|
||||
if (!probe.ok) return this.fail(tenantId, userId, probe);
|
||||
if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe);
|
||||
res.status(200);
|
||||
res.setHeader('cache-control', 'private, no-store');
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
private abortOnResponseClose(res: Response): AbortSignal {
|
||||
const abort = new AbortController();
|
||||
res.on('close', () => {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('NextcloudFilesController — Metadaten', () => {
|
||||
expect(route('move')).toEqual([1, 'move']);
|
||||
expect(route('preview')).toEqual([0, 'preview']);
|
||||
expect(route('download')).toEqual([0, 'download']);
|
||||
expect(route('downloadZip')).toEqual([0, 'download/zip']);
|
||||
expect(route('downloadZip')).toEqual([1, 'download/zip']);
|
||||
expect(route('startUpload')).toEqual([1, 'uploads']);
|
||||
expect(route('putSingle')).toEqual([2, 'uploads/file']);
|
||||
expect(route('putChunk')).toEqual([2, 'uploads/:uploadId/chunks/:n']);
|
||||
@@ -297,7 +297,9 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c';
|
||||
await controller.download(r, res, { path: '/a.txt' } as any);
|
||||
await controller.download(r, res, { path: '/Ordner', zip: '1' } as any);
|
||||
await controller.downloadZip(r, res, { dir: '/Ordner', name: ['a', 'b'] } as any);
|
||||
await controller.downloadZip(r, res, { dir: '/Ordner', names: ['a', 'b'] } as any);
|
||||
await controller.downloadZip(r, res, { dir: '/Ordner', names: ['a'], check: '1' } as any);
|
||||
await controller.download(r, res, { path: '/a.txt', check: '1' } as any);
|
||||
await controller.startUpload(r, { path: '/x', size: 5, userId: 'fremd' } as any);
|
||||
const rawReq = Object.assign(raw, r);
|
||||
await controller.putSingle(rawReq, { path: '/x', size: 5 } as any);
|
||||
@@ -314,12 +316,32 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
expect(transfer.download).toHaveBeenNthCalledWith(1, res, 't1', 'u1', '/a.txt', {
|
||||
zip: false,
|
||||
range: 'bytes=0-99',
|
||||
check: false,
|
||||
});
|
||||
expect(transfer.download).toHaveBeenNthCalledWith(2, res, 't1', 'u1', '/Ordner', {
|
||||
zip: true,
|
||||
range: 'bytes=0-99',
|
||||
check: false,
|
||||
});
|
||||
expect(transfer.download).toHaveBeenNthCalledWith(3, res, 't1', 'u1', '/a.txt', {
|
||||
zip: false,
|
||||
range: 'bytes=0-99',
|
||||
check: true,
|
||||
});
|
||||
expect(transfer.downloadZip).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
res,
|
||||
't1',
|
||||
'u1',
|
||||
'/Ordner',
|
||||
['a', 'b'],
|
||||
{
|
||||
check: false,
|
||||
},
|
||||
);
|
||||
expect(transfer.downloadZip).toHaveBeenNthCalledWith(2, res, 't1', 'u1', '/Ordner', ['a'], {
|
||||
check: true,
|
||||
});
|
||||
expect(transfer.downloadZip).toHaveBeenCalledWith(res, 't1', 'u1', '/Ordner', ['a', 'b']);
|
||||
expect(transfer.startUpload).toHaveBeenCalledWith('t1', 'u1', {
|
||||
path: '/x',
|
||||
size: 5,
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
DownloadQueryDto,
|
||||
StartUploadDto,
|
||||
UploadQueryDto,
|
||||
ZipQueryDto,
|
||||
ZipBodyDto,
|
||||
} from './dto/nextcloud-files-transfer.dto';
|
||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
@@ -63,7 +63,7 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
|
||||
* Benutzen (nur Klassen-`@UseModule`): alles andere — GET status, GET server,
|
||||
* GET server/logo, POST connect/password, POST connect/flow,
|
||||
* DELETE connect, GET files, DELETE files, POST folders, POST move,
|
||||
* GET preview, GET download, GET download/zip, POST uploads, PUT
|
||||
* GET preview, GET download, POST download/zip, POST uploads, PUT
|
||||
* uploads/file; danach die Parameterrouten am ENDE: GET/DELETE
|
||||
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST
|
||||
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
|
||||
@@ -248,23 +248,29 @@ export class NextcloudFilesController {
|
||||
{
|
||||
zip: query.zip === '1',
|
||||
range: headerOf(req.headers.range),
|
||||
check: query.check === '1',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Nur die gewaehlten Eintraege eines Ordners als ZIP (`name` darf mehrfach vorkommen). */
|
||||
@Get('download/zip')
|
||||
/**
|
||||
* Nur die gewaehlten Eintraege eines Ordners als ZIP. Die Namen stehen im Koerper (WR-08),
|
||||
* als JSON oder aus einem Formular; `check` prueft nur, ohne ZIP (IN-06).
|
||||
*/
|
||||
@Post('download/zip')
|
||||
@HttpCode(200)
|
||||
async downloadZip(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
@Query() query: ZipQueryDto,
|
||||
@Body() body: ZipBodyDto,
|
||||
): Promise<void> {
|
||||
await this.transfer.downloadZip(
|
||||
res,
|
||||
this.requireTenantId(req),
|
||||
this.requireUserId(req),
|
||||
query.dir,
|
||||
query.name,
|
||||
body.dir,
|
||||
body.names,
|
||||
{ check: body.check === true || body.check === '1' },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export type NcErrorCode =
|
||||
| 'lengthRequired'
|
||||
| 'chunkTooLarge'
|
||||
| 'fileTooLarge'
|
||||
| 'selectionTooLarge'
|
||||
| 'flowExpired'
|
||||
| 'tooManyFlows'
|
||||
| 'invalidUrl'
|
||||
@@ -155,6 +156,11 @@ export const NC_ERROR_DEFAULTS: Record<NcErrorCode, ErrorDefault> = {
|
||||
status: 413,
|
||||
message: 'Die Datei ist zu groß für die Übertragung.',
|
||||
},
|
||||
selectionTooLarge: {
|
||||
status: 413,
|
||||
message:
|
||||
'Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter.',
|
||||
},
|
||||
flowExpired: {
|
||||
status: 410,
|
||||
message: 'Die Anmeldung im Browser ist abgelaufen. Bitte starten Sie sie erneut.',
|
||||
|
||||
@@ -24,6 +24,8 @@ const mockCreate = vi.fn();
|
||||
const mockMove = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
const mockUpload = vi.fn();
|
||||
const mockCheckDownload = vi.fn();
|
||||
const mockCheckZip = vi.fn();
|
||||
|
||||
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
||||
@@ -33,6 +35,8 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||
createFolder: (...a: unknown[]) => mockCreate(...a),
|
||||
moveEntry: (...a: unknown[]) => mockMove(...a),
|
||||
deleteEntry: (...a: unknown[]) => mockDelete(...a),
|
||||
checkDownload: (...a: unknown[]) => mockCheckDownload(...a),
|
||||
checkZip: (...a: unknown[]) => mockCheckZip(...a),
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/nextcloud-files-upload', () => ({
|
||||
@@ -98,6 +102,7 @@ function render(ui: ReactElement) {
|
||||
}
|
||||
|
||||
let clicked: string[] = [];
|
||||
let submitted: { action: string; target: string; fields: Record<string, string> }[] = [];
|
||||
let onExpired: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -109,13 +114,24 @@ beforeEach(() => {
|
||||
mockMove.mockReset().mockResolvedValue({ from: '', to: '' });
|
||||
mockDelete.mockReset().mockResolvedValue({ deleted: true });
|
||||
mockUpload.mockReset().mockResolvedValue({ path: '/x', size: 1 });
|
||||
mockCheckDownload.mockReset().mockResolvedValue({ ok: true });
|
||||
mockCheckZip.mockReset().mockResolvedValue({ ok: true });
|
||||
onExpired = vi.fn();
|
||||
clicked = [];
|
||||
submitted = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
clicked.push(this.href);
|
||||
});
|
||||
vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(function (
|
||||
this: HTMLFormElement,
|
||||
) {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const input of Array.from(this.querySelectorAll('input')))
|
||||
fields[input.name] = input.value;
|
||||
submitted.push({ action: this.action, target: this.target, fields });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -188,7 +204,8 @@ describe('FileBrowser — Liste', () => {
|
||||
const file = screen.getByRole('row', { name: 'Bericht.pdf' });
|
||||
fireEvent.focus(file);
|
||||
fireEvent.keyDown(file, { key: 'Enter' });
|
||||
expect(clicked).toHaveLength(1);
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
expect(mockCheckDownload).toHaveBeenCalledWith('/Bericht.pdf', { zip: false });
|
||||
expect(clicked[0]).toContain('/modules/nextcloud-files/download?path=%2FBericht.pdf');
|
||||
const dir = screen.getByRole('row', { name: 'Projekte' });
|
||||
fireEvent.focus(dir);
|
||||
@@ -215,12 +232,54 @@ describe('FileBrowser — Liste', () => {
|
||||
expect(screen.getByRole('button', { name: 'Hochladen' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Herunterladen mehrerer Eintraege nutzt das Auswahl-ZIP', async () => {
|
||||
it('Herunterladen mehrerer Eintraege nutzt das Auswahl-ZIP per POST mit den Namen im Koerper (WR-08)', async () => {
|
||||
await mount();
|
||||
fireEvent.click(screen.getByRole('row', { name: 'Bericht.pdf' }));
|
||||
fireEvent.click(screen.getByRole('row', { name: 'Projekte' }), { ctrlKey: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' }));
|
||||
expect(clicked[0]).toContain('/download/zip?dir=%2F&name=Projekte&name=Bericht.pdf');
|
||||
await waitFor(() => expect(submitted).toHaveLength(1));
|
||||
expect(mockCheckZip).toHaveBeenCalledWith('/', ['Projekte', 'Bericht.pdf']);
|
||||
expect(submitted[0].action).toMatch(/\/modules\/nextcloud-files\/download\/zip$/);
|
||||
expect(submitted[0].target).toBe('nc-files-download');
|
||||
expect(submitted[0].fields).toEqual({ dir: '/', names: '["Projekte","Bericht.pdf"]' });
|
||||
expect(clicked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('alles ausgewaehlt: der ganze Ordner als ZIP statt einer Namensliste', async () => {
|
||||
await mount();
|
||||
fireEvent.keyDown(screen.getByRole('row', { name: 'Bericht.pdf' }), {
|
||||
key: 'a',
|
||||
ctrlKey: true,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' }));
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
expect(clicked[0]).toContain('download?path=%2F&zip=1');
|
||||
expect(submitted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scheitert die Vorabpruefung, steht der Grund in der Statuszeile und nichts wird geladen (IN-06)', async () => {
|
||||
mockCheckDownload.mockRejectedValueOnce(
|
||||
new NextcloudFilesRequestError(404, 'notFound', 'Der Eintrag wurde nicht gefunden.'),
|
||||
);
|
||||
await mount();
|
||||
const file = screen.getByRole('row', { name: 'Bericht.pdf' });
|
||||
fireEvent.focus(file);
|
||||
fireEvent.keyDown(file, { key: 'Enter' });
|
||||
expect(
|
||||
await screen.findByText('Herunterladen nicht möglich: Der Eintrag existiert nicht mehr.'),
|
||||
).toBeTruthy();
|
||||
expect(clicked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('abgelaufene Verbindung beim Herunterladen: zurueck zum Anmeldebildschirm (IN-06)', async () => {
|
||||
mockCheckDownload.mockRejectedValueOnce(
|
||||
new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'),
|
||||
);
|
||||
await mount();
|
||||
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Als ZIP herunterladen' }));
|
||||
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
|
||||
expect(clicked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Entf oeffnet die Rueckfrage mit Anzahl und Papierkorb; Bestaetigen loescht je Pfad und laedt neu', async () => {
|
||||
@@ -342,6 +401,7 @@ describe('FileBrowser — Liste', () => {
|
||||
await mount();
|
||||
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Als ZIP herunterladen' }));
|
||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
||||
expect(clicked[0]).toContain('download?path=%2FProjekte&zip=1');
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { basename, freeName, join, parent, ROOT } from '@/components/nextcloud-f
|
||||
import { EMPTY_SELECTION, selectionReducer } from '@/components/nextcloud-files/selection';
|
||||
import { isActive, useTransfers } from '@/components/nextcloud-files/use-transfers';
|
||||
import {
|
||||
checkDownload,
|
||||
checkZip,
|
||||
createFolder,
|
||||
deleteEntry,
|
||||
downloadUrl,
|
||||
@@ -16,7 +18,9 @@ import {
|
||||
type NcEntry,
|
||||
type NcListing,
|
||||
type NcQuota,
|
||||
zipUrl,
|
||||
zipFormFields,
|
||||
zipPostUrl,
|
||||
zipSelectionFits,
|
||||
} from '@/lib/nextcloud-files-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { DeleteDialog } from './DeleteDialog';
|
||||
@@ -82,6 +86,45 @@ export function triggerDownload(url: string): void {
|
||||
a.remove();
|
||||
}
|
||||
|
||||
const DOWNLOAD_FRAME = 'nc-files-download';
|
||||
|
||||
/**
|
||||
* Laedt per POST-Formular herunter (Auswahl-ZIP, WR-08): der Browser speichert den Strom
|
||||
* direkt (nichts im Arbeitsspeicher der Seite). Ziel ist ein unsichtbarer Rahmen, damit
|
||||
* eine unerwartete Fehlerantwort nie die Seite ersetzt.
|
||||
*/
|
||||
export function submitDownloadForm(action: string, fields: Record<string, string>): void {
|
||||
let frame = document.querySelector<HTMLIFrameElement>(`iframe[name="${DOWNLOAD_FRAME}"]`);
|
||||
if (!frame) {
|
||||
frame = document.createElement('iframe');
|
||||
frame.name = DOWNLOAD_FRAME;
|
||||
frame.title = DOWNLOAD_FRAME;
|
||||
frame.hidden = true;
|
||||
frame.tabIndex = -1;
|
||||
frame.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(frame);
|
||||
}
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = action;
|
||||
form.target = DOWNLOAD_FRAME;
|
||||
form.style.display = 'none';
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
}
|
||||
|
||||
type DownloadJob =
|
||||
| { kind: 'file' | 'folder'; path: string }
|
||||
| { kind: 'selection'; dir: string; names: string[] };
|
||||
|
||||
function isTypingTarget(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
return !!el.closest(
|
||||
@@ -427,27 +470,60 @@ export function FileBrowser({
|
||||
|
||||
// --- Aktionen ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Herunterladen mit Vorabpruefung (IN-06): erst fragt die Ansicht die API, ob Verbindung und
|
||||
* Eintrag da sind, dann startet der eigentliche Download im Browser. Ein Fehler steht in der
|
||||
* Statuszeile; eine abgelaufene Verbindung fuehrt zurueck zum Anmeldebildschirm.
|
||||
*/
|
||||
const startDownload = useCallback(
|
||||
async (job: DownloadJob) => {
|
||||
try {
|
||||
if (job.kind === 'selection') await checkZip(job.dir, job.names);
|
||||
else await checkDownload(job.path, { zip: job.kind === 'folder' });
|
||||
} catch (err) {
|
||||
const e = toErrorLike(err);
|
||||
if (e.code === 'connectionExpired') {
|
||||
onExpiredRef.current();
|
||||
return;
|
||||
}
|
||||
say(t('status.downloadFailed', { reason: errorText(tCodes, e, locale) }));
|
||||
return;
|
||||
}
|
||||
if (job.kind === 'selection') {
|
||||
submitDownloadForm(zipPostUrl(), zipFormFields(job.dir, job.names));
|
||||
} else {
|
||||
triggerDownload(downloadUrl(job.path, { zip: job.kind === 'folder' }));
|
||||
}
|
||||
},
|
||||
[say, t, tCodes, locale],
|
||||
);
|
||||
|
||||
const openEntry = useCallback(
|
||||
(entry: NcEntry) => {
|
||||
if (entry.type === 'folder') navigate(entry.path);
|
||||
else triggerDownload(downloadUrl(entry.path));
|
||||
else void startDownload({ kind: 'file', path: entry.path });
|
||||
},
|
||||
[navigate],
|
||||
[navigate, startDownload],
|
||||
);
|
||||
|
||||
const downloadEntries = (list: NcEntry[]) => {
|
||||
if (list.length === 0) return;
|
||||
if (list.length === 1) {
|
||||
const [only] = list;
|
||||
triggerDownload(downloadUrl(only.path, { zip: only.type === 'folder' }));
|
||||
void startDownload({ kind: only.type === 'folder' ? 'folder' : 'file', path: only.path });
|
||||
return;
|
||||
}
|
||||
triggerDownload(
|
||||
zipUrl(
|
||||
path,
|
||||
list.map((e) => e.name),
|
||||
),
|
||||
);
|
||||
// Alles im Ordner gewaehlt: der ganze Ordner als ZIP (ohne Namensliste).
|
||||
if (listing && !listing.truncated && list.length === entries.length) {
|
||||
void startDownload({ kind: 'folder', path });
|
||||
return;
|
||||
}
|
||||
const names = list.map((e) => e.name);
|
||||
if (!zipSelectionFits(path, names)) {
|
||||
say(t('status.zipTooMany'));
|
||||
return;
|
||||
}
|
||||
void startDownload({ kind: 'selection', dir: path, names });
|
||||
};
|
||||
|
||||
/** Fuehrt eine Aktion je Eintrag aus und meldet Teilerfolge; laedt danach neu. */
|
||||
@@ -555,14 +631,14 @@ export function FileBrowser({
|
||||
id: 'downloadZip',
|
||||
label: t('menu.downloadZip'),
|
||||
icon: <DownloadIcon />,
|
||||
onSelect: () => triggerDownload(downloadUrl(entry.path, { zip: true })),
|
||||
onSelect: () => void startDownload({ kind: 'folder', path: entry.path }),
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
id: 'download',
|
||||
label: t('menu.download'),
|
||||
icon: <DownloadIcon />,
|
||||
onSelect: () => triggerDownload(downloadUrl(entry.path)),
|
||||
onSelect: () => void startDownload({ kind: 'file', path: entry.path }),
|
||||
});
|
||||
}
|
||||
actions.push(
|
||||
|
||||
@@ -209,7 +209,12 @@ export function FileList({
|
||||
href={downloadUrl(entry.path)}
|
||||
tabIndex={-1}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
// Ueber die Ansicht laden: sie prueft vorab und meldet Fehler (IN-06).
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onItemOpen(entry);
|
||||
}}
|
||||
className="min-w-0 truncate text-foreground hover:underline hover:underline-offset-2"
|
||||
title={entry.name}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ const KNOWN = new Set([
|
||||
'invalidName',
|
||||
'invalidPath',
|
||||
'nextcloudUnavailable',
|
||||
'selectionTooLarge',
|
||||
]);
|
||||
|
||||
export interface ErrorLike {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkDownload,
|
||||
checkZip,
|
||||
createFolder,
|
||||
deleteEntry,
|
||||
downloadUrl,
|
||||
@@ -7,7 +9,10 @@ import {
|
||||
moveEntry,
|
||||
NextcloudFilesRequestError,
|
||||
previewUrl,
|
||||
zipUrl,
|
||||
ZIP_MAX_NAMES,
|
||||
zipFormFields,
|
||||
zipPostUrl,
|
||||
zipSelectionFits,
|
||||
} from './nextcloud-files-api';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -105,10 +110,43 @@ describe('nextcloud-files-api — Herunterladen', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('zipUrl: Ordner in dir, jeder Name als eigener wiederholter name-Schluessel, einzeln codiert', () => {
|
||||
expect(zipUrl('/Projekte', ['a.txt', 'Ärger & Co', '100%'])).toBe(
|
||||
`${API}/modules/nextcloud-files/download/zip?dir=%2FProjekte&name=a.txt&name=%C3%84rger%20%26%20Co&name=100%25`,
|
||||
it('Auswahl-ZIP per POST: Namen im Koerper als JSON-Liste, nie in der Adresse (WR-08)', () => {
|
||||
expect(zipPostUrl()).toBe(`${API}/modules/nextcloud-files/download/zip`);
|
||||
expect(zipFormFields('/Projekte', ['a.txt', 'Ärger & Co', '100%'])).toEqual({
|
||||
dir: '/Projekte',
|
||||
names: '["a.txt","Ärger & Co","100%"]',
|
||||
});
|
||||
});
|
||||
|
||||
it('zipSelectionFits: Anzahl und Laenge der Adresse an Nextcloud (Apache: 8190 Zeichen)', () => {
|
||||
const names = (n: number, len = 20) =>
|
||||
Array.from({ length: n }, (_, i) => `${String(i).padStart(4, '0')}${'x'.repeat(len)}`);
|
||||
expect(zipSelectionFits('/', names(200))).toBe(true);
|
||||
expect(zipSelectionFits('/', names(300))).toBe(false);
|
||||
expect(zipSelectionFits('/', names(ZIP_MAX_NAMES + 1, 0))).toBe(false);
|
||||
expect(zipSelectionFits('/', [])).toBe(false);
|
||||
// Umlaute zaehlen codiert (je sechs Zeichen)
|
||||
expect(zipSelectionFits('/', ['ä'.repeat(1200)])).toBe(false);
|
||||
});
|
||||
|
||||
it('Vorabpruefungen (IN-06): GET download mit check=1, POST download/zip mit check: true', async () => {
|
||||
const fn = mockFetch(200, { ok: true });
|
||||
await checkDownload('/Ordner/a b.txt');
|
||||
await checkDownload('/Ordner', { zip: true });
|
||||
await checkZip('/Ordner', ['a', 'b']);
|
||||
expect(fn.mock.calls[0][0]).toBe(
|
||||
`${API}/modules/nextcloud-files/download?path=%2FOrdner%2Fa%20b.txt&check=1`,
|
||||
);
|
||||
expect(zipUrl('/', ['x'])).toBe(`${API}/modules/nextcloud-files/download/zip?dir=%2F&name=x`);
|
||||
expect(fn.mock.calls[1][0]).toBe(
|
||||
`${API}/modules/nextcloud-files/download?path=%2FOrdner&zip=1&check=1`,
|
||||
);
|
||||
expect(fn.mock.calls[2][0]).toBe(`${API}/modules/nextcloud-files/download/zip`);
|
||||
const init = fn.mock.calls[2][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
dir: '/Ordner',
|
||||
names: ['a', 'b'],
|
||||
check: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,11 +269,49 @@ export function downloadUrl(path: string, opts: { zip?: boolean } = {}): string
|
||||
return `${API_URL}${BASE}/download?path=${encodeURIComponent(path)}${zip}`;
|
||||
}
|
||||
|
||||
/** Hoechstzahl gewaehlter Eintraege fuer ein Auswahl-ZIP (wie die API, WR-08). */
|
||||
export const ZIP_MAX_NAMES = 1000;
|
||||
/**
|
||||
* Adresse zum Herunterladen mehrerer Eintraege eines Ordners als ein ZIP. Jeder Name
|
||||
* steht als eigener `name`-Parameter (wiederholter Schluessel), einzeln codiert.
|
||||
* Budget fuer Ordner und Namensliste, codiert wie in der Adresse an Nextcloud. Nextcloud nimmt
|
||||
* die Auswahl nur in der Adresse an, und ein Apache davor lehnt Anfragezeilen ueber 8190
|
||||
* Zeichen ab; die API prueft verbindlich (413 `selectionTooLarge`), hier wird vorher und
|
||||
* etwas vorsichtiger gerechnet, damit der Benutzer sofort eine klare Meldung bekommt.
|
||||
*/
|
||||
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}`;
|
||||
export const ZIP_MAX_ENCODED_CHARS = 7000;
|
||||
|
||||
/**
|
||||
* Ziel des Auswahl-ZIPs (WR-08): POST mit den Namen im KOERPER, nicht in der Adresse —
|
||||
* mehrere hundert Namen sprengten sonst die Grenzen von Node (431) oder des Proxys (414).
|
||||
*/
|
||||
export function zipPostUrl(): string {
|
||||
return `${API_URL}${BASE}/download/zip`;
|
||||
}
|
||||
|
||||
/** Formularfelder des Auswahl-ZIPs: Ordner und die Namen als JSON-Liste. */
|
||||
export function zipFormFields(dir: string, names: readonly string[]): Record<string, string> {
|
||||
return { dir, names: JSON.stringify(names) };
|
||||
}
|
||||
|
||||
/** Passt die Auswahl in ein Auswahl-ZIP (Anzahl und Laenge der Adresse an Nextcloud)? */
|
||||
export function zipSelectionFits(dir: string, names: readonly string[]): boolean {
|
||||
if (names.length === 0 || names.length > ZIP_MAX_NAMES) return false;
|
||||
const encoded = encodeURIComponent(dir).length + encodeURIComponent(JSON.stringify(names)).length;
|
||||
return encoded <= ZIP_MAX_ENCODED_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorabpruefung eines Downloads (IN-06): Verbindung und Eintrag. Ein Download ueber einen Link
|
||||
* zeigt Fehler im Browser nicht an; so kann die Ansicht sie melden.
|
||||
*/
|
||||
export function checkDownload(path: string, opts: { zip?: boolean } = {}): Promise<{ ok: true }> {
|
||||
const zip = opts.zip ? '&zip=1' : '';
|
||||
return request<{ ok: true }>(`/download?path=${encodeURIComponent(path)}${zip}&check=1`);
|
||||
}
|
||||
|
||||
/** Vorabpruefung eines Auswahl-ZIPs (IN-06): Verbindung, Ordner und die Namen selbst. */
|
||||
export function checkZip(dir: string, names: readonly string[]): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>('/download/zip', {
|
||||
method: 'POST',
|
||||
json: { dir, names, check: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2486,7 +2486,9 @@
|
||||
"moved": "{count, plural, one {„{name}“ nach „{target}“ verschoben.} other {# Elemente nach „{target}“ verschoben.}}",
|
||||
"renamed": "Umbenannt in „{name}“.",
|
||||
"folderCreated": "Ordner „{name}“ angelegt.",
|
||||
"failed": "{failed, plural, one {Ein Element ließ sich nicht verarbeiten} other {# Elemente ließen sich nicht verarbeiten}}: {reason}"
|
||||
"failed": "{failed, plural, one {Ein Element ließ sich nicht verarbeiten} other {# Elemente ließen sich nicht verarbeiten}}: {reason}",
|
||||
"downloadFailed": "Herunterladen nicht möglich: {reason}",
|
||||
"zipTooMany": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter."
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
@@ -2569,7 +2571,8 @@
|
||||
"invalidName": "Dieser Name ist nicht erlaubt.",
|
||||
"invalidPath": "Dieser Name ist nicht erlaubt.",
|
||||
"nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.",
|
||||
"generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut."
|
||||
"generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut.",
|
||||
"selectionTooLarge": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2486,7 +2486,9 @@
|
||||
"moved": "{count, plural, one {“{name}” moved to “{target}”.} other {# items moved to “{target}”.}}",
|
||||
"renamed": "Renamed to “{name}”.",
|
||||
"folderCreated": "Folder “{name}” created.",
|
||||
"failed": "{failed, plural, one {One item could not be processed} other {# items could not be processed}}: {reason}"
|
||||
"failed": "{failed, plural, one {One item could not be processed} other {# items could not be processed}}: {reason}",
|
||||
"downloadFailed": "Download not possible: {reason}",
|
||||
"zipTooMany": "Too many entries for one ZIP file. Select fewer entries or download the whole folder."
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
@@ -2569,7 +2571,8 @@
|
||||
"invalidName": "This name is not allowed.",
|
||||
"invalidPath": "This name is not allowed.",
|
||||
"nextcloudUnavailable": "Nextcloud cannot be reached or does not respond in time.",
|
||||
"generic": "That did not work. Please try again."
|
||||
"generic": "That did not work. Please try again.",
|
||||
"selectionTooLarge": "Too many entries for one ZIP file. Select fewer entries or download the whole folder."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user