mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -3325,6 +3325,8 @@
|
||||
"rename": "إعادة تسمية",
|
||||
"rename_title": "إعادة تسمية",
|
||||
"new_name": "الاسم الجديد",
|
||||
"name_forbidden_chars": "لا يسمح الخادم بهذه الأحرف في الأسماء: {chars}",
|
||||
"name_reserved": "هذا الاسم محجوز على الخادم.",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف المورد",
|
||||
"delete_confirm_message": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ لا يمكن التراجع عن هذا.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3325,6 +3325,8 @@
|
||||
"rename": "تغییر نام",
|
||||
"rename_title": "تغییر نام",
|
||||
"new_name": "نام جدید",
|
||||
"name_forbidden_chars": "سرور این نویسهها را در نامها مجاز نمیداند: {chars}",
|
||||
"name_reserved": "این نام در سرور رزرو شده است.",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف منبع",
|
||||
"delete_confirm_message": "آیا از حذف \"{name}\" اطمینان دارید؟ این عملیات قابل بازگشت نیست.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3206,6 +3206,8 @@
|
||||
"rename": "שנה שם",
|
||||
"rename_title": "שנה שם",
|
||||
"new_name": "שם חדש",
|
||||
"name_forbidden_chars": "השרת אינו מתיר את התווים האלה בשמות: {chars}",
|
||||
"name_reserved": "השם הזה שמור בשרת.",
|
||||
"delete": "לִמְחוֹק",
|
||||
"delete_confirm_title": "מחק משאב",
|
||||
"delete_confirm_message": "האם אתה בטוח שברצונך למחוק את \"{name}\"? לא ניתן לבטל זאת.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3283,6 +3283,8 @@
|
||||
"rename": "名前を変更",
|
||||
"rename_title": "名前を変更",
|
||||
"new_name": "新しい名前",
|
||||
"name_forbidden_chars": "サーバーでは名前に次の文字を使用できません: {chars}",
|
||||
"name_reserved": "この名前はサーバーで予約されています。",
|
||||
"delete": "削除",
|
||||
"delete_confirm_title": "リソースを削除",
|
||||
"delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
|
||||
@@ -3283,6 +3283,8 @@
|
||||
"rename": "이름 바꾸기",
|
||||
"rename_title": "이름 바꾸기",
|
||||
"new_name": "새 이름",
|
||||
"name_forbidden_chars": "서버에서 이름에 다음 문자를 허용하지 않습니다: {chars}",
|
||||
"name_reserved": "이 이름은 서버에서 예약되어 있습니다.",
|
||||
"delete": "삭제",
|
||||
"delete_confirm_title": "항목 삭제",
|
||||
"delete_confirm_message": "정말 \"{name}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없어요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3325,6 +3325,8 @@
|
||||
"rename": "Нэрээ өөрчлөх",
|
||||
"rename_title": "Нэрээ өөрчлөх",
|
||||
"new_name": "Шинэ нэр",
|
||||
"name_forbidden_chars": "Сервер нэрэнд эдгээр тэмдэгтийг зөвшөөрдөггүй: {chars}",
|
||||
"name_reserved": "Энэ нэр сервер дээр нөөцлөгдсөн.",
|
||||
"delete": "Устгах",
|
||||
"delete_confirm_title": "Нөөцийг устгах",
|
||||
"delete_confirm_message": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Үүнийг буцаах боломжгүй.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ąć.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ă.",
|
||||
|
||||
@@ -3283,6 +3283,8 @@
|
||||
"rename": "Переименовать",
|
||||
"rename_title": "Переименовать",
|
||||
"new_name": "Новое название",
|
||||
"name_forbidden_chars": "Сервер не допускает эти символы в именах: {chars}",
|
||||
"name_reserved": "Это имя зарезервировано на сервере.",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm_title": "Удалить ресурс",
|
||||
"delete_confirm_message": "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.",
|
||||
|
||||
@@ -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ť.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -3283,6 +3283,8 @@
|
||||
"rename": "Перейменувати",
|
||||
"rename_title": "Перейменувати",
|
||||
"new_name": "Нова назва",
|
||||
"name_forbidden_chars": "Сервер не дозволяє ці символи в назвах: {chars}",
|
||||
"name_reserved": "Ця назва зарезервована на сервері.",
|
||||
"delete": "Видалити",
|
||||
"delete_confirm_title": "Видалити ресурс",
|
||||
"delete_confirm_message": "Ви впевнені, що хочете видалити \"{name}\"? Це неможливо скасувати.",
|
||||
|
||||
@@ -3325,6 +3325,8 @@
|
||||
"rename": "重新命名",
|
||||
"rename_title": "重新命名",
|
||||
"new_name": "新名稱",
|
||||
"name_forbidden_chars": "伺服器不允許名稱中包含這些字元:{chars}",
|
||||
"name_reserved": "此名稱已被伺服器保留。",
|
||||
"delete": "刪除",
|
||||
"delete_confirm_title": "刪除項目",
|
||||
"delete_confirm_message": "您確定要刪除「{name}」嗎?此動作無法復原。",
|
||||
|
||||
@@ -3283,6 +3283,8 @@
|
||||
"rename": "重命名",
|
||||
"rename_title": "重命名",
|
||||
"new_name": "新名称",
|
||||
"name_forbidden_chars": "服务器不允许名称中包含这些字符:{chars}",
|
||||
"name_reserved": "此名称已被服务器保留。",
|
||||
"delete": "删除",
|
||||
"delete_confirm_title": "删除资源",
|
||||
"delete_confirm_message": "您确定要删除\"{name}\"吗?此操作无法撤消。",
|
||||
|
||||
Reference in New Issue
Block a user