mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: store awkward file names and explain file errors on Stalwart 1.0
Stalwart 1.0 checks names more strictly than its capability says: a reserved name may not be the stem either (`con.txt`, `Aux.tar.gz`), and control characters are refused (macOS `Icon\r`). Names over 255 bytes of UTF-8 were never accepted. Uploaded names are now made acceptable before they are sent: control characters become "_", a reserved stem gets "_" after it (`con.txt` becomes `con_.txt`), and a long name is shortened, keeping its extension. The rename and new-folder dialogs refuse such names with their own messages. The rules apply wherever the server publishes naming rules, so 0.16.6 to 0.16.23 get `con_.txt` too. One refused file used to abort a whole upload. Uploads now go on and report the files that failed at the end, and a folder upload skips the contents of a folder it could not create instead of dropping them into the folder the upload started in. What would sit deeper than 1.0's `maxFileNodeDepth` (64) is left out before it is uploaded, and a folder copy that would go past it fails before the first create. File errors keep their JMAP type, and the Files toasts now say why an action failed: too many files or folders (`overQuota`), folders nested too deeply, a refused name, missing rights, or a file too large. New and copied files get a bare lowercase media type, which 1.0 requires.
This commit is contained in:
@@ -416,7 +416,12 @@ export function FileBrowser({
|
||||
const validateFileName = useCallback((name: string) => {
|
||||
const problem = fileNameProblem(name, fileNameRules);
|
||||
if (!problem) return null;
|
||||
return problem.kind === "chars" ? t("name_forbidden_chars", { chars: problem.chars }) : t("name_reserved");
|
||||
switch (problem.kind) {
|
||||
case "chars": return t("name_forbidden_chars", { chars: problem.chars });
|
||||
case "control": return t("name_control_chars");
|
||||
case "tooLong": return t("name_too_long");
|
||||
case "reserved": return t("name_reserved");
|
||||
}
|
||||
}, [fileNameRules, t]);
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore, resourceServerRef } from "@/stores/file-store";
|
||||
import { useFileStore, resourceServerRef, type UploadReport } from "@/stores/file-store";
|
||||
import { fileErrorKind } from "@/lib/file-errors";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
@@ -307,15 +308,41 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
}, [navigate, navigateByPath, isEmbedded]);
|
||||
|
||||
// What the server said went wrong, as the second line under an action's
|
||||
// own error message; undefined when it said nothing specific.
|
||||
const failureReason = useCallback((err: unknown): string | undefined => {
|
||||
switch (fileErrorKind(err)) {
|
||||
case "overQuota": return t("error_over_quota");
|
||||
case "tooDeep": return t("error_too_deep");
|
||||
case "invalidName": return t("error_invalid_name");
|
||||
case "forbidden": return t("error_forbidden");
|
||||
case "tooLarge": return t("error_too_large");
|
||||
default: return undefined;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// An upload goes on past a file the server refuses; the refused ones are
|
||||
// reported once it is done.
|
||||
const showUploadReport = useCallback((report: UploadReport) => {
|
||||
if (report.uploaded > 0) {
|
||||
toast.success(t("upload_success", { count: report.uploaded }));
|
||||
}
|
||||
if (report.failed.length === 0) return;
|
||||
const names = report.failed.slice(0, 3).map((f) => f.name).join(", ")
|
||||
+ (report.failed.length > 3 ? ", …" : "");
|
||||
const reason = failureReason(report.failed[0].error);
|
||||
toast.error(t("upload_failed", { count: report.failed.length }), reason ? `${names}: ${reason}` : names);
|
||||
}, [t, failureReason]);
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
try {
|
||||
await createDirectory(name);
|
||||
toast.success(t("create_folder_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to create folder:", err);
|
||||
toast.error(t("create_folder_error"));
|
||||
toast.error(t("create_folder_error"), failureReason(err));
|
||||
}
|
||||
}, [createDirectory, t]);
|
||||
}, [createDirectory, t, failureReason]);
|
||||
|
||||
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||
|
||||
@@ -329,13 +356,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
await uploadFiles(files);
|
||||
toast.success(t("upload_success", { count: files.length }));
|
||||
showUploadReport(await uploadFiles(files));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload files:", err);
|
||||
toast.error(t("upload_error"));
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
}
|
||||
}, [uploadFiles, t, maxSizeUpload]);
|
||||
}, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason]);
|
||||
|
||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
@@ -347,13 +373,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
await uploadFolder(files);
|
||||
toast.success(t("upload_success", { count: files.length }));
|
||||
showUploadReport(await uploadFolder(files));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload folder:", err);
|
||||
toast.error(t("upload_error"));
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
}
|
||||
}, [uploadFolder, t, maxSizeUpload]);
|
||||
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]);
|
||||
|
||||
const handleDelete = useCallback(async (name: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
@@ -369,9 +394,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
toast.success(t("delete_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"));
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
}, [deleteResource, confirmDialog, t]);
|
||||
}, [deleteResource, confirmDialog, t, failureReason]);
|
||||
|
||||
const handleBatchDelete = useCallback(async (names: string[]) => {
|
||||
const confirmed = await confirmDialog({
|
||||
@@ -387,9 +412,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
toast.success(t("batch_delete_success", { count: names.length }));
|
||||
} catch (err) {
|
||||
console.error("Failed to batch delete:", err);
|
||||
toast.error(t("delete_error"));
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
}, [deleteResources, confirmDialog, t]);
|
||||
}, [deleteResources, confirmDialog, t, failureReason]);
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
try {
|
||||
@@ -397,9 +422,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
toast.success(t("undo_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to undo:", err);
|
||||
toast.error(t("undo_error"));
|
||||
toast.error(t("undo_error"), failureReason(err));
|
||||
}
|
||||
}, [undoLastAction, t]);
|
||||
}, [undoLastAction, t, failureReason]);
|
||||
|
||||
const handleRename = useCallback(async (oldName: string, newName: string) => {
|
||||
try {
|
||||
@@ -409,9 +434,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to rename:", err);
|
||||
toast.error(t("rename_error"));
|
||||
toast.error(t("rename_error"), failureReason(err));
|
||||
}
|
||||
}, [renameResource, t, handleUndo]);
|
||||
}, [renameResource, t, handleUndo, failureReason]);
|
||||
|
||||
const findResourceId = useCallback((name: string) => {
|
||||
const r = resources.find(res => res.name === name);
|
||||
@@ -443,9 +468,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
toast.success(t("create_file_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to create file:", err);
|
||||
toast.error(t("create_file_error"));
|
||||
toast.error(t("create_file_error"), failureReason(err));
|
||||
}
|
||||
}, [createTextFile, t]);
|
||||
}, [createTextFile, t, failureReason]);
|
||||
|
||||
const handleDuplicate = useCallback(async (name: string) => {
|
||||
try {
|
||||
@@ -453,9 +478,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
toast.success(t("duplicate_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to duplicate:", err);
|
||||
toast.error(t("duplicate_error"));
|
||||
toast.error(t("duplicate_error"), failureReason(err));
|
||||
}
|
||||
}, [duplicateResource, t]);
|
||||
}, [duplicateResource, t, failureReason]);
|
||||
|
||||
const handleMoveToFolder = useCallback(async (names: string[], targetFolder: string) => {
|
||||
try {
|
||||
@@ -465,9 +490,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to move:", err);
|
||||
toast.error(t("move_error"));
|
||||
toast.error(t("move_error"), failureReason(err));
|
||||
}
|
||||
}, [moveToFolder, t, handleUndo]);
|
||||
}, [moveToFolder, t, handleUndo, failureReason]);
|
||||
|
||||
const handleMoveToParent = useCallback(async (names: string[]) => {
|
||||
try {
|
||||
@@ -477,9 +502,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to move:", err);
|
||||
toast.error(t("move_error"));
|
||||
toast.error(t("move_error"), failureReason(err));
|
||||
}
|
||||
}, [moveToParent, t, handleUndo]);
|
||||
}, [moveToParent, t, handleUndo, failureReason]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
try {
|
||||
@@ -489,9 +514,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to paste:", err);
|
||||
toast.error(t("paste_error"));
|
||||
toast.error(t("paste_error"), failureReason(err));
|
||||
}
|
||||
}, [pasteResources, t, lastAction, handleUndo]);
|
||||
}, [pasteResources, t, lastAction, handleUndo, failureReason]);
|
||||
|
||||
const handlePreviewImage = useCallback((name: string) => {
|
||||
setPreviewImage(name);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fileErrorKind, folderDepthError } from '../file-errors';
|
||||
import { jmapError } from '../jmap/jmap-error';
|
||||
|
||||
// SetErrors as Stalwart words them (1.0: jmap/src/file/*.rs; 0.16.23:
|
||||
// jmap/src/file/set.rs), thrown by the JMAP client through jmapError.
|
||||
const setError = (type: string, description: string, properties?: string[]) =>
|
||||
jmapError(description, { type, description, properties });
|
||||
|
||||
describe('fileErrorKind', () => {
|
||||
it('explains Stalwart 1.0\'s limits on files and folders', () => {
|
||||
expect(fileErrorKind(setError('overQuota', 'There are too many files, please delete some before adding a new one.'))).toBe('overQuota');
|
||||
expect(fileErrorKind(setError('overQuota', 'There are too many folders, please delete some before adding a new one.'))).toBe('overQuota');
|
||||
expect(fileErrorKind(setError('invalidProperties', 'Maximum folder depth exceeded.', ['parentId']))).toBe('tooDeep');
|
||||
expect(fileErrorKind(setError('tooLarge', 'File is too large.'))).toBe('tooLarge');
|
||||
});
|
||||
|
||||
it('treats Bulwark\'s own depth check like the server\'s', () => {
|
||||
expect(fileErrorKind(folderDepthError())).toBe('tooDeep');
|
||||
});
|
||||
|
||||
it('explains refused names on 0.16 and 1.0', () => {
|
||||
expect(fileErrorKind(setError('invalidProperties', 'Name is reserved and cannot be used.', ['name']))).toBe('invalidName');
|
||||
expect(fileErrorKind(setError('invalidProperties', 'Name contains a forbidden character.', ['name']))).toBe('invalidName');
|
||||
expect(fileErrorKind(setError('invalidProperties', 'Name must be between 1 and 255 octets.', ['name']))).toBe('invalidName');
|
||||
});
|
||||
|
||||
it('explains missing rights on 0.16 and 1.0', () => {
|
||||
expect(fileErrorKind(setError('forbidden', 'You are not allowed to create file nodes in this folder.'))).toBe('forbidden');
|
||||
expect(fileErrorKind(setError('forbidden', 'You do not have access to blobId b2.'))).toBe('forbidden');
|
||||
expect(fileErrorKind(setError('forbidden', 'You are not allowed to modify this file node.'))).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('has nothing to add for other errors', () => {
|
||||
// The folder is gone, or a folder was moved into itself.
|
||||
expect(fileErrorKind(setError('invalidProperties', 'Parent ID does not exist or is not a folder.', ['parentId']))).toBeNull();
|
||||
expect(fileErrorKind(setError('invalidProperties', 'Circular reference in parent ids.', ['parentId']))).toBeNull();
|
||||
expect(fileErrorKind(setError('notFound', 'Not found'))).toBeNull();
|
||||
expect(fileErrorKind(new Error('Failed to upload file: 500'))).toBeNull();
|
||||
expect(fileErrorKind(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { acceptedFileName, fileNameProblem, fileNameRulesFrom } from '../file-name-rules';
|
||||
|
||||
const RESERVED = [
|
||||
'.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM0', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7',
|
||||
'COM8', 'COM9', 'LPT0', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||||
];
|
||||
|
||||
// The filenode capability Stalwart 0.16.23 publishes.
|
||||
const STALWART = fileNameRulesFrom({
|
||||
const STALWART_016 = fileNameRulesFrom({
|
||||
maxFileNodeDepth: null,
|
||||
maxSizeFileNodeName: 255,
|
||||
forbiddenNameChars: '/<>:"\\|?*',
|
||||
forbiddenNodeNames: ['.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1'],
|
||||
forbiddenNodeNames: RESERVED,
|
||||
});
|
||||
|
||||
// Stalwart 1.0 publishes the same rules (plus a depth limit) but also refuses
|
||||
// reserved stems and control characters, which the capability doesn't say.
|
||||
const STALWART_10 = fileNameRulesFrom({
|
||||
maxFileNodeDepth: 64,
|
||||
maxSizeFileNodeName: 255,
|
||||
forbiddenNameChars: '/<>:"\\|?*',
|
||||
forbiddenNodeNames: RESERVED,
|
||||
});
|
||||
|
||||
describe('file name rules', () => {
|
||||
@@ -15,16 +30,98 @@ describe('file name rules', () => {
|
||||
expect(acceptedFileName('a:b', null)).toBe('a:b');
|
||||
});
|
||||
|
||||
it('reports forbidden characters and reserved names', () => {
|
||||
expect(fileNameProblem('bad:name?.txt', STALWART)).toEqual({ kind: 'chars', chars: ': ?' });
|
||||
expect(fileNameProblem('con', STALWART)).toEqual({ kind: 'reserved' });
|
||||
expect(fileNameProblem('CON.txt', STALWART)).toBeNull();
|
||||
expect(fileNameProblem('report.pdf', STALWART)).toBeNull();
|
||||
it('reads the byte limit from the capability', () => {
|
||||
expect(STALWART_10?.maxNameBytes).toBe(255);
|
||||
expect(fileNameRulesFrom({ forbiddenNameChars: '/', maxSizeFileNodeName: 100 })?.maxNameBytes).toBe(100);
|
||||
expect(fileNameRulesFrom({ forbiddenNameChars: '/' })?.maxNameBytes).toBe(255);
|
||||
});
|
||||
|
||||
it('reports forbidden characters and reserved names on 0.16', () => {
|
||||
expect(fileNameProblem('bad:name?.txt', STALWART_016)).toEqual({ kind: 'chars', chars: ': ?' });
|
||||
expect(fileNameProblem('con', STALWART_016)).toEqual({ kind: 'reserved' });
|
||||
expect(fileNameProblem('report.pdf', STALWART_016)).toBeNull();
|
||||
});
|
||||
|
||||
// The cases of Stalwart 1.0's own `reserved_names` test (jmap/src/file/node.rs).
|
||||
it('refuses reserved names and reserved stems the way Stalwart 1.0 does', () => {
|
||||
for (const name of ['CON', 'con.txt', 'Aux.tar.gz', '..', '.', 'LPT9.log']) {
|
||||
expect(fileNameProblem(name, STALWART_10), name).toEqual({ kind: 'reserved' });
|
||||
}
|
||||
for (const name of ['console.txt', '.bashrc', 'auxiliary', 'com10', 'a.con', 'ok name (1).txt']) {
|
||||
expect(fileNameProblem(name, STALWART_10), name).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the stem rule on 0.16 too, as 1.0 would refuse the name later', () => {
|
||||
expect(fileNameProblem('CON.txt', STALWART_016)).toEqual({ kind: 'reserved' });
|
||||
});
|
||||
|
||||
it('refuses control characters (Rust char::is_control) as Stalwart 1.0 does', () => {
|
||||
expect(fileNameProblem('a\u0001b', STALWART_10)).toEqual({ kind: 'control' });
|
||||
// macOS stores custom folder icons as "Icon\r".
|
||||
expect(fileNameProblem('Icon\r', STALWART_10)).toEqual({ kind: 'control' });
|
||||
expect(fileNameProblem('a\u007fb', STALWART_10)).toEqual({ kind: 'control' });
|
||||
expect(fileNameProblem('next\u0085line', STALWART_10)).toEqual({ kind: 'control' });
|
||||
// Not a control character.
|
||||
expect(fileNameProblem('no break', STALWART_10)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses names over 255 bytes of UTF-8, as every Stalwart release does', () => {
|
||||
expect(fileNameProblem('a'.repeat(256), STALWART_10)).toEqual({ kind: 'tooLong' });
|
||||
expect(fileNameProblem('a'.repeat(255), STALWART_10)).toBeNull();
|
||||
// 128 x 2 bytes.
|
||||
expect(fileNameProblem('é'.repeat(128), STALWART_016)).toEqual({ kind: 'tooLong' });
|
||||
expect(fileNameProblem('é'.repeat(127), STALWART_016)).toBeNull();
|
||||
});
|
||||
|
||||
it('turns an uploaded name into one the server accepts', () => {
|
||||
expect(acceptedFileName('bad:name?.txt', STALWART)).toBe('bad_name_.txt');
|
||||
expect(acceptedFileName('NUL', STALWART)).toBe('NUL_');
|
||||
expect(acceptedFileName('fine.txt', STALWART)).toBe('fine.txt');
|
||||
expect(acceptedFileName('bad:name?.txt', STALWART_10)).toBe('bad_name_.txt');
|
||||
expect(acceptedFileName('NUL', STALWART_10)).toBe('NUL_');
|
||||
expect(acceptedFileName('fine.txt', STALWART_10)).toBe('fine.txt');
|
||||
});
|
||||
|
||||
it('puts the "_" after a reserved stem, where Stalwart 1.0 looks', () => {
|
||||
expect(acceptedFileName('con.txt', STALWART_10)).toBe('con_.txt');
|
||||
expect(acceptedFileName('Aux.tar.gz', STALWART_10)).toBe('Aux_.tar.gz');
|
||||
expect(acceptedFileName('LPT9.log', STALWART_10)).toBe('LPT9_.log');
|
||||
expect(acceptedFileName('..', STALWART_10)).toBe('.._');
|
||||
expect(acceptedFileName('.', STALWART_10)).toBe('._');
|
||||
for (const name of ['console.txt', '.bashrc', 'auxiliary', 'com10', 'a.con']) {
|
||||
expect(acceptedFileName(name, STALWART_10), name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces control characters', () => {
|
||||
expect(acceptedFileName('Icon\r', STALWART_10)).toBe('Icon_');
|
||||
expect(acceptedFileName('a\u0085b.txt', STALWART_10)).toBe('a_b.txt');
|
||||
});
|
||||
|
||||
it('shortens a name to 255 bytes and keeps its extension', () => {
|
||||
const long = acceptedFileName(`${'a'.repeat(300)}.txt`, STALWART_10);
|
||||
expect(new TextEncoder().encode(long).length).toBe(255);
|
||||
expect(long.endsWith('.txt')).toBe(true);
|
||||
|
||||
// Whole characters only: a 2-byte "é" is not split.
|
||||
const accented = acceptedFileName(`${'é'.repeat(200)}.pdf`, STALWART_10);
|
||||
expect(accented).toBe(`${'é'.repeat(125)}.pdf`);
|
||||
|
||||
// An "extension" too long to keep goes with the rest.
|
||||
expect(acceptedFileName(`a.${'b'.repeat(300)}`, STALWART_10)).toBe(`a.${'b'.repeat(253)}`);
|
||||
});
|
||||
|
||||
it('keeps a name at the byte limit acceptable when its stem is reserved', () => {
|
||||
// 3 + 1 + 251 = 255 bytes: no room to add "_" after "con".
|
||||
const accepted = acceptedFileName(`con.${'x'.repeat(251)}`, STALWART_10);
|
||||
expect(accepted).toBe(`co_.${'x'.repeat(251)}`);
|
||||
expect(fileNameProblem(accepted, STALWART_10)).toBeNull();
|
||||
});
|
||||
|
||||
it('only produces names the rules accept', () => {
|
||||
const awkward = [
|
||||
'con.txt', 'Icon\r', 'a:b|c', '..', `${'z'.repeat(400)}.docx`, `CON.${'y'.repeat(260)}`, 'tab\there', 'prn',
|
||||
];
|
||||
for (const name of awkward) {
|
||||
expect(fileNameProblem(acceptedFileName(name, STALWART_10), STALWART_10), JSON.stringify(name)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error';
|
||||
import { fileErrorKind } from '../file-errors';
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
|
||||
interface FakeOptions {
|
||||
/** The SetError the server answers a create with, if it refuses it. */
|
||||
refuseCreate?: (props: Record<string, unknown>) => Record<string, unknown> | undefined;
|
||||
/** The SetError the server answers an update or destroy of `id` with. */
|
||||
refuse?: (id: string) => Record<string, unknown> | undefined;
|
||||
/** The filenode account capability of "me" (and of "grp", unless `sharedCapability` says otherwise). */
|
||||
capability?: Record<string, unknown>;
|
||||
/** Null: "grp" lists no filenode capability, as a shared account may. */
|
||||
sharedCapability?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// The rules Stalwart 1.0 publishes (0.16.23: the same with maxFileNodeDepth null).
|
||||
const STALWART_10_CAPABILITY = {
|
||||
maxFileNodeDepth: 64,
|
||||
maxSizeFileNodeName: 255,
|
||||
forbiddenNameChars: '/<>:"\\|?*',
|
||||
forbiddenNodeNames: ['.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1'],
|
||||
};
|
||||
|
||||
// A primary account "me" plus a shared drive "grp", as listed by
|
||||
// listAllFileNodesAcrossAccounts ("grp:<id>").
|
||||
function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, options: FakeOptions = {}) {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||
const fileCap = { 'urn:ietf:params:jmap:filenode': {} };
|
||||
const fileCap = { 'urn:ietf:params:jmap:filenode': options.capability ?? {} };
|
||||
const sharedCap = options.sharedCapability === null
|
||||
? {}
|
||||
: { 'urn:ietf:params:jmap:filenode': options.sharedCapability ?? options.capability ?? {} };
|
||||
Object.assign(client, {
|
||||
accountId: 'me',
|
||||
capabilities: { 'urn:ietf:params:jmap:filenode': {} },
|
||||
accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: fileCap, isPersonal: false } },
|
||||
accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: sharedCap, isPersonal: false } },
|
||||
session: {
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:filenode': 'me' },
|
||||
accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: fileCap, isPersonal: false } },
|
||||
accounts: { me: { accountCapabilities: fileCap, isPersonal: true }, grp: { accountCapabilities: sharedCap, isPersonal: false } },
|
||||
},
|
||||
});
|
||||
vi.spyOn(client as unknown as { getFilesCapableAccountIds: () => string[] }, 'getFilesCapableAccountIds')
|
||||
@@ -43,10 +62,20 @@ function makeClient(nodes: Record<string, Array<Record<string, unknown>>>, optio
|
||||
return { methodResponses: [['FileNode/set', { created: { n0: { id: `new${++seq}` } } }, id]] };
|
||||
}
|
||||
if (method === 'FileNode/set' && args.update) {
|
||||
return { methodResponses: [['FileNode/set', { updated: Object.fromEntries(Object.keys(args.update as object).map(k => [k, null])) }, id]] };
|
||||
const ids = Object.keys(args.update as object);
|
||||
const refused = ids.filter(k => options.refuse?.(k));
|
||||
return { methodResponses: [['FileNode/set', {
|
||||
updated: Object.fromEntries(ids.filter(k => !refused.includes(k)).map(k => [k, null])),
|
||||
notUpdated: Object.fromEntries(refused.map(k => [k, options.refuse?.(k)])),
|
||||
}, id]] };
|
||||
}
|
||||
if (method === 'FileNode/set' && args.destroy) {
|
||||
return { methodResponses: [['FileNode/set', { destroyed: args.destroy }, id]] };
|
||||
const ids = args.destroy as string[];
|
||||
const refused = ids.filter(k => options.refuse?.(k));
|
||||
return { methodResponses: [['FileNode/set', {
|
||||
destroyed: ids.filter(k => !refused.includes(k)),
|
||||
notDestroyed: Object.fromEntries(refused.map(k => [k, options.refuse?.(k)])),
|
||||
}, id]] };
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
});
|
||||
@@ -236,3 +265,88 @@ describe('FileNode writes', () => {
|
||||
expect(sets(calls).some(a => a.destroy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileNode write errors, names and types', () => {
|
||||
const caught = (promise: Promise<unknown>) => promise.then(() => null, (error: unknown) => error);
|
||||
|
||||
it('keeps the SetError\'s type and properties on what it throws on Stalwart 1.0', async () => {
|
||||
const { client } = makeClient({}, {
|
||||
refuseCreate: () => ({ type: 'invalidProperties', description: 'Maximum folder depth exceeded.', properties: ['parentId'] }),
|
||||
refuse: (id) => id === 'n1'
|
||||
? { type: 'forbidden', description: 'You are not allowed to rename or move this file node.' }
|
||||
: { type: 'overQuota', description: 'There are too many folders, please delete some before adding a new one.' },
|
||||
});
|
||||
|
||||
const tooDeep = await caught(client.createFileDirectory('Deeper', 'd63'));
|
||||
expect(jmapErrorType(tooDeep)).toBe('invalidProperties');
|
||||
expect(jmapErrorProperties(tooDeep)).toEqual(['parentId']);
|
||||
expect(fileErrorKind(tooDeep)).toBe('tooDeep');
|
||||
|
||||
const renamed = await caught(client.updateFileNode('n1', { name: 'x' }));
|
||||
expect(renamed).toBeInstanceOf(Error);
|
||||
expect(fileErrorKind(renamed)).toBe('forbidden');
|
||||
|
||||
expect(fileErrorKind(await caught(client.destroyFileNodes(['n2'])))).toBe('overQuota');
|
||||
});
|
||||
|
||||
it('keeps the type of a name 0.16 refuses', async () => {
|
||||
const { client } = makeClient({}, {
|
||||
refuseCreate: () => ({ type: 'invalidProperties', description: 'Name is reserved and cannot be used.', properties: ['name'] }),
|
||||
});
|
||||
expect(fileErrorKind(await caught(client.createFileNode('x', 'b1', 'text/plain', 1, null)))).toBe('invalidName');
|
||||
});
|
||||
|
||||
it('sends the bare media type Stalwart 1.0 insists on, when creating and copying', async () => {
|
||||
const { client, calls } = makeClient({
|
||||
me: [{ id: 'f1', name: 'data.csv', parentId: null, blobId: 'b1', type: 'TEXT/CSV', size: 3 }],
|
||||
}, { capability: STALWART_10_CAPABILITY });
|
||||
|
||||
await client.createFileNode('a.txt', 'b0', 'Text/Plain; charset=utf-8', 3, null);
|
||||
// A type no browser sends, as old builds stored for folder markers.
|
||||
await client.createFileNode('marker', 'b9', 'd', 0, null);
|
||||
await client.copyFileNode('f1', 'data (copy).csv', null);
|
||||
|
||||
expect(sets(calls).map(a => (a.create as { n0: { type: string } }).n0.type))
|
||||
.toEqual(['text/plain', 'application/octet-stream', 'text/csv']);
|
||||
});
|
||||
|
||||
it('names a node in a shared drive by the user\'s own rules when the drive lists none', async () => {
|
||||
const { client, calls } = makeClient({}, { capability: STALWART_10_CAPABILITY, sharedCapability: null });
|
||||
await client.createFileNode('con.txt', 'b1', 'text/plain', 3, 'grp:d9');
|
||||
expect(sets(calls)[0]).toMatchObject({ accountId: 'grp', create: { n0: { name: 'con_.txt', parentId: 'd9' } } });
|
||||
});
|
||||
|
||||
// Three levels: top > mid > target.
|
||||
const tree = [
|
||||
{ id: 'top', name: 'top', parentId: null, blobId: null },
|
||||
{ id: 'mid', name: 'mid', parentId: 'top', blobId: null },
|
||||
{ id: 'target', name: 'target', parentId: 'mid', blobId: null },
|
||||
{ id: 'src', name: 'src', parentId: null, blobId: null },
|
||||
{ id: 'inner', name: 'inner', parentId: 'src', blobId: null },
|
||||
{ id: 'f', name: 'f.txt', parentId: 'inner', blobId: 'b1', type: 'text/plain', size: 1 },
|
||||
];
|
||||
|
||||
it('stops a copy that would go past maxFileNodeDepth before creating anything (Stalwart 1.0)', async () => {
|
||||
// target sits at level 3; src/inner/f.txt adds three more.
|
||||
const { client, calls } = makeClient({ me: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: 5 } });
|
||||
const refused = await caught(client.copyFileNode('src', 'src', 'target'));
|
||||
expect(fileErrorKind(refused)).toBe('tooDeep');
|
||||
expect(sets(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
it('copies when it fits, and checks the other account\'s tree for a shared drive', async () => {
|
||||
const fits = makeClient({ me: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: 6 } });
|
||||
await fits.client.copyFileNode('src', 'src', 'target');
|
||||
expect(sets(fits.calls)).toHaveLength(3);
|
||||
|
||||
const shared = makeClient({ me: tree, grp: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: 5 } });
|
||||
expect(fileErrorKind(await caught(shared.client.copyFileNode('src', 'src', 'grp:target')))).toBe('tooDeep');
|
||||
expect(sets(shared.calls)).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no depth to check on 0.16, which advertises maxFileNodeDepth null', async () => {
|
||||
const { client, calls } = makeClient({ me: tree }, { capability: { ...STALWART_10_CAPABILITY, maxFileNodeDepth: null } });
|
||||
await client.copyFileNode('src', 'src', 'target');
|
||||
expect(sets(calls)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mediaTypeEssence } from '../media-type';
|
||||
|
||||
// Mirrors Stalwart 1.0's `essence_normalisation` test (types/src/media_type/mod.rs):
|
||||
// 1.0 refuses a FileNode `type` that isn't a bare, valid media type.
|
||||
describe('mediaTypeEssence', () => {
|
||||
it('drops parameters and lowercases', () => {
|
||||
expect(mediaTypeEssence('Text/Plain; charset=utf-8')).toBe('text/plain');
|
||||
expect(mediaTypeEssence('image/png')).toBe('image/png');
|
||||
expect(mediaTypeEssence(' text/html ')).toBe('text/html');
|
||||
expect(mediaTypeEssence('application/vnd.api+json')).toBe('application/vnd.api+json');
|
||||
expect(mediaTypeEssence('application/vnd.openxmlformats-officedocument.wordprocessingml.document'))
|
||||
.toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
});
|
||||
|
||||
it('falls back to application/octet-stream for anything else', () => {
|
||||
for (const invalid of ['', 'text', 'text/', '/plain', 'te xt/plain', '-a/b', 'a/b/c', `a/${'b'.repeat(128)}`, 'd']) {
|
||||
expect(mediaTypeEssence(invalid), JSON.stringify(invalid)).toBe('application/octet-stream');
|
||||
}
|
||||
expect(mediaTypeEssence(null)).toBe('application/octet-stream');
|
||||
expect(mediaTypeEssence(undefined)).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('keeps names of the longest length the server takes', () => {
|
||||
const longest = `a/${'b'.repeat(127)}`;
|
||||
expect(mediaTypeEssence(longest)).toBe(longest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { jmapError, jmapErrorProperties, jmapErrorType } from '@/lib/jmap/jmap-error';
|
||||
|
||||
/**
|
||||
* Why a FileNode write failed, from the SetError the JMAP client threw
|
||||
* (see jmapError), for the Files toasts to explain under their own title.
|
||||
*/
|
||||
export type FileErrorKind = 'overQuota' | 'tooDeep' | 'invalidName' | 'forbidden' | 'tooLarge';
|
||||
|
||||
// Stalwart 1.0's description for a node past maxFileNodeDepth.
|
||||
const TOO_DEEP = 'Maximum folder depth exceeded.';
|
||||
|
||||
/** The error for a node that would sit deeper than the server allows, worded as Stalwart 1.0 does. */
|
||||
export function folderDepthError(): Error {
|
||||
return jmapError(TOO_DEEP, { type: 'invalidProperties', properties: ['parentId'] });
|
||||
}
|
||||
|
||||
export function fileErrorKind(error: unknown): FileErrorKind | null {
|
||||
switch (jmapErrorType(error)) {
|
||||
// Stalwart 1.0, once an administrator limits files or folders per account.
|
||||
case 'overQuota':
|
||||
return 'overQuota';
|
||||
// 1.0 for content over 4 GiB.
|
||||
case 'tooLarge':
|
||||
return 'tooLarge';
|
||||
case 'forbidden':
|
||||
return 'forbidden';
|
||||
case 'invalidProperties': {
|
||||
const properties = jmapErrorProperties(error);
|
||||
if (properties.includes('name')) return 'invalidName';
|
||||
// `parentId` is refused for other reasons too (the folder is gone, a
|
||||
// folder moved into itself); only the description tells depth apart.
|
||||
if (properties.includes('parentId') && error instanceof Error && /depth/i.test(error.message)) {
|
||||
return 'tooDeep';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+101
-14
@@ -1,45 +1,132 @@
|
||||
/**
|
||||
* FileNode naming rules a server publishes in its filenode capability
|
||||
* (`forbiddenNameChars`, `forbiddenNodeNames`; Stalwart 0.16.6+). A name that
|
||||
* breaks them is refused ("Name contains a forbidden character." / "Name is
|
||||
* reserved and cannot be used."), which used to surface only after an upload
|
||||
* had started and then aborted the rest of the batch.
|
||||
* (`forbiddenNameChars`, `forbiddenNodeNames`, `maxSizeFileNodeName`;
|
||||
* Stalwart 0.16.6+). A name that breaks them is refused ("Name contains a
|
||||
* forbidden character." / "Name is reserved and cannot be used."), which used
|
||||
* to surface only after an upload had started.
|
||||
*
|
||||
* Stalwart 1.0 checks more than its capability says (`validate_name`,
|
||||
* jmap/src/file/node.rs), on every name set over JMAP:
|
||||
* - no control characters (Rust's `char::is_control`: U+0000-001F, U+007F-009F);
|
||||
* - a reserved name may not be the stem either, the text before the first
|
||||
* "." (unless that is empty): "con.txt" and "Aux.tar.gz" are refused too.
|
||||
* 1.0's rules take in 0.16's, so they are applied wherever the server
|
||||
* publishes rules; on 0.16.6-0.16.23 "con.txt" is renamed although it would
|
||||
* have been taken.
|
||||
*/
|
||||
export interface FileNameRules {
|
||||
forbiddenChars: string;
|
||||
forbiddenNames: string[];
|
||||
/** Longest name, in bytes of UTF-8. */
|
||||
maxNameBytes: number;
|
||||
}
|
||||
|
||||
export type FileNameProblem = { kind: 'chars'; chars: string } | { kind: 'reserved' };
|
||||
export type FileNameProblem =
|
||||
| { kind: 'chars'; chars: string }
|
||||
| { kind: 'control' }
|
||||
| { kind: 'tooLong' }
|
||||
| { kind: 'reserved' };
|
||||
|
||||
// Every Stalwart release refuses names over 255 bytes.
|
||||
const DEFAULT_MAX_NAME_BYTES = 255;
|
||||
|
||||
export function fileNameRulesFrom(capability: unknown): FileNameRules | null {
|
||||
if (!capability || typeof capability !== 'object') return null;
|
||||
const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: unknown };
|
||||
const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: unknown; maxSizeFileNodeName?: unknown };
|
||||
const forbiddenChars = typeof cap.forbiddenNameChars === 'string' ? cap.forbiddenNameChars : '';
|
||||
const forbiddenNames = Array.isArray(cap.forbiddenNodeNames)
|
||||
? cap.forbiddenNodeNames.filter((n): n is string => typeof n === 'string')
|
||||
: [];
|
||||
if (!forbiddenChars && forbiddenNames.length === 0) return null;
|
||||
return { forbiddenChars, forbiddenNames };
|
||||
const maxNameBytes = typeof cap.maxSizeFileNodeName === 'number' && cap.maxSizeFileNodeName > 0
|
||||
? cap.maxSizeFileNodeName
|
||||
: DEFAULT_MAX_NAME_BYTES;
|
||||
return { forbiddenChars, forbiddenNames, maxNameBytes };
|
||||
}
|
||||
|
||||
function isControlChar(ch: string): boolean {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
||||
}
|
||||
|
||||
function utf8Length(text: string): number {
|
||||
return new TextEncoder().encode(text).length;
|
||||
}
|
||||
|
||||
// Stalwart compares reserved names ignoring ASCII case only.
|
||||
function asciiLower(text: string): string {
|
||||
return text.replace(/[A-Z]/g, c => c.toLowerCase());
|
||||
}
|
||||
|
||||
function nameStem(name: string): string {
|
||||
const dot = name.indexOf('.');
|
||||
return dot > 0 ? name.slice(0, dot) : name;
|
||||
}
|
||||
|
||||
function isReservedName(name: string, reserved: string[]): boolean {
|
||||
const whole = asciiLower(name);
|
||||
const stem = asciiLower(nameStem(name));
|
||||
return reserved.some(n => asciiLower(n) === whole || asciiLower(n) === stem);
|
||||
}
|
||||
|
||||
export function fileNameProblem(name: string, rules: FileNameRules | null): FileNameProblem | null {
|
||||
if (!rules) return null;
|
||||
if (utf8Length(name) > rules.maxNameBytes) return { kind: 'tooLong' };
|
||||
const bad = [...new Set([...name].filter(c => rules.forbiddenChars.includes(c)))];
|
||||
if (bad.length) return { kind: 'chars', chars: bad.join(' ') };
|
||||
// Matched against the whole name, ignoring case: "CON" is reserved, "CON.txt" is not.
|
||||
if (rules.forbiddenNames.some(n => n.toLowerCase() === name.toLowerCase())) return { kind: 'reserved' };
|
||||
if ([...name].some(isControlChar)) return { kind: 'control' };
|
||||
if (isReservedName(name, rules.forbiddenNames)) return { kind: 'reserved' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The longest start of `text` that fits in `maxBytes` of UTF-8, whole characters only. */
|
||||
function cutToBytes(text: string, maxBytes: number): string {
|
||||
let out = '';
|
||||
let used = 0;
|
||||
for (const ch of text) {
|
||||
const bytes = utf8Length(ch);
|
||||
if (used + bytes > maxBytes) break;
|
||||
out += ch;
|
||||
used += bytes;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `name` cut to `maxBytes`, keeping the extension when it leaves room for a stem. */
|
||||
function fitToBytes(name: string, maxBytes: number): string {
|
||||
if (utf8Length(name) <= maxBytes) return name;
|
||||
const dot = name.lastIndexOf('.');
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
const extBytes = utf8Length(ext);
|
||||
if (!ext || extBytes >= maxBytes) return cutToBytes(name, maxBytes);
|
||||
return cutToBytes(name.slice(0, dot), maxBytes - extBytes) + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variant of `name` the server accepts: forbidden characters become "_",
|
||||
* a reserved name gets "_" appended. Used for uploads, where the name comes
|
||||
* from the local file system and cannot be asked for again.
|
||||
* A reserved name made acceptable: "_" after the stem ("con.txt" becomes
|
||||
* "con_.txt", "CON" becomes "CON_", ".." becomes ".._"). A name already at
|
||||
* the byte limit has the stem's last character replaced instead.
|
||||
*/
|
||||
function unreserved(name: string, maxBytes: number): string {
|
||||
const stemEnd = nameStem(name).length;
|
||||
const marked = `${name.slice(0, stemEnd)}_${name.slice(stemEnd)}`;
|
||||
if (utf8Length(marked) <= maxBytes) return marked;
|
||||
const stem = [...name.slice(0, stemEnd)];
|
||||
stem[stem.length - 1] = '_';
|
||||
return stem.join('') + name.slice(stemEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* A variant of `name` the server accepts: forbidden and control characters
|
||||
* become "_", a reserved name or stem gets "_" after the stem, and a name over
|
||||
* the byte limit is shortened, keeping its extension. Used for uploads,
|
||||
* where the name comes from the local file system and cannot be asked for
|
||||
* again.
|
||||
*/
|
||||
export function acceptedFileName(name: string, rules: FileNameRules | null): string {
|
||||
if (!rules) return name;
|
||||
let out = [...name].map(c => (rules.forbiddenChars.includes(c) ? '_' : c)).join('');
|
||||
if (rules.forbiddenNames.some(n => n.toLowerCase() === out.toLowerCase())) out = `${out}_`;
|
||||
let out = [...name].map(c => (isControlChar(c) || rules.forbiddenChars.includes(c) ? '_' : c)).join('');
|
||||
out = fitToBytes(out, rules.maxNameBytes);
|
||||
if (isReservedName(out, rules.forbiddenNames)) out = unreserved(out, rules.maxNameBytes);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface IJMAPClient {
|
||||
getAccountInfo(accountId: string): AccountInfo | null;
|
||||
/** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */
|
||||
getFileNameRules?(accountId?: string): FileNameRules | null;
|
||||
/** How deep a FileNode may sit (Stalwart 1.0: 64), or null for no limit. */
|
||||
getMaxFileNodeDepth?(accountId?: string): number | null;
|
||||
getMaxSizeUpload(): number;
|
||||
/** RFC 8621 maxSizeAttachmentsPerEmail of the mail account (0 = unknown). */
|
||||
getMaxSizeAttachmentsPerEmail?(): number;
|
||||
|
||||
+58
-15
@@ -15,12 +15,15 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio
|
||||
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||||
import { decodeFileNodeName } from "./filenode-name";
|
||||
import { fileNodeKind } from "./filenode-kind";
|
||||
import { fileNodeDepth } from "./filenode-depth";
|
||||
import { contactFromWire, contactToWire } from "./contact-wire";
|
||||
import { SchedulingDeniedError } from "./scheduling-error";
|
||||
import { jmapError, jmapErrorType, jmapSetError, type JmapErrorInfo } from "./jmap-error";
|
||||
|
||||
export { SchedulingDeniedError };
|
||||
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
|
||||
import { folderDepthError } from "@/lib/file-errors";
|
||||
import { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type";
|
||||
import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone";
|
||||
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
|
||||
import type { AccountInfo } from "./types";
|
||||
@@ -8140,6 +8143,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
return this.getAccountCapability("urn:ietf:params:jmap:filenode", accountId) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The filenode capability whose limits apply in `accountId`. Stalwart
|
||||
* publishes the same ones for every account, and a shared account may
|
||||
* come without any (see getFilesCapableAccountIds), so the user's own
|
||||
* files account stands in.
|
||||
*/
|
||||
private fileNodeLimits(accountId?: string): Record<string, unknown> | undefined {
|
||||
const own = this.getFilesAccountId();
|
||||
return this.fileNodeCapability(accountId || own) ?? this.fileNodeCapability(own);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart before 0.16.6 implements an older File Storage draft (old
|
||||
* rights names, 30-character MIME types, no naming rules). The draft update
|
||||
@@ -8152,7 +8166,24 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
/** Naming rules for new FileNodes in `accountId`, if the server publishes any. */
|
||||
getFileNameRules(accountId?: string): FileNameRules | null {
|
||||
return fileNameRulesFrom(this.fileNodeCapability(accountId || this.getFilesAccountId()));
|
||||
return fileNameRulesFrom(this.fileNodeLimits(accountId));
|
||||
}
|
||||
|
||||
/**
|
||||
* How deep a node may sit (`maxFileNodeDepth`: 64 on Stalwart 1.0), or
|
||||
* null for no limit (0.16 advertises null).
|
||||
*/
|
||||
getMaxFileNodeDepth(accountId?: string): number | null {
|
||||
const depth = this.fileNodeLimits(accountId)?.maxFileNodeDepth;
|
||||
return typeof depth === 'number' && depth > 0 ? depth : null;
|
||||
}
|
||||
|
||||
/** The `type` to create a file node in `accountId` with. */
|
||||
private fileNodeType(accountId: string, type: string | null | undefined): string {
|
||||
// Stalwart 1.0 only takes a bare, valid media type; servers before
|
||||
// 0.16.6 refuse more than 30 characters (most OOXML types).
|
||||
const essence = mediaTypeEssence(type);
|
||||
return this.isLegacyFileNodeServer(accountId) && essence.length > 30 ? OCTET_STREAM : essence;
|
||||
}
|
||||
|
||||
/** Namespace a node created in `accountId` the way the listings do. */
|
||||
@@ -8198,7 +8229,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
);
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set create failed");
|
||||
throw jmapError(result?.[1]?.description || "FileNode/set create failed", result?.[1]);
|
||||
}
|
||||
const created = result[1].created?.n0;
|
||||
if (created) {
|
||||
@@ -8209,7 +8240,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const err = result[1].notCreated?.n0;
|
||||
// Servers before 0.16.6 ignore onExists and refuse the clash instead.
|
||||
if (attempt < 20 && /already exists/i.test(err?.description ?? '')) continue;
|
||||
throw new Error(err?.description || `Failed to create ${what}`);
|
||||
throw jmapError(err?.description || `Failed to create ${what}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8237,11 +8268,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
async createFileNode(name: string, blobId: string, type: string, _size: number, parentId: string | null): Promise<FileNode> {
|
||||
const parent = this.resolveFileNodeId(parentId);
|
||||
|
||||
// Servers before 0.16.6 refuse MIME types over 30 characters (most OOXML
|
||||
// types); later ones take up to 255.
|
||||
const maxTypeLength = this.isLegacyFileNodeServer(parent.accountId) ? 30 : 255;
|
||||
const safeType = type.length > maxTypeLength ? 'application/octet-stream' : type;
|
||||
const fileProps: Record<string, unknown> = { name, type: safeType, blobId };
|
||||
const fileProps: Record<string, unknown> = { name, type: this.fileNodeType(parent.accountId, type), blobId };
|
||||
if (parent.id !== null) {
|
||||
fileProps.parentId = parent.id;
|
||||
}
|
||||
@@ -8267,10 +8294,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||||
throw jmapError(result?.[1]?.description || "FileNode/set update failed", result?.[1]);
|
||||
}
|
||||
if (result[1].notUpdated?.[node.id as string]) {
|
||||
throw new Error(result[1].notUpdated[node.id as string].description || "Failed to update file node");
|
||||
const refused = result[1].notUpdated?.[node.id as string];
|
||||
if (refused) {
|
||||
throw jmapError(refused.description || "Failed to update file node", refused);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8359,7 +8387,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
||||
throw jmapError(result?.[1]?.description || "FileNode/set destroy failed", result?.[1]);
|
||||
}
|
||||
|
||||
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
|
||||
@@ -8367,7 +8395,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
if (notDestroyedIds.length > 0) {
|
||||
const firstError = notDestroyedMap[notDestroyedIds[0]];
|
||||
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
||||
throw jmapError(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`, firstError);
|
||||
}
|
||||
|
||||
destroyed.push(...((result[1].destroyed || []) as string[]).map(rawId => byRawId.get(rawId) ?? rawId));
|
||||
@@ -8401,7 +8429,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (fileNodeKind(original) === 'symlink') throw new Error('Symbolic links cannot be copied');
|
||||
const isFolderNode = (n: FileNode) => fileNodeKind(n) === 'directory';
|
||||
|
||||
// The whole subtree is laid out from one listing before the first create.
|
||||
// The whole subtree is laid out from one listing and checked before the
|
||||
// first create.
|
||||
const childrenOf = new Map<string, FileNode[]>();
|
||||
for (const node of tree) {
|
||||
if (node.parentId == null) continue;
|
||||
@@ -8423,11 +8452,25 @@ export class JMAPClient implements IJMAPClient {
|
||||
};
|
||||
const plan = collect(original);
|
||||
|
||||
// Stalwart 1.0 refuses nodes below maxFileNodeDepth; a copy that would
|
||||
// reach past it fails here rather than part-way.
|
||||
const maxDepth = this.getMaxFileNodeDepth(target.accountId);
|
||||
if (maxDepth !== null) {
|
||||
const height = (entry: CopyEntry): number =>
|
||||
1 + entry.children.reduce((tallest, child) => Math.max(tallest, height(child)), 0);
|
||||
const targetTree = target.accountId === source.accountId
|
||||
? tree
|
||||
: await this.fetchAllFileNodes(target.accountId).catch(() => null);
|
||||
if (targetTree && fileNodeDepth(targetTree, target.id) + height(plan) > maxDepth) {
|
||||
throw folderDepthError();
|
||||
}
|
||||
}
|
||||
|
||||
const createCopy = (node: FileNode, name: string, parent: string | null): Promise<FileNode> => {
|
||||
const props: Record<string, unknown> = { name };
|
||||
if (parent !== null) props.parentId = parent;
|
||||
if (!isFolderNode(node)) {
|
||||
props.type = node.type;
|
||||
props.type = this.fileNodeType(target.accountId, node.type);
|
||||
props.blobId = node.blobId;
|
||||
}
|
||||
return this.createFileNodeIn(target.accountId, props, 'copy');
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FileNode } from './types';
|
||||
|
||||
/**
|
||||
* How deep a node sits in `nodes`: 0 for the root (`null`), 1 for a node at
|
||||
* the top level. Stalwart 1.0 refuses a node below its maxFileNodeDepth,
|
||||
* counted this way. A parent missing from the listing ends the walk, so the
|
||||
* result can only come out low.
|
||||
*/
|
||||
export function fileNodeDepth(nodes: Pick<FileNode, 'id' | 'parentId'>[], id: string | null): number {
|
||||
const parents = new Map(nodes.map(n => [n.id, n.parentId ?? null]));
|
||||
const seen = new Set<string>();
|
||||
let depth = 0;
|
||||
for (let current = id; current !== null && !seen.has(current); current = parents.get(current) ?? null) {
|
||||
seen.add(current);
|
||||
depth++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** What Stalwart stores for content without a usable media type. */
|
||||
export const OCTET_STREAM = 'application/octet-stream';
|
||||
|
||||
// RFC 6838 restricted-name: a letter or digit, then at most 126 more from a
|
||||
// small set.
|
||||
const RESTRICTED_NAME = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/;
|
||||
|
||||
/**
|
||||
* The essence of a media type: `top/sub` in lowercase, without parameters
|
||||
* ("Text/Plain; charset=utf-8" becomes "text/plain"), or
|
||||
* application/octet-stream when there is none. Stalwart 1.0 refuses any
|
||||
* other FileNode `type` (`is_valid_media_type`, types/src/media_type/mod.rs);
|
||||
* 0.16.6+ take anything with a "/" up to 256 characters.
|
||||
*/
|
||||
export function mediaTypeEssence(value: string | null | undefined): string {
|
||||
const essence = (value ?? '').split(';', 1)[0].replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, '');
|
||||
const slash = essence.indexOf('/');
|
||||
if (slash < 0) return OCTET_STREAM;
|
||||
const top = essence.slice(0, slash);
|
||||
const sub = essence.slice(slash + 1);
|
||||
return RESTRICTED_NAME.test(top) && RESTRICTED_NAME.test(sub) ? essence.toLowerCase() : OCTET_STREAM;
|
||||
}
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "الاسم الجديد",
|
||||
"name_forbidden_chars": "لا يسمح الخادم بهذه الأحرف في الأسماء: {chars}",
|
||||
"name_reserved": "هذا الاسم محجوز على الخادم.",
|
||||
"name_control_chars": "لا يمكن أن تحتوي الأسماء على أحرف تحكم.",
|
||||
"name_too_long": "هذا الاسم طويل جدًا بالنسبة للخادم.",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف المورد",
|
||||
"delete_confirm_message": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ لا يمكن التراجع عن هذا.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "جارٍ الرفع...",
|
||||
"upload_success": "{count, plural, one {تم رفع ملف واحد} other {تم رفع # ملف}}",
|
||||
"upload_error": "فشل رفع الملف",
|
||||
"upload_failed": "{count, plural, one {تعذّر رفع ملف واحد} two {تعذّر رفع ملفين} few {تعذّر رفع # ملفات} other {تعذّر رفع # ملف}}",
|
||||
"error_over_quota": "لقد بلغت الحد الأقصى لعدد الملفات أو المجلدات. احذف بعضها ثم حاول مرة أخرى.",
|
||||
"error_too_deep": "لا يمكن تداخل المجلدات بهذا العمق.",
|
||||
"error_invalid_name": "لا يقبل الخادم هذا الاسم.",
|
||||
"error_forbidden": "ليست لديك صلاحية للقيام بذلك هنا.",
|
||||
"error_too_large": "هذا الملف كبير جدًا بالنسبة للخادم.",
|
||||
"create_folder_success": "تم إنشاء المجلد",
|
||||
"create_folder_error": "فشل إنشاء المجلد",
|
||||
"delete_success": "تم الحذف بنجاح",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Nom nou",
|
||||
"name_forbidden_chars": "El servidor no permet aquests caràcters als noms: {chars}",
|
||||
"name_reserved": "Aquest nom està reservat al servidor.",
|
||||
"name_control_chars": "Els noms no poden contenir caràcters de control.",
|
||||
"name_too_long": "Aquest nom és massa llarg per al servidor.",
|
||||
"delete": "Suprimeix",
|
||||
"delete_confirm_title": "Suprimeix el recurs",
|
||||
"delete_confirm_message": "Segur que voleu suprimir «{name}»? Això no es pot desfer.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "Pujant...",
|
||||
"upload_success": "{count, plural, one {1 fitxer pujat} other {# fitxers pujats}}",
|
||||
"upload_error": "No s'ha pogut pujar el fitxer",
|
||||
"upload_failed": "{count, plural, one {No s'ha pogut pujar 1 fitxer} other {No s'han pogut pujar # fitxers}}",
|
||||
"error_over_quota": "Heu arribat al límit de fitxers o carpetes. Suprimiu-ne alguns i torneu-ho a provar.",
|
||||
"error_too_deep": "Les carpetes no es poden imbricar tan profundament.",
|
||||
"error_invalid_name": "El servidor no accepta aquest nom.",
|
||||
"error_forbidden": "No teniu permís per fer-ho aquí.",
|
||||
"error_too_large": "Aquest fitxer és massa gran per al servidor.",
|
||||
"create_folder_success": "Carpeta creada",
|
||||
"create_folder_error": "No s'ha pogut crear la carpeta",
|
||||
"delete_success": "Suprimit correctament",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Nový název",
|
||||
"name_forbidden_chars": "Server nepovoluje v názvech tyto znaky: {chars}",
|
||||
"name_reserved": "Tento název je na serveru vyhrazen.",
|
||||
"name_control_chars": "Názvy nesmí obsahovat řídicí znaky.",
|
||||
"name_too_long": "Tento název je pro server příliš dlouhý.",
|
||||
"delete": "Odstranit",
|
||||
"delete_confirm_title": "Odstranit položku",
|
||||
"delete_confirm_message": "Opravdu chcete odstranit \"{name}\"? Tuto akci nelze vrátit zpět.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Nahrávání...",
|
||||
"upload_success": "{count, plural, one {Nahrán 1 soubor} few {Nahrány # soubory} other {Nahráno # souborů}}",
|
||||
"upload_error": "Nahrání souboru selhalo",
|
||||
"upload_failed": "{count, plural, one {1 soubor se nepodařilo nahrát} few {# soubory se nepodařilo nahrát} other {# souborů se nepodařilo nahrát}}",
|
||||
"error_over_quota": "Dosáhli jste limitu počtu souborů nebo složek. Některé smažte a zkuste to znovu.",
|
||||
"error_too_deep": "Složky nelze vnořit tak hluboko.",
|
||||
"error_invalid_name": "Server tento název nepřijímá.",
|
||||
"error_forbidden": "K této akci zde nemáte oprávnění.",
|
||||
"error_too_large": "Tento soubor je pro server příliš velký.",
|
||||
"create_folder_success": "Složka byla vytvořena",
|
||||
"create_folder_error": "Vytvoření složky selhalo",
|
||||
"delete_success": "Úspěšně odstraněno",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Nyt navn",
|
||||
"name_forbidden_chars": "Serveren tillader ikke disse tegn i navne: {chars}",
|
||||
"name_reserved": "Dette navn er reserveret på serveren.",
|
||||
"name_control_chars": "Navne må ikke indeholde kontroltegn.",
|
||||
"name_too_long": "Dette navn er for langt til serveren.",
|
||||
"delete": "Slet",
|
||||
"delete_confirm_title": "Slet ressource",
|
||||
"delete_confirm_message": "Er du sikker på, at du vil slette \"{name}\"? Dette kan ikke fortrydes.",
|
||||
@@ -3371,6 +3373,12 @@
|
||||
"uploading": "Uploader...",
|
||||
"upload_success": "{count, plural, one {1 fil uploadet} other {# filer uploadet}}",
|
||||
"upload_error": "Kunne ikke uploade fil",
|
||||
"upload_failed": "{count, plural, one {1 fil kunne ikke uploades} other {# filer kunne ikke uploades}}",
|
||||
"error_over_quota": "Du har nået grænsen for filer eller mapper. Slet nogle, og prøv igen.",
|
||||
"error_too_deep": "Mapper kan ikke indlejres så dybt.",
|
||||
"error_invalid_name": "Serveren accepterer ikke dette navn.",
|
||||
"error_forbidden": "Du har ikke tilladelse til at gøre dette her.",
|
||||
"error_too_large": "Denne fil er for stor til serveren.",
|
||||
"create_folder_success": "Mappe oprettet",
|
||||
"create_folder_error": "Kunne ikke oprette mappe",
|
||||
"delete_success": "Slettet",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Neuer Name",
|
||||
"name_forbidden_chars": "Diese Zeichen sind in Namen auf dem Server nicht erlaubt: {chars}",
|
||||
"name_reserved": "Dieser Name ist auf dem Server reserviert.",
|
||||
"name_control_chars": "Namen dürfen keine Steuerzeichen enthalten.",
|
||||
"name_too_long": "Dieser Name ist für den Server zu lang.",
|
||||
"delete": "Löschen",
|
||||
"delete_confirm_title": "Ressource löschen",
|
||||
"delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"upload_success": "{count, plural, one {1 Datei hochgeladen} other {# Dateien hochgeladen}}",
|
||||
"upload_error": "Datei konnte nicht hochgeladen werden",
|
||||
"upload_failed": "{count, plural, one {1 Datei konnte nicht hochgeladen werden} other {# Dateien konnten nicht hochgeladen werden}}",
|
||||
"error_over_quota": "Die Höchstzahl an Dateien oder Ordnern ist erreicht. Löschen Sie einige und versuchen Sie es erneut.",
|
||||
"error_too_deep": "Ordner lassen sich nicht so tief verschachteln.",
|
||||
"error_invalid_name": "Der Server akzeptiert diesen Namen nicht.",
|
||||
"error_forbidden": "Dazu fehlt Ihnen hier die Berechtigung.",
|
||||
"error_too_large": "Diese Datei ist für den Server zu groß.",
|
||||
"create_folder_success": "Ordner erstellt",
|
||||
"create_folder_error": "Ordner konnte nicht erstellt werden",
|
||||
"delete_success": "Erfolgreich gelöscht",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "New name",
|
||||
"name_forbidden_chars": "The server does not allow these characters in names: {chars}",
|
||||
"name_reserved": "This name is reserved on the server.",
|
||||
"name_control_chars": "Names cannot contain control characters.",
|
||||
"name_too_long": "This name is too long for the server.",
|
||||
"delete": "Delete",
|
||||
"delete_confirm_title": "Delete resource",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "Uploading...",
|
||||
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
|
||||
"upload_error": "Failed to upload file",
|
||||
"upload_failed": "{count, plural, one {1 file could not be uploaded} other {# files could not be uploaded}}",
|
||||
"error_over_quota": "You have reached the limit on files or folders. Delete some and try again.",
|
||||
"error_too_deep": "Folders cannot be nested this deeply.",
|
||||
"error_invalid_name": "The server does not accept this name.",
|
||||
"error_forbidden": "You do not have permission to do this here.",
|
||||
"error_too_large": "This file is too large for the server.",
|
||||
"create_folder_success": "Folder created",
|
||||
"create_folder_error": "Failed to create folder",
|
||||
"delete_success": "Deleted successfully",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Nuevo nombre",
|
||||
"name_forbidden_chars": "El servidor no permite estos caracteres en los nombres: {chars}",
|
||||
"name_reserved": "Este nombre está reservado en el servidor.",
|
||||
"name_control_chars": "Los nombres no pueden contener caracteres de control.",
|
||||
"name_too_long": "Este nombre es demasiado largo para el servidor.",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm_title": "Eliminar recurso",
|
||||
"delete_confirm_message": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Subiendo...",
|
||||
"upload_success": "{count, plural, one {1 archivo subido} other {# archivos subidos}}",
|
||||
"upload_error": "Error al subir el archivo",
|
||||
"upload_failed": "{count, plural, one {No se pudo subir 1 archivo} other {No se pudieron subir # archivos}}",
|
||||
"error_over_quota": "Se alcanzó el límite de archivos o carpetas. Elimine algunos e inténtelo de nuevo.",
|
||||
"error_too_deep": "Las carpetas no se pueden anidar a tanta profundidad.",
|
||||
"error_invalid_name": "El servidor no acepta este nombre.",
|
||||
"error_forbidden": "No tiene permiso para hacer esto aquí.",
|
||||
"error_too_large": "Este archivo es demasiado grande para el servidor.",
|
||||
"create_folder_success": "Carpeta creada",
|
||||
"create_folder_error": "Error al crear la carpeta",
|
||||
"delete_success": "Eliminado correctamente",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "نام جدید",
|
||||
"name_forbidden_chars": "سرور این نویسهها را در نامها مجاز نمیداند: {chars}",
|
||||
"name_reserved": "این نام در سرور رزرو شده است.",
|
||||
"name_control_chars": "نامها نمیتوانند نویسههای کنترلی داشته باشند.",
|
||||
"name_too_long": "این نام برای سرور بیش از حد طولانی است.",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف منبع",
|
||||
"delete_confirm_message": "آیا از حذف \"{name}\" اطمینان دارید؟ این عملیات قابل بازگشت نیست.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "در حال بارگذاری...",
|
||||
"upload_success": "{count, plural, one {۱ فایل بارگذاری شد} other {# فایل بارگذاری شد}}",
|
||||
"upload_error": "بارگذاری فایل ناموفق بود",
|
||||
"upload_failed": "{count, plural, one {۱ فایل بارگذاری نشد} other {# فایل بارگذاری نشد}}",
|
||||
"error_over_quota": "به حداکثر تعداد فایلها یا پوشهها رسیدهاید. برخی را حذف کنید و دوباره تلاش کنید.",
|
||||
"error_too_deep": "پوشهها را نمیتوان تا این عمق تودرتو کرد.",
|
||||
"error_invalid_name": "سرور این نام را نمیپذیرد.",
|
||||
"error_forbidden": "اجازهٔ انجام این کار را در اینجا ندارید.",
|
||||
"error_too_large": "این فایل برای سرور بیش از حد بزرگ است.",
|
||||
"create_folder_success": "پوشه ایجاد شد",
|
||||
"create_folder_error": "ایجاد پوشه ناموفق بود",
|
||||
"delete_success": "با موفقیت حذف شد",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Nouveau nom",
|
||||
"name_forbidden_chars": "Le serveur n'autorise pas ces caractères dans les noms : {chars}",
|
||||
"name_reserved": "Ce nom est réservé sur le serveur.",
|
||||
"name_control_chars": "Les noms ne peuvent pas contenir de caractères de contrôle.",
|
||||
"name_too_long": "Ce nom est trop long pour le serveur.",
|
||||
"delete": "Supprimer",
|
||||
"delete_confirm_title": "Supprimer la ressource",
|
||||
"delete_confirm_message": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Téléversement en cours...",
|
||||
"upload_success": "{count, plural, one {1 fichier téléversé} other {# fichiers téléversés}}",
|
||||
"upload_error": "Échec du téléversement du fichier",
|
||||
"upload_failed": "{count, plural, one {1 fichier n'a pas pu être téléversé} other {# fichiers n'ont pas pu être téléversés}}",
|
||||
"error_over_quota": "Vous avez atteint la limite de fichiers ou de dossiers. Supprimez-en quelques-uns et réessayez.",
|
||||
"error_too_deep": "Les dossiers ne peuvent pas être imbriqués aussi profondément.",
|
||||
"error_invalid_name": "Le serveur n'accepte pas ce nom.",
|
||||
"error_forbidden": "Vous n'avez pas l'autorisation de faire cela ici.",
|
||||
"error_too_large": "Ce fichier est trop volumineux pour le serveur.",
|
||||
"create_folder_success": "Dossier créé",
|
||||
"create_folder_error": "Échec de la création du dossier",
|
||||
"delete_success": "Supprimé avec succès",
|
||||
|
||||
@@ -3232,6 +3232,8 @@
|
||||
"new_name": "שם חדש",
|
||||
"name_forbidden_chars": "השרת אינו מתיר את התווים האלה בשמות: {chars}",
|
||||
"name_reserved": "השם הזה שמור בשרת.",
|
||||
"name_control_chars": "שמות אינם יכולים להכיל תווי בקרה.",
|
||||
"name_too_long": "השם הזה ארוך מדי עבור השרת.",
|
||||
"delete": "לִמְחוֹק",
|
||||
"delete_confirm_title": "מחק משאב",
|
||||
"delete_confirm_message": "האם אתה בטוח שברצונך למחוק את \"{name}\"? לא ניתן לבטל זאת.",
|
||||
@@ -3249,6 +3251,12 @@
|
||||
"uploading": "מעלה...",
|
||||
"upload_success": "{count, plural, one {קובץ אחד הועלה} other {# קבצים הועלו}}",
|
||||
"upload_error": "העלאת הקובץ נכשלה",
|
||||
"upload_failed": "{count, plural, one {לא ניתן היה להעלות קובץ אחד} other {לא ניתן היה להעלות # קבצים}}",
|
||||
"error_over_quota": "הגעת למגבלת הקבצים או התיקיות. יש למחוק חלק מהם ולנסות שוב.",
|
||||
"error_too_deep": "לא ניתן לקנן תיקיות לעומק כזה.",
|
||||
"error_invalid_name": "השרת אינו מקבל את השם הזה.",
|
||||
"error_forbidden": "אין לך הרשאה לבצע פעולה זו כאן.",
|
||||
"error_too_large": "הקובץ הזה גדול מדי עבור השרת.",
|
||||
"create_folder_success": "התיקיה נוצרה",
|
||||
"create_folder_error": "יצירת התיקיה נכשלה",
|
||||
"delete_success": "נמחק בהצלחה",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Új név",
|
||||
"name_forbidden_chars": "A kiszolgáló nem engedi ezeket a karaktereket a nevekben: {chars}",
|
||||
"name_reserved": "Ez a név a kiszolgálón foglalt.",
|
||||
"name_control_chars": "A nevek nem tartalmazhatnak vezérlőkaraktereket.",
|
||||
"name_too_long": "Ez a név túl hosszú a kiszolgáló számára.",
|
||||
"delete": "Törlés",
|
||||
"delete_confirm_title": "Erőforrás törlése",
|
||||
"delete_confirm_message": "Biztosan törölni szeretnéd a \"{name}\" elemet? Ez nem vonható vissza.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "Feltöltés...",
|
||||
"upload_success": "{count, plural, one {1 fájl feltöltve} other {# fájl feltöltve}}",
|
||||
"upload_error": "Nem sikerült feltölteni a fájlt",
|
||||
"upload_failed": "{count, plural, one {1 fájlt nem sikerült feltölteni} other {# fájlt nem sikerült feltölteni}}",
|
||||
"error_over_quota": "Elérted a fájlok vagy mappák számának korlátját. Törölj néhányat, és próbáld újra.",
|
||||
"error_too_deep": "A mappák nem ágyazhatók egymásba ilyen mélyen.",
|
||||
"error_invalid_name": "A kiszolgáló nem fogadja el ezt a nevet.",
|
||||
"error_forbidden": "Ehhez itt nincs jogosultságod.",
|
||||
"error_too_large": "Ez a fájl túl nagy a kiszolgáló számára.",
|
||||
"create_folder_success": "Mappa létrehozva",
|
||||
"create_folder_error": "Nem sikerült létrehozni a mappát",
|
||||
"delete_success": "Sikeresen törölve",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Nuovo nome",
|
||||
"name_forbidden_chars": "Il server non consente questi caratteri nei nomi: {chars}",
|
||||
"name_reserved": "Questo nome è riservato sul server.",
|
||||
"name_control_chars": "I nomi non possono contenere caratteri di controllo.",
|
||||
"name_too_long": "Questo nome è troppo lungo per il server.",
|
||||
"delete": "Elimina",
|
||||
"delete_confirm_title": "Elimina risorsa",
|
||||
"delete_confirm_message": "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Caricamento in corso...",
|
||||
"upload_success": "{count, plural, one {1 file caricato} other {# file caricati}}",
|
||||
"upload_error": "Caricamento del file non riuscito",
|
||||
"upload_failed": "{count, plural, one {Impossibile caricare 1 file} other {Impossibile caricare # file}}",
|
||||
"error_over_quota": "Hai raggiunto il limite di file o cartelle. Eliminane alcuni e riprova.",
|
||||
"error_too_deep": "Le cartelle non possono essere annidate così in profondità.",
|
||||
"error_invalid_name": "Il server non accetta questo nome.",
|
||||
"error_forbidden": "Non hai l'autorizzazione per farlo qui.",
|
||||
"error_too_large": "Questo file è troppo grande per il server.",
|
||||
"create_folder_success": "Cartella creata",
|
||||
"create_folder_error": "Creazione della cartella non riuscita",
|
||||
"delete_success": "Eliminato con successo",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "新しい名前",
|
||||
"name_forbidden_chars": "サーバーでは名前に次の文字を使用できません: {chars}",
|
||||
"name_reserved": "この名前はサーバーで予約されています。",
|
||||
"name_control_chars": "名前に制御文字は使用できません。",
|
||||
"name_too_long": "この名前はサーバーには長すぎます。",
|
||||
"delete": "削除",
|
||||
"delete_confirm_title": "リソースを削除",
|
||||
"delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "アップロード中...",
|
||||
"upload_success": "{count, plural, other {#件のファイルをアップロードしました}}",
|
||||
"upload_error": "ファイルのアップロードに失敗しました",
|
||||
"upload_failed": "{count, plural, other {#件のファイルをアップロードできませんでした}}",
|
||||
"error_over_quota": "ファイルまたはフォルダーの上限に達しました。いくつか削除してから、もう一度お試しください。",
|
||||
"error_too_deep": "フォルダーをこれ以上深く入れ子にすることはできません。",
|
||||
"error_invalid_name": "サーバーはこの名前を受け付けません。",
|
||||
"error_forbidden": "ここでこの操作を行う権限がありません。",
|
||||
"error_too_large": "このファイルはサーバーには大きすぎます。",
|
||||
"create_folder_success": "フォルダーを作成しました",
|
||||
"create_folder_error": "フォルダーの作成に失敗しました",
|
||||
"delete_success": "正常に削除しました",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "새 이름",
|
||||
"name_forbidden_chars": "서버에서 이름에 다음 문자를 허용하지 않습니다: {chars}",
|
||||
"name_reserved": "이 이름은 서버에서 예약되어 있습니다.",
|
||||
"name_control_chars": "이름에 제어 문자를 사용할 수 없습니다.",
|
||||
"name_too_long": "이 이름은 서버에서 사용하기에 너무 깁니다.",
|
||||
"delete": "삭제",
|
||||
"delete_confirm_title": "항목 삭제",
|
||||
"delete_confirm_message": "정말 \"{name}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없어요.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "업로드 중...",
|
||||
"upload_success": "{count}개의 파일이 업로드되었어요",
|
||||
"upload_error": "파일 업로드에 실패했어요",
|
||||
"upload_failed": "{count}개의 파일을 업로드하지 못했습니다",
|
||||
"error_over_quota": "파일 또는 폴더 수 한도에 도달했습니다. 일부를 삭제한 후 다시 시도하세요.",
|
||||
"error_too_deep": "폴더를 이렇게 깊이 중첩할 수 없습니다.",
|
||||
"error_invalid_name": "서버에서 이 이름을 허용하지 않습니다.",
|
||||
"error_forbidden": "여기에서 이 작업을 수행할 권한이 없습니다.",
|
||||
"error_too_large": "이 파일은 서버에서 처리하기에 너무 큽니다.",
|
||||
"create_folder_success": "폴더가 만들어졌어요",
|
||||
"create_folder_error": "폴더를 만들지 못했어요",
|
||||
"delete_success": "성공적으로 삭제되었어요",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Jauns nosaukums",
|
||||
"name_forbidden_chars": "Serveris neatļauj šīs rakstzīmes nosaukumos: {chars}",
|
||||
"name_reserved": "Šis nosaukums serverī ir rezervēts.",
|
||||
"name_control_chars": "Nosaukumos nedrīkst būt vadības rakstzīmes.",
|
||||
"name_too_long": "Šis nosaukums serverim ir pārāk garš.",
|
||||
"delete": "Dzēst",
|
||||
"delete_confirm_title": "Dzēst resursu",
|
||||
"delete_confirm_message": "Vai tiešām vēlaties dzēst «{name}»? Šo darbību nevar atcelt.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Augšupielādē...",
|
||||
"upload_success": "Augšupielādēts {count, plural, one {1 fails} other {# faili}}",
|
||||
"upload_error": "Neizdevās augšupielādēt failu",
|
||||
"upload_failed": "{count, plural, one {Neizdevās augšupielādēt # failu} other {Neizdevās augšupielādēt # failus}}",
|
||||
"error_over_quota": "Ir sasniegts failu vai mapju skaita ierobežojums. Izdzēsiet dažus un mēģiniet vēlreiz.",
|
||||
"error_too_deep": "Mapes nevar ievietot tik dziļi citu citā.",
|
||||
"error_invalid_name": "Serveris nepieņem šo nosaukumu.",
|
||||
"error_forbidden": "Jums šeit nav atļaujas to darīt.",
|
||||
"error_too_large": "Šis fails serverim ir pārāk liels.",
|
||||
"create_folder_success": "Mape izveidota",
|
||||
"create_folder_error": "Neizdevās izveidot mapi",
|
||||
"delete_success": "Sekmīgi izdzēsts",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Шинэ нэр",
|
||||
"name_forbidden_chars": "Сервер нэрэнд эдгээр тэмдэгтийг зөвшөөрдөггүй: {chars}",
|
||||
"name_reserved": "Энэ нэр сервер дээр нөөцлөгдсөн.",
|
||||
"name_control_chars": "Нэрэнд удирдах тэмдэгт агуулж болохгүй.",
|
||||
"name_too_long": "Энэ нэр серверийн хувьд хэт урт байна.",
|
||||
"delete": "Устгах",
|
||||
"delete_confirm_title": "Нөөцийг устгах",
|
||||
"delete_confirm_message": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Үүнийг буцаах боломжгүй.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "Байршуулж байна...",
|
||||
"upload_success": "{count, plural, one {1 файл дээш ачигдлаа} other {# файл дээш ачигдлаа}}",
|
||||
"upload_error": "Файл байршуулж чадсангүй",
|
||||
"upload_failed": "{count, plural, one {1 файлыг дээш ачаалж чадсангүй} other {# файлыг дээш ачаалж чадсангүй}}",
|
||||
"error_over_quota": "Файл эсвэл хавтасны хязгаарт хүрсэн байна. Заримыг нь устгаад дахин оролдоно уу.",
|
||||
"error_too_deep": "Хавтсуудыг ийм гүн давхарлах боломжгүй.",
|
||||
"error_invalid_name": "Сервер энэ нэрийг хүлээн авахгүй байна.",
|
||||
"error_forbidden": "Танд энд үүнийг хийх эрх байхгүй.",
|
||||
"error_too_large": "Энэ файл серверийн хувьд хэт том байна.",
|
||||
"create_folder_success": "Хавтас үүсгэсэн",
|
||||
"create_folder_error": "Хавтас үүсгэж чадсангүй",
|
||||
"delete_success": "Амжилттай устгасан",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Nytt navn",
|
||||
"name_forbidden_chars": "Serveren tillater ikke disse tegnene i navn: {chars}",
|
||||
"name_reserved": "Dette navnet er reservert på serveren.",
|
||||
"name_control_chars": "Navn kan ikke inneholde kontrolltegn.",
|
||||
"name_too_long": "Dette navnet er for langt for serveren.",
|
||||
"delete": "Slett",
|
||||
"delete_confirm_title": "Slett fil eller mappe",
|
||||
"delete_confirm_message": "Er du sikker på at du vil slette «{name}»? Dette kan ikke angres.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "Laster opp...",
|
||||
"upload_success": "{count, plural, one {1 fil lastet opp} other {# filer lastet opp}}",
|
||||
"upload_error": "Kunne ikke laste opp filen",
|
||||
"upload_failed": "{count, plural, one {1 fil kunne ikke lastes opp} other {# filer kunne ikke lastes opp}}",
|
||||
"error_over_quota": "Du har nådd grensen for filer eller mapper. Slett noen og prøv igjen.",
|
||||
"error_too_deep": "Mapper kan ikke nestes så dypt.",
|
||||
"error_invalid_name": "Serveren godtar ikke dette navnet.",
|
||||
"error_forbidden": "Du har ikke tillatelse til å gjøre dette her.",
|
||||
"error_too_large": "Denne filen er for stor for serveren.",
|
||||
"create_folder_success": "Mappen er opprettet",
|
||||
"create_folder_error": "Kunne ikke opprette mappe",
|
||||
"delete_success": "Elementet er slettet",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Nieuwe naam",
|
||||
"name_forbidden_chars": "De server staat deze tekens niet toe in namen: {chars}",
|
||||
"name_reserved": "Deze naam is gereserveerd op de server.",
|
||||
"name_control_chars": "Namen mogen geen besturingstekens bevatten.",
|
||||
"name_too_long": "Deze naam is te lang voor de server.",
|
||||
"delete": "Verwijderen",
|
||||
"delete_confirm_title": "Bron verwijderen",
|
||||
"delete_confirm_message": "Weet u zeker dat u \"{name}\" wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Uploaden...",
|
||||
"upload_success": "{count, plural, one {1 bestand geüpload} other {# bestanden geüpload}}",
|
||||
"upload_error": "Bestand uploaden mislukt",
|
||||
"upload_failed": "{count, plural, one {1 bestand kon niet worden geüpload} other {# bestanden konden niet worden geüpload}}",
|
||||
"error_over_quota": "Het maximumaantal bestanden of mappen is bereikt. Verwijder er enkele en probeer het opnieuw.",
|
||||
"error_too_deep": "Mappen kunnen niet zo diep worden genest.",
|
||||
"error_invalid_name": "De server accepteert deze naam niet.",
|
||||
"error_forbidden": "U hebt hier geen toestemming voor.",
|
||||
"error_too_large": "Dit bestand is te groot voor de server.",
|
||||
"create_folder_success": "Map aangemaakt",
|
||||
"create_folder_error": "Map aanmaken mislukt",
|
||||
"delete_success": "Succesvol verwijderd",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Nowa nazwa",
|
||||
"name_forbidden_chars": "Serwer nie zezwala na te znaki w nazwach: {chars}",
|
||||
"name_reserved": "Ta nazwa jest zarezerwowana na serwerze.",
|
||||
"name_control_chars": "Nazwy nie mogą zawierać znaków sterujących.",
|
||||
"name_too_long": "Ta nazwa jest zbyt długa dla serwera.",
|
||||
"delete": "Usuń",
|
||||
"delete_confirm_title": "Usuń zasób",
|
||||
"delete_confirm_message": "Czy na pewno chcesz usunąć \"{name}\"? Tej operacji nie można cofnąć.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Przesyłanie...",
|
||||
"upload_success": "{count, plural, one {Przesłano 1 plik} other {Przesłano # plików}}",
|
||||
"upload_error": "Nie udało się przesłać pliku",
|
||||
"upload_failed": "{count, plural, one {Nie udało się przesłać 1 pliku} few {Nie udało się przesłać # plików} many {Nie udało się przesłać # plików} other {Nie udało się przesłać # pliku}}",
|
||||
"error_over_quota": "Osiągnięto limit plików lub folderów. Usuń niektóre i spróbuj ponownie.",
|
||||
"error_too_deep": "Foldery nie mogą być zagnieżdżone tak głęboko.",
|
||||
"error_invalid_name": "Serwer nie akceptuje tej nazwy.",
|
||||
"error_forbidden": "Nie masz tutaj uprawnień do wykonania tej czynności.",
|
||||
"error_too_large": "Ten plik jest zbyt duży dla serwera.",
|
||||
"create_folder_success": "Folder utworzono",
|
||||
"create_folder_error": "Nie udało się utworzyć folderu",
|
||||
"delete_success": "Usunięto pomyślnie",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Novo nome",
|
||||
"name_forbidden_chars": "O servidor não permite estes caracteres nos nomes: {chars}",
|
||||
"name_reserved": "Este nome está reservado no servidor.",
|
||||
"name_control_chars": "Os nomes não podem conter caracteres de controle.",
|
||||
"name_too_long": "Este nome é longo demais para o servidor.",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm_title": "Eliminar recurso",
|
||||
"delete_confirm_message": "Tem a certeza de que deseja eliminar \"{name}\"? Esta ação não pode ser desfeita.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "A carregar...",
|
||||
"upload_success": "{count, plural, one {1 arquivo carregado} other {# arquivos carregados}}",
|
||||
"upload_error": "Falha ao carregar o arquivo",
|
||||
"upload_failed": "{count, plural, one {Não foi possível carregar 1 arquivo} other {Não foi possível carregar # arquivos}}",
|
||||
"error_over_quota": "O limite de arquivos ou pastas foi atingido. Exclua alguns e tente novamente.",
|
||||
"error_too_deep": "As pastas não podem ser aninhadas tão profundamente.",
|
||||
"error_invalid_name": "O servidor não aceita este nome.",
|
||||
"error_forbidden": "Não tem permissão para fazer isso aqui.",
|
||||
"error_too_large": "Este arquivo é grande demais para o servidor.",
|
||||
"create_folder_success": "Pasta criada",
|
||||
"create_folder_error": "Falha ao criar a pasta",
|
||||
"delete_success": "Eliminado com sucesso",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Nume nou",
|
||||
"name_forbidden_chars": "Serverul nu permite aceste caractere în nume: {chars}",
|
||||
"name_reserved": "Acest nume este rezervat pe server.",
|
||||
"name_control_chars": "Numele nu pot conține caractere de control.",
|
||||
"name_too_long": "Acest nume este prea lung pentru server.",
|
||||
"delete": "Șterge",
|
||||
"delete_confirm_title": "Ștergeți resursa",
|
||||
"delete_confirm_message": "Sunteți sigur că doriți să ștergeți „{name}”? Această acțiune nu poate fi anulată.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "Se încarcă...",
|
||||
"upload_success": "{count, plural, one {1 fișier încărcat} few {# fișiere încărcate} other {# de fișiere încărcate}}",
|
||||
"upload_error": "Nu s-a reușit încărcarea fișierului",
|
||||
"upload_failed": "{count, plural, one {1 fișier nu a putut fi încărcat} few {# fișiere nu au putut fi încărcate} other {# de fișiere nu au putut fi încărcate}}",
|
||||
"error_over_quota": "Ați atins limita de fișiere sau foldere. Ștergeți câteva și încercați din nou.",
|
||||
"error_too_deep": "Folderele nu pot fi imbricate atât de adânc.",
|
||||
"error_invalid_name": "Serverul nu acceptă acest nume.",
|
||||
"error_forbidden": "Nu aveți permisiunea de a face acest lucru aici.",
|
||||
"error_too_large": "Acest fișier este prea mare pentru server.",
|
||||
"create_folder_success": "Folder creat",
|
||||
"create_folder_error": "Nu s-a putut crea folderul",
|
||||
"delete_success": "Șters cu succes",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Новое название",
|
||||
"name_forbidden_chars": "Сервер не допускает эти символы в именах: {chars}",
|
||||
"name_reserved": "Это имя зарезервировано на сервере.",
|
||||
"name_control_chars": "Имена не могут содержать управляющие символы.",
|
||||
"name_too_long": "Это имя слишком длинное для сервера.",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm_title": "Удалить ресурс",
|
||||
"delete_confirm_message": "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Загрузка...",
|
||||
"upload_success": "{count, plural, one {1 файл загружен} other {# файлов загружено}}",
|
||||
"upload_error": "Не удалось загрузить файл",
|
||||
"upload_failed": "{count, plural, one {Не удалось загрузить # файл} few {Не удалось загрузить # файла} many {Не удалось загрузить # файлов} other {Не удалось загрузить # файла}}",
|
||||
"error_over_quota": "Достигнуто ограничение на число файлов или папок. Удалите некоторые и повторите попытку.",
|
||||
"error_too_deep": "Папки нельзя вкладывать так глубоко.",
|
||||
"error_invalid_name": "Сервер не принимает это имя.",
|
||||
"error_forbidden": "У вас нет прав на это действие здесь.",
|
||||
"error_too_large": "Этот файл слишком большой для сервера.",
|
||||
"create_folder_success": "Папка создана",
|
||||
"create_folder_error": "Не удалось создать папку",
|
||||
"delete_success": "Успешно удалено",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Nový názov",
|
||||
"name_forbidden_chars": "Server nepovoľuje v názvoch tieto znaky: {chars}",
|
||||
"name_reserved": "Tento názov je na serveri vyhradený.",
|
||||
"name_control_chars": "Názvy nesmú obsahovať riadiace znaky.",
|
||||
"name_too_long": "Tento názov je pre server príliš dlhý.",
|
||||
"delete": "Odstrániť",
|
||||
"delete_confirm_title": "Odstrániť položku",
|
||||
"delete_confirm_message": "Naozaj chcete odstrániť \"{name}\"? Túto akciu nie je možné vrátiť.",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "Nahrávanie...",
|
||||
"upload_success": "{count, plural, one {1 súbor nahraný} other {# súborov nahraných}}",
|
||||
"upload_error": "Nahranie súboru zlyhalo",
|
||||
"upload_failed": "{count, plural, one {1 súbor sa nepodarilo nahrať} few {# súbory sa nepodarilo nahrať} other {# súborov sa nepodarilo nahrať}}",
|
||||
"error_over_quota": "Dosiahli ste limit počtu súborov alebo priečinkov. Niektoré odstráňte a skúste to znova.",
|
||||
"error_too_deep": "Priečinky nemožno vnoriť tak hlboko.",
|
||||
"error_invalid_name": "Server tento názov neprijíma.",
|
||||
"error_forbidden": "Na túto akciu tu nemáte oprávnenie.",
|
||||
"error_too_large": "Tento súbor je pre server príliš veľký.",
|
||||
"create_folder_success": "Priečinok bol vytvorený",
|
||||
"create_folder_error": "Vytvorenie priečinka zlyhalo",
|
||||
"delete_success": "Úspešne odstránené",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "Yeni ad",
|
||||
"name_forbidden_chars": "Sunucu adlarda şu karakterlere izin vermiyor: {chars}",
|
||||
"name_reserved": "Bu ad sunucuda ayrılmış.",
|
||||
"name_control_chars": "Adlar denetim karakterleri içeremez.",
|
||||
"name_too_long": "Bu ad sunucu için çok uzun.",
|
||||
"delete": "Sil",
|
||||
"delete_confirm_title": "Kaynağı sil",
|
||||
"delete_confirm_message": "\"{name}\" silinsin mi? Bu geri alınamaz.",
|
||||
@@ -3371,6 +3373,12 @@
|
||||
"uploading": "Yükleniyor...",
|
||||
"upload_success": "{count, plural, one {1 dosya yüklendi} other {# dosya yüklendi}}",
|
||||
"upload_error": "Dosya yüklenemedi",
|
||||
"upload_failed": "{count, plural, one {1 dosya yüklenemedi} other {# dosya yüklenemedi}}",
|
||||
"error_over_quota": "Dosya veya klasör sınırına ulaştınız. Bazılarını silip yeniden deneyin.",
|
||||
"error_too_deep": "Klasörler bu kadar derin iç içe yerleştirilemez.",
|
||||
"error_invalid_name": "Sunucu bu adı kabul etmiyor.",
|
||||
"error_forbidden": "Burada bunu yapma izniniz yok.",
|
||||
"error_too_large": "Bu dosya sunucu için çok büyük.",
|
||||
"create_folder_success": "Klasör oluşturuldu",
|
||||
"create_folder_error": "Klasör oluşturulamadı",
|
||||
"delete_success": "Başarıyla silindi",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "Нова назва",
|
||||
"name_forbidden_chars": "Сервер не дозволяє ці символи в назвах: {chars}",
|
||||
"name_reserved": "Ця назва зарезервована на сервері.",
|
||||
"name_control_chars": "Назви не можуть містити керівні символи.",
|
||||
"name_too_long": "Ця назва задовга для сервера.",
|
||||
"delete": "Видалити",
|
||||
"delete_confirm_title": "Видалити ресурс",
|
||||
"delete_confirm_message": "Ви впевнені, що хочете видалити \"{name}\"? Це неможливо скасувати.",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "Завантаження...",
|
||||
"upload_success": "{count, plural, one {1 файл завантажено} few {# файли завантажено} many {# файлів завантажено} other {# файлів завантажено}}",
|
||||
"upload_error": "Не вдалося завантажити файл",
|
||||
"upload_failed": "{count, plural, one {Не вдалося завантажити # файл} few {Не вдалося завантажити # файли} many {Не вдалося завантажити # файлів} other {Не вдалося завантажити # файлу}}",
|
||||
"error_over_quota": "Досягнуто обмеження на кількість файлів або папок. Видаліть деякі й повторіть спробу.",
|
||||
"error_too_deep": "Папки не можна вкладати так глибоко.",
|
||||
"error_invalid_name": "Сервер не приймає цю назву.",
|
||||
"error_forbidden": "У вас немає дозволу на цю дію тут.",
|
||||
"error_too_large": "Цей файл завеликий для сервера.",
|
||||
"create_folder_success": "Папка створена",
|
||||
"create_folder_error": "Не вдалося створити папку",
|
||||
"delete_success": "Успішно видалено",
|
||||
|
||||
@@ -3354,6 +3354,8 @@
|
||||
"new_name": "新名稱",
|
||||
"name_forbidden_chars": "伺服器不允許名稱中包含這些字元:{chars}",
|
||||
"name_reserved": "此名稱已被伺服器保留。",
|
||||
"name_control_chars": "名稱不能包含控制字元。",
|
||||
"name_too_long": "此名稱對伺服器來說過長。",
|
||||
"delete": "刪除",
|
||||
"delete_confirm_title": "刪除項目",
|
||||
"delete_confirm_message": "您確定要刪除「{name}」嗎?此動作無法復原。",
|
||||
@@ -3373,6 +3375,12 @@
|
||||
"uploading": "正在上傳…",
|
||||
"upload_success": "{count, plural, one {已上傳 1 個檔案} other {已上傳 # 個檔案}}",
|
||||
"upload_error": "上傳檔案失敗",
|
||||
"upload_failed": "{count, plural, one {1 個檔案無法上傳} other {# 個檔案無法上傳}}",
|
||||
"error_over_quota": "已達到檔案或資料夾數量上限。請刪除一些後再試一次。",
|
||||
"error_too_deep": "資料夾不能巢狀這麼深。",
|
||||
"error_invalid_name": "伺服器不接受此名稱。",
|
||||
"error_forbidden": "你沒有權限在此處執行此操作。",
|
||||
"error_too_large": "此檔案對伺服器來說過大。",
|
||||
"create_folder_success": "資料夾已建立",
|
||||
"create_folder_error": "建立資料夾失敗",
|
||||
"delete_success": "刪除成功",
|
||||
|
||||
@@ -3309,6 +3309,8 @@
|
||||
"new_name": "新名称",
|
||||
"name_forbidden_chars": "服务器不允许名称中包含这些字符:{chars}",
|
||||
"name_reserved": "此名称已被服务器保留。",
|
||||
"name_control_chars": "名称不能包含控制字符。",
|
||||
"name_too_long": "此名称对服务器来说过长。",
|
||||
"delete": "删除",
|
||||
"delete_confirm_title": "删除资源",
|
||||
"delete_confirm_message": "您确定要删除\"{name}\"吗?此操作无法撤消。",
|
||||
@@ -3326,6 +3328,12 @@
|
||||
"uploading": "正在上传…",
|
||||
"upload_success": "{count, plural, one {已上传 1 个文件} other {已上传 # 个文件}}",
|
||||
"upload_error": "上传文件失败",
|
||||
"upload_failed": "{count, plural, one {1 个文件未能上传} other {# 个文件未能上传}}",
|
||||
"error_over_quota": "已达到文件或文件夹数量上限。请删除一些后重试。",
|
||||
"error_too_deep": "文件夹不能嵌套这么深。",
|
||||
"error_invalid_name": "服务器不接受此名称。",
|
||||
"error_forbidden": "你无权在此处执行此操作。",
|
||||
"error_too_large": "此文件对服务器来说过大。",
|
||||
"create_folder_success": "文件夹已创建",
|
||||
"create_folder_error": "创建文件夹失败",
|
||||
"delete_success": "删除成功",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useFileStore, resourceServerRef } from '../file-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { jmapError } from '@/lib/jmap/jmap-error';
|
||||
import { fileErrorKind } from '@/lib/file-errors';
|
||||
|
||||
// Minimal in-memory FileNode backend that models the JMAP hierarchy via
|
||||
// parentId links (the behaviour issue #379 asks for).
|
||||
@@ -528,6 +530,112 @@ describe('file-store symlinks (Stalwart 1.0)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-store upload batches', () => {
|
||||
// A file dropped as part of a folder carries its path inside it.
|
||||
const inFolder = (path: string) => {
|
||||
const file = new File(['x'], path.split('/').pop()!, { type: 'text/plain' });
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: path });
|
||||
return file;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({
|
||||
client: null,
|
||||
currentParentId: null,
|
||||
currentPath: '/',
|
||||
pathStack: [{ id: null, name: '' }],
|
||||
resources: [],
|
||||
selectedResources: new Set(),
|
||||
clipboard: null,
|
||||
lastAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('goes on past a file the server refuses and reports it at the end', async () => {
|
||||
const client = makeMockClient([]);
|
||||
const createFileNode = client.createFileNode.bind(client);
|
||||
Object.assign(client, {
|
||||
createFileNode: async (name: string, ...rest: [string, string, number, string | null]) => {
|
||||
if (name === 'b.txt') {
|
||||
// 1.0 once an administrator has set maxFiles.
|
||||
throw jmapError('There are too many files, please delete some before adding a new one.', { type: 'overQuota' });
|
||||
}
|
||||
return createFileNode(name, ...rest);
|
||||
},
|
||||
});
|
||||
useFileStore.getState().initClient(client);
|
||||
|
||||
const report = await useFileStore.getState().uploadFiles(
|
||||
['a.txt', 'b.txt', 'c.txt'].map(n => new File(['x'], n, { type: 'text/plain' })),
|
||||
);
|
||||
|
||||
expect(report.uploaded).toBe(2);
|
||||
expect(report.failed.map(f => f.name)).toEqual(['b.txt']);
|
||||
expect(fileErrorKind(report.failed[0].error)).toBe('overQuota');
|
||||
expect(client._nodes().map(n => n.name)).toEqual(['a.txt', 'c.txt']);
|
||||
});
|
||||
|
||||
it('skips the files of a folder it could not create instead of putting them in the upload\'s folder', async () => {
|
||||
const client = makeMockClient([dir('here', 'Here', null)]);
|
||||
const createFileDirectory = client.createFileDirectory.bind(client);
|
||||
Object.assign(client, {
|
||||
createFileDirectory: async (name: string, parentId: string | null) => {
|
||||
if (name === 'Locked') {
|
||||
throw jmapError('You are not allowed to create file nodes in this folder.', { type: 'forbidden' });
|
||||
}
|
||||
return createFileDirectory(name, parentId);
|
||||
},
|
||||
});
|
||||
useFileStore.getState().initClient(client);
|
||||
await useFileStore.getState().navigate('here', 'Here');
|
||||
|
||||
const report = await useFileStore.getState().uploadFolder([
|
||||
inFolder('Photos/a.jpg'),
|
||||
inFolder('Photos/Locked/b.jpg'),
|
||||
inFolder('Photos/Locked/Inner/c.jpg'),
|
||||
]);
|
||||
|
||||
expect(report.uploaded).toBe(1);
|
||||
expect(report.failed.map(f => f.name)).toEqual(['Photos/Locked/b.jpg', 'Photos/Locked/Inner/c.jpg']);
|
||||
expect(report.failed.every(f => fileErrorKind(f.error) === 'forbidden')).toBe(true);
|
||||
|
||||
const nodes = client._nodes();
|
||||
const photos = nodes.find(n => n.name === 'Photos')!;
|
||||
expect(nodes.filter(n => n.parentId === 'here').map(n => n.name)).toEqual(['Photos']);
|
||||
expect(nodes.filter(n => n.parentId === photos.id).map(n => n.name)).toEqual(['a.jpg']);
|
||||
});
|
||||
|
||||
it('leaves out what would sit deeper than maxFileNodeDepth (Stalwart 1.0)', async () => {
|
||||
// Uploading into Stuff/Nonsense, level 2, with a limit of 4 levels.
|
||||
const client = makeMockClient([dir('stuff', 'Stuff', null), dir('nonsense', 'Nonsense', 'stuff')]);
|
||||
Object.assign(client, { getMaxFileNodeDepth: () => 4 });
|
||||
useFileStore.getState().initClient(client);
|
||||
await useFileStore.getState().navigate('stuff', 'Stuff');
|
||||
await useFileStore.getState().navigate('nonsense', 'Nonsense');
|
||||
|
||||
const report = await useFileStore.getState().uploadFolder([
|
||||
inFolder('Up/a.txt'),
|
||||
inFolder('Up/Deep/b.txt'),
|
||||
inFolder('Up/Deep/Deeper/c.txt'),
|
||||
]);
|
||||
|
||||
expect(report.uploaded).toBe(1);
|
||||
expect(report.failed.map(f => f.name)).toEqual(['Up/Deep/b.txt', 'Up/Deep/Deeper/c.txt']);
|
||||
expect(report.failed.every(f => fileErrorKind(f.error) === 'tooDeep')).toBe(true);
|
||||
expect(client._nodes().map(n => n.name)).toEqual(['Stuff', 'Nonsense', 'Up', 'Deep', 'a.txt']);
|
||||
});
|
||||
|
||||
it('has no depth to check on 0.16', async () => {
|
||||
const client = makeMockClient([dir('stuff', 'Stuff', null), dir('nonsense', 'Nonsense', 'stuff')]);
|
||||
Object.assign(client, { getMaxFileNodeDepth: () => null });
|
||||
useFileStore.getState().initClient(client);
|
||||
await useFileStore.getState().navigate('nonsense', 'Nonsense');
|
||||
|
||||
const report = await useFileStore.getState().uploadFolder([inFolder('Up/Deep/Deeper/c.txt')]);
|
||||
expect(report).toEqual({ uploaded: 1, failed: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resourceServerRef', () => {
|
||||
it('splits a shared drive\'s namespaced id into the owner account and the node id', () => {
|
||||
expect(resourceServerRef({ id: 'owner1:n5', isShared: true, ownerAccountId: 'owner1' }))
|
||||
|
||||
+70
-21
@@ -2,6 +2,8 @@ import { create } from 'zustand';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode, FileNodeRights } from '@/lib/jmap/types';
|
||||
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
|
||||
import { fileNodeDepth } from '@/lib/jmap/filenode-depth';
|
||||
import { folderDepthError } from '@/lib/file-errors';
|
||||
|
||||
export interface FileResource {
|
||||
id: string;
|
||||
@@ -34,6 +36,15 @@ interface UploadProgress {
|
||||
totalFiles: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an upload batch did. A file the server refuses doesn't stop the rest;
|
||||
* it is listed here (by its path inside an uploaded folder) with the error.
|
||||
*/
|
||||
export interface UploadReport {
|
||||
uploaded: number;
|
||||
failed: { name: string; error: unknown }[];
|
||||
}
|
||||
|
||||
interface ClipboardState {
|
||||
mode: 'cut' | 'copy';
|
||||
ids: string[];
|
||||
@@ -90,8 +101,8 @@ interface FileState {
|
||||
refresh: () => Promise<void>;
|
||||
createDirectory: (name: string) => Promise<void>;
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
uploadFiles: (files: File[]) => Promise<void>;
|
||||
uploadFolder: (files: File[]) => Promise<void>;
|
||||
uploadFiles: (files: File[]) => Promise<UploadReport>;
|
||||
uploadFolder: (files: File[]) => Promise<UploadReport>;
|
||||
cancelUpload: () => void;
|
||||
deleteResource: (name: string) => Promise<void>;
|
||||
deleteResources: (names: string[]) => Promise<void>;
|
||||
@@ -637,7 +648,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
|
||||
uploadFiles: async (files: File[]) => {
|
||||
const { client, currentParentId, resources } = get();
|
||||
if (!client) return;
|
||||
const report: UploadReport = { uploaded: 0, failed: [] };
|
||||
if (!client) return report;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
@@ -662,14 +674,15 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId);
|
||||
report.uploaded++;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
throw err;
|
||||
report.failed.push({ name: file.name, error: err });
|
||||
}
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
return report;
|
||||
},
|
||||
|
||||
cancelUpload: () => {
|
||||
@@ -682,41 +695,68 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
|
||||
uploadFolder: async (files: File[]) => {
|
||||
const { client, currentParentId } = get();
|
||||
if (!client || files.length === 0) return;
|
||||
const report: UploadReport = { uploaded: 0, failed: [] };
|
||||
if (!client || files.length === 0) return report;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const totalFiles = files.length;
|
||||
const relativePathOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const levelsOf = (path: string) => (path ? path.split('/').length : 0);
|
||||
|
||||
// Collect unique directory paths (relative to the dropped folder) and
|
||||
// create them as real nested directories, mapping each path to its node id.
|
||||
const dirs = new Set<string>();
|
||||
for (const file of files) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const parts = relativePath.split('/');
|
||||
const parts = relativePathOf(file).split('/');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// Map a directory path to its created node id. Root ('') maps to the
|
||||
// current folder we are uploading into.
|
||||
const dirIds = new Map<string, string | null>();
|
||||
dirIds.set('', currentParentId);
|
||||
// Stalwart 1.0 refuses nodes deeper than maxFileNodeDepth (64). Find out
|
||||
// up front what won't fit below the current folder.
|
||||
const maxDepth = client.getMaxFileNodeDepth?.() ?? null;
|
||||
let baseDepth = 0;
|
||||
if (maxDepth !== null && currentParentId !== null) {
|
||||
try {
|
||||
const nodes = isCrossAccountId(currentParentId)
|
||||
? await client.listAllFileNodesAcrossAccounts()
|
||||
: await client.listAllFileNodes();
|
||||
baseDepth = fileNodeDepth(nodes, currentParentId);
|
||||
} catch {
|
||||
// The server still refuses what doesn't fit.
|
||||
}
|
||||
}
|
||||
const tooDeep = (levels: number) => maxDepth !== null && baseDepth + levels > maxDepth;
|
||||
|
||||
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
|
||||
// Map a directory path to its created node id. Root ('') maps to the
|
||||
// current folder we are uploading into. A folder that can't be created
|
||||
// keeps its error, and everything inside it is skipped with that error
|
||||
// instead of landing in the folder the upload started in.
|
||||
const dirIds = new Map<string, string | null>([['', currentParentId]]);
|
||||
const dirErrors = new Map<string, unknown>();
|
||||
|
||||
const sortedDirs = [...dirs].sort((a, b) => levelsOf(a) - levelsOf(b));
|
||||
for (const dir of sortedDirs) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const slash = dir.lastIndexOf('/');
|
||||
const parentPath = slash >= 0 ? dir.slice(0, slash) : '';
|
||||
const dirName = slash >= 0 ? dir.slice(slash + 1) : dir;
|
||||
const parentId = dirIds.get(parentPath) ?? currentParentId;
|
||||
const parentId = dirIds.get(parentPath);
|
||||
if (parentId === undefined) {
|
||||
dirErrors.set(dir, dirErrors.get(parentPath));
|
||||
continue;
|
||||
}
|
||||
if (tooDeep(levelsOf(dir))) {
|
||||
dirErrors.set(dir, folderDepthError());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const created = await client.createFileDirectory(dirName, parentId);
|
||||
dirIds.set(dir, created.id);
|
||||
} catch {
|
||||
// Directory may already exist - leave it unmapped; files fall back to
|
||||
// the closest known parent below.
|
||||
} catch (err) {
|
||||
dirErrors.set(dir, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,10 +764,18 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const file = files[i];
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const relativePath = relativePathOf(file);
|
||||
const slash = relativePath.lastIndexOf('/');
|
||||
const dirPath = slash >= 0 ? relativePath.slice(0, slash) : '';
|
||||
const parentId = dirIds.get(dirPath) ?? currentParentId;
|
||||
const parentId = dirIds.get(dirPath);
|
||||
if (parentId === undefined) {
|
||||
report.failed.push({ name: relativePath, error: dirErrors.get(dirPath) });
|
||||
continue;
|
||||
}
|
||||
if (tooDeep(levelsOf(dirPath) + 1)) {
|
||||
report.failed.push({ name: relativePath, error: folderDepthError() });
|
||||
continue;
|
||||
}
|
||||
|
||||
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
@@ -736,14 +784,15 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId);
|
||||
report.uploaded++;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') break;
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
throw err;
|
||||
report.failed.push({ name: relativePath, error: err });
|
||||
}
|
||||
}
|
||||
set({ uploadProgress: null, uploadAbortController: null });
|
||||
await get().refresh();
|
||||
return report;
|
||||
},
|
||||
|
||||
deleteResource: async (name: string) => {
|
||||
|
||||
Reference in New Issue
Block a user