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:
Linus Rath
2026-09-25 18:37:41 +02:00
parent 92de8e99bd
commit e712454ec2
41 changed files with 991 additions and 95 deletions
+6 -1
View File
@@ -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);
+54 -29
View File
@@ -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);
+42
View File
@@ -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();
});
});
+107 -10
View File
@@ -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();
}
});
});
+119 -5
View File
@@ -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);
});
});
+28
View File
@@ -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);
});
});
+40
View File
@@ -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
View File
@@ -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;
}
+2
View File
@@ -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
View File
@@ -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');
+18
View File
@@ -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;
}
+22
View File
@@ -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;
}
+8
View File
@@ -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": "تم الحذف بنجاح",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "با موفقیت حذف شد",
+8
View File
@@ -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",
+8
View File
@@ -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": "נמחק בהצלחה",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "正常に削除しました",
+8
View File
@@ -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": "성공적으로 삭제되었어요",
+8
View File
@@ -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",
+8
View File
@@ -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": "Амжилттай устгасан",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "Успешно удалено",
+8
View File
@@ -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é",
+8
View File
@@ -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",
+8
View File
@@ -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": "Успішно видалено",
+8
View File
@@ -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": "刪除成功",
+8
View File
@@ -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": "删除成功",
+108
View File
@@ -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
View File
@@ -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) => {