fix: handle the file names and types Stalwart refuses

From 0.16.6 Stalwart refuses FileNode names with `/<>:"\|?*` or a
reserved name such as `CON` ("Name contains a forbidden character."),
and publishes the rules as `forbiddenNameChars` / `forbiddenNodeNames`.
Bulwark did not read them: the error showed up only after an upload had
started, and it aborted the rest of a multi-file or folder upload.

- new folder, new text file and rename now say which characters or
  names the server does not allow, before sending anything
- uploads store an accepted variant instead: forbidden characters
  become `_` and a reserved name gets `_` appended

MIME types over 30 characters (most Office formats) were always stored
as `application/octet-stream`, because Stalwart before 0.16.6 refuses
them. From 0.16.6 the limit is 255, so the real type is kept there.
This commit is contained in:
Linus Rath
2026-09-22 20:54:01 +02:00
parent c3225b3fa8
commit 4d3a3db90b
34 changed files with 194 additions and 17 deletions
+10
View File
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
import { RenameDialog } from "@/components/files/rename-dialog";
import { fileNameProblem } from "@/lib/file-name-rules";
import { FileUploadArea } from "@/components/files/file-upload-area";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
@@ -400,6 +401,12 @@ export function FileBrowser({
onShare,
}: FileBrowserProps) {
const t = useTranslations("files");
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
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");
}, [fileNameRules, t]);
const [showNewFolder, setShowNewFolder] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
@@ -2003,6 +2010,7 @@ export function FileBrowser({
setShowNewFolder(false);
}}
onCancel={() => setShowNewFolder(false)}
validate={validateFileName}
/>
)}
@@ -2017,6 +2025,7 @@ export function FileBrowser({
setShowNewTextFile(false);
}}
onCancel={() => setShowNewTextFile(false)}
validate={validateFileName}
/>
)}
@@ -2029,6 +2038,7 @@ export function FileBrowser({
setRenameTarget(null);
}}
onCancel={() => setRenameTarget(null)}
validate={validateFileName}
/>
)}
+9 -4
View File
@@ -8,17 +8,20 @@ import { Input } from "@/components/ui/input";
interface NewFolderDialogProps {
onConfirm: (name: string) => Promise<void>;
onCancel: () => void;
/** Returns an error message for a name the server would refuse. */
validate?: (name: string) => string | null;
}
export function NewFolderDialog({ onConfirm, onCancel }: NewFolderDialogProps) {
export function NewFolderDialog({ onConfirm, onCancel, validate }: NewFolderDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const nameError = name.trim() ? validate?.(name.trim()) ?? null : null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
if (!trimmed || validate?.(trimmed)) return;
setIsSubmitting(true);
try {
@@ -41,13 +44,15 @@ export function NewFolderDialog({ onConfirm, onCancel }: NewFolderDialogProps) {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("new_folder_name")}
className="mb-4"
className={nameError ? "mb-1" : "mb-4"}
aria-invalid={!!nameError}
/>
{nameError && <p className="text-xs text-destructive mb-3">{nameError}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
<Button type="submit" disabled={!name.trim() || !!nameError || isSubmitting}>
{t("create")}
</Button>
</div>
+9 -4
View File
@@ -11,17 +11,20 @@ interface RenameDialogProps {
label?: string;
onConfirm: (newName: string) => Promise<void>;
onCancel: () => void;
/** Returns an error message for a name the server would refuse. */
validate?: (name: string) => string | null;
}
export function RenameDialog({ currentName, title, label, onConfirm, onCancel }: RenameDialogProps) {
export function RenameDialog({ currentName, title, label, onConfirm, onCancel, validate }: RenameDialogProps) {
const t = useTranslations("files");
const [name, setName] = useState(currentName);
const [isSubmitting, setIsSubmitting] = useState(false);
const nameError = name.trim() ? validate?.(name.trim()) ?? null : null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
if (!trimmed || validate?.(trimmed)) return;
setIsSubmitting(true);
try {
@@ -44,13 +47,15 @@ export function RenameDialog({ currentName, title, label, onConfirm, onCancel }:
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={label || t("new_name")}
className="mb-4"
className={nameError ? "mb-1" : "mb-4"}
aria-invalid={!!nameError}
/>
{nameError && <p className="text-xs text-destructive mb-3">{nameError}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={!name.trim() || isSubmitting}>
<Button type="submit" disabled={!name.trim() || !!nameError || isSubmitting}>
{t("save")}
</Button>
</div>
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { acceptedFileName, fileNameProblem, fileNameRulesFrom } from '../file-name-rules';
// The filenode capability Stalwart 0.16.23 publishes.
const STALWART = fileNameRulesFrom({
maxSizeFileNodeName: 255,
forbiddenNameChars: '/<>:"\\|?*',
forbiddenNodeNames: ['.', '..', 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1'],
});
describe('file name rules', () => {
it('has no rules for servers that publish none (Stalwart before 0.16.6)', () => {
expect(fileNameRulesFrom({ maxSizeFileNodeName: 255 })).toBeNull();
expect(fileNameProblem('a:b', null)).toBeNull();
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('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');
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* 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.
*/
export interface FileNameRules {
forbiddenChars: string;
forbiddenNames: string[];
}
export type FileNameProblem = { kind: 'chars'; chars: string } | { kind: 'reserved' };
export function fileNameRulesFrom(capability: unknown): FileNameRules | null {
if (!capability || typeof capability !== 'object') return null;
const cap = capability as { forbiddenNameChars?: unknown; forbiddenNodeNames?: 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 };
}
export function fileNameProblem(name: string, rules: FileNameRules | null): FileNameProblem | null {
if (!rules) return null;
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' };
return null;
}
/**
* 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.
*/
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}_`;
return out;
}
+3
View File
@@ -1,5 +1,6 @@
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { FileNameRules } from "@/lib/file-name-rules";
import type { SortLevel } from "@/lib/message-list-order";
/** What `migrateKeyword` managed to do. */
@@ -58,6 +59,8 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown>;
hasAccountCapability(capability: string, accountId?: string): boolean;
/** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */
getFileNameRules?(accountId?: string): FileNameRules | null;
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
+34 -9
View File
@@ -15,6 +15,7 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { contactFromWire, contactToWire } from "./contact-wire";
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone";
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
@@ -4632,6 +4633,12 @@ export class JMAPClient implements IJMAPClient {
return capability in this.capabilities;
}
/** The value of an account capability (primary account by default). */
getAccountCapability(capability: string, accountId?: string): unknown {
const id = accountId || this.accountId;
return this.session?.accounts?.[id]?.accountCapabilities?.[capability];
}
/** Check whether a capability is present on the primary account. */
hasAccountCapability(capability: string, accountId?: string): boolean {
const id = accountId || this.accountId;
@@ -7492,12 +7499,7 @@ export class JMAPClient implements IJMAPClient {
: this.resolveFileNodeId(fileNodeId);
const accountId = node.accountId;
const rawId = node.id as string;
// `forbiddenNameChars` arrived with the draft (Stalwart 0.16.6) that split
// mayWrite into the finer rights; older servers reject those names.
const fileCapability = this.session?.accounts?.[accountId]?.accountCapabilities?.["urn:ietf:params:jmap:filenode"] as
Record<string, unknown> | undefined;
const legacyRights = !!fileCapability && !("forbiddenNameChars" in fileCapability);
const wireRights = rights && legacyRights ? toLegacyRights(rights) : rights;
const wireRights = rights && this.isLegacyFileNodeServer(accountId) ? toLegacyRights(rights) : rights;
const response = await this.request([
["FileNode/set", {
accountId,
@@ -7534,6 +7536,25 @@ export class JMAPClient implements IJMAPClient {
return { accountId: primary, id };
}
private fileNodeCapability(accountId: string): Record<string, unknown> | undefined {
return this.getAccountCapability("urn:ietf:params:jmap:filenode", accountId) as Record<string, unknown> | undefined;
}
/**
* Stalwart before 0.16.6 implements an older File Storage draft (old
* rights names, 30-character MIME types, no naming rules). The draft update
* added `forbiddenNameChars` to the capability, which tells them apart.
*/
private isLegacyFileNodeServer(accountId: string): boolean {
const cap = this.fileNodeCapability(accountId);
return !!cap && !("forbiddenNameChars" in cap);
}
/** Naming rules for new FileNodes in `accountId`, if the server publishes any. */
getFileNameRules(accountId?: string): FileNameRules | null {
return fileNameRulesFrom(this.fileNodeCapability(accountId || this.getFilesAccountId()));
}
/** Namespace a node created in `accountId` the way the listings do. */
private namespaceFileNode(node: FileNode, accountId: string): FileNode {
if (accountId === this.getFilesAccountId()) return node;
@@ -7566,7 +7587,9 @@ export class JMAPClient implements IJMAPClient {
props: Record<string, unknown>,
what: string,
): Promise<FileNode> {
const baseName = String(props.name ?? '');
// Names from the local file system (uploads) may hold characters the
// server refuses; store an accepted variant instead of failing the batch.
const baseName = acceptedFileName(String(props.name ?? ''), this.getFileNameRules(accountId));
for (let attempt = 1; ; attempt++) {
const name = attempt === 1 ? baseName : numberedFileName(baseName, attempt);
const response = await this.request(
@@ -7610,8 +7633,10 @@ 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);
//fall back for long MIME types
const safeType = type.length > 30 ? 'application/octet-stream' : type;
// 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, size };
if (parent.id !== null) {
fileProps.parentId = parent.id;
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "إعادة تسمية",
"rename_title": "إعادة تسمية",
"new_name": "الاسم الجديد",
"name_forbidden_chars": "لا يسمح الخادم بهذه الأحرف في الأسماء: {chars}",
"name_reserved": "هذا الاسم محجوز على الخادم.",
"delete": "حذف",
"delete_confirm_title": "حذف المورد",
"delete_confirm_message": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ لا يمكن التراجع عن هذا.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Canvia el nom",
"rename_title": "Canvia el nom",
"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.",
"delete": "Suprimeix",
"delete_confirm_title": "Suprimeix el recurs",
"delete_confirm_message": "Segur que voleu suprimir «{name}»? Això no es pot desfer.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Přejmenovat",
"rename_title": "Přejmenovat",
"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.",
"delete": "Odstranit",
"delete_confirm_title": "Odstranit položku",
"delete_confirm_message": "Opravdu chcete odstranit \"{name}\"? Tuto akci nelze vrátit zpět.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Omdøb",
"rename_title": "Omdøb",
"new_name": "Nyt navn",
"name_forbidden_chars": "Serveren tillader ikke disse tegn i navne: {chars}",
"name_reserved": "Dette navn er reserveret på serveren.",
"delete": "Slet",
"delete_confirm_title": "Slet ressource",
"delete_confirm_message": "Er du sikker på, at du vil slette \"{name}\"? Dette kan ikke fortrydes.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Umbenennen",
"rename_title": "Umbenennen",
"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.",
"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.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Rename",
"rename_title": "Rename",
"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.",
"delete": "Delete",
"delete_confirm_title": "Delete resource",
"delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Renombrar",
"rename_title": "Renombrar",
"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.",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar recurso",
"delete_confirm_message": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "تغییر نام",
"rename_title": "تغییر نام",
"new_name": "نام جدید",
"name_forbidden_chars": "سرور این نویسه‌ها را در نام‌ها مجاز نمی‌داند: {chars}",
"name_reserved": "این نام در سرور رزرو شده است.",
"delete": "حذف",
"delete_confirm_title": "حذف منبع",
"delete_confirm_message": "آیا از حذف \"{name}\" اطمینان دارید؟ این عملیات قابل بازگشت نیست.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Renommer",
"rename_title": "Renommer",
"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.",
"delete": "Supprimer",
"delete_confirm_title": "Supprimer la ressource",
"delete_confirm_message": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
+2
View File
@@ -3206,6 +3206,8 @@
"rename": "שנה שם",
"rename_title": "שנה שם",
"new_name": "שם חדש",
"name_forbidden_chars": "השרת אינו מתיר את התווים האלה בשמות: {chars}",
"name_reserved": "השם הזה שמור בשרת.",
"delete": "לִמְחוֹק",
"delete_confirm_title": "מחק משאב",
"delete_confirm_message": "האם אתה בטוח שברצונך למחוק את \"{name}\"? לא ניתן לבטל זאת.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Átnevezés",
"rename_title": "Átnevezés",
"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.",
"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.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Rinomina",
"rename_title": "Rinomina",
"new_name": "Nuovo nome",
"name_forbidden_chars": "Il server non consente questi caratteri nei nomi: {chars}",
"name_reserved": "Questo nome è riservato sul server.",
"delete": "Elimina",
"delete_confirm_title": "Elimina risorsa",
"delete_confirm_message": "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "名前を変更",
"rename_title": "名前を変更",
"new_name": "新しい名前",
"name_forbidden_chars": "サーバーでは名前に次の文字を使用できません: {chars}",
"name_reserved": "この名前はサーバーで予約されています。",
"delete": "削除",
"delete_confirm_title": "リソースを削除",
"delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "이름 바꾸기",
"rename_title": "이름 바꾸기",
"new_name": "새 이름",
"name_forbidden_chars": "서버에서 이름에 다음 문자를 허용하지 않습니다: {chars}",
"name_reserved": "이 이름은 서버에서 예약되어 있습니다.",
"delete": "삭제",
"delete_confirm_title": "항목 삭제",
"delete_confirm_message": "정말 \"{name}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없어요.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Pārdēvēt",
"rename_title": "Pārdēvēt",
"new_name": "Jauns nosaukums",
"name_forbidden_chars": "Serveris neatļauj šīs rakstzīmes nosaukumos: {chars}",
"name_reserved": "Šis nosaukums serverī ir rezervēts.",
"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.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Нэрээ өөрчлөх",
"rename_title": "Нэрээ өөрчлөх",
"new_name": "Шинэ нэр",
"name_forbidden_chars": "Сервер нэрэнд эдгээр тэмдэгтийг зөвшөөрдөггүй: {chars}",
"name_reserved": "Энэ нэр сервер дээр нөөцлөгдсөн.",
"delete": "Устгах",
"delete_confirm_title": "Нөөцийг устгах",
"delete_confirm_message": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Үүнийг буцаах боломжгүй.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Gi nytt navn",
"rename_title": "Gi nytt navn",
"new_name": "Nytt navn",
"name_forbidden_chars": "Serveren tillater ikke disse tegnene i navn: {chars}",
"name_reserved": "Dette navnet er reservert på 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.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Hernoemen",
"rename_title": "Hernoemen",
"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.",
"delete": "Verwijderen",
"delete_confirm_title": "Bron verwijderen",
"delete_confirm_message": "Weet u zeker dat u \"{name}\" wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Zmień nazwę",
"rename_title": "Zmień nazwę",
"new_name": "Nowa nazwa",
"name_forbidden_chars": "Serwer nie zezwala na te znaki w nazwach: {chars}",
"name_reserved": "Ta nazwa jest zarezerwowana na serwerze.",
"delete": "Usuń",
"delete_confirm_title": "Usuń zasób",
"delete_confirm_message": "Czy na pewno chcesz usunąć \"{name}\"? Tej operacji nie można cofnąć.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Renomear",
"rename_title": "Renomear",
"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.",
"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.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Redenumire",
"rename_title": "Redenumire",
"new_name": "Nume nou",
"name_forbidden_chars": "Serverul nu permite aceste caractere în nume: {chars}",
"name_reserved": "Acest nume este rezervat pe 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ă.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Переименовать",
"rename_title": "Переименовать",
"new_name": "Новое название",
"name_forbidden_chars": "Сервер не допускает эти символы в именах: {chars}",
"name_reserved": "Это имя зарезервировано на сервере.",
"delete": "Удалить",
"delete_confirm_title": "Удалить ресурс",
"delete_confirm_message": "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Premenovať",
"rename_title": "Premenovať",
"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ý.",
"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ť.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "Yeniden Adlandır",
"rename_title": "Yeniden Adlandır",
"new_name": "Yeni ad",
"name_forbidden_chars": "Sunucu adlarda şu karakterlere izin vermiyor: {chars}",
"name_reserved": "Bu ad sunucuda ayrılmış.",
"delete": "Sil",
"delete_confirm_title": "Kaynağı sil",
"delete_confirm_message": "\"{name}\" silinsin mi? Bu geri alınamaz.",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "Перейменувати",
"rename_title": "Перейменувати",
"new_name": "Нова назва",
"name_forbidden_chars": "Сервер не дозволяє ці символи в назвах: {chars}",
"name_reserved": "Ця назва зарезервована на сервері.",
"delete": "Видалити",
"delete_confirm_title": "Видалити ресурс",
"delete_confirm_message": "Ви впевнені, що хочете видалити \"{name}\"? Це неможливо скасувати.",
+2
View File
@@ -3325,6 +3325,8 @@
"rename": "重新命名",
"rename_title": "重新命名",
"new_name": "新名稱",
"name_forbidden_chars": "伺服器不允許名稱中包含這些字元:{chars}",
"name_reserved": "此名稱已被伺服器保留。",
"delete": "刪除",
"delete_confirm_title": "刪除項目",
"delete_confirm_message": "您確定要刪除「{name}」嗎?此動作無法復原。",
+2
View File
@@ -3283,6 +3283,8 @@
"rename": "重命名",
"rename_title": "重命名",
"new_name": "新名称",
"name_forbidden_chars": "服务器不允许名称中包含这些字符:{chars}",
"name_reserved": "此名称已被服务器保留。",
"delete": "删除",
"delete_confirm_title": "删除资源",
"delete_confirm_message": "您确定要删除\"{name}\"吗?此操作无法撤消。",