mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: save an attachment to Files from the message
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { SaveToFilesDialog } from '../save-to-files-dialog';
|
||||
|
||||
// "Save to Files" files an attachment in a folder of the Files app by its
|
||||
// blobId: no download, no upload.
|
||||
|
||||
const folder = (id: string, name: string, parentId: string | null = null) =>
|
||||
({ id, name, parentId, blobId: null, type: 'd', size: 0, created: '', modified: '' }) as FileNode;
|
||||
const nodes = [folder('docs', 'Documents'), folder('invoices', 'Invoices', 'docs'), folder('hidden', '.trash')];
|
||||
const attachment = { name: 'invoice.pdf', type: 'application/pdf', size: 1234, blobId: 'blob-1' };
|
||||
|
||||
describe('SaveToFilesDialog', () => {
|
||||
it('saves the attachment into the picked folder by its blob', async () => {
|
||||
const createFileNode = vi.fn().mockResolvedValue({});
|
||||
const client = { listAllFileNodes: vi.fn().mockResolvedValue(nodes), createFileNode } as unknown as IJMAPClient;
|
||||
const onClose = vi.fn();
|
||||
render(<SaveToFilesDialog attachment={attachment} client={client} onClose={onClose} />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Documents'));
|
||||
expect(screen.queryByText('.trash')).toBeNull();
|
||||
fireEvent.click(await screen.findByText('Invoices'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save_here' }));
|
||||
|
||||
await waitFor(() => expect(createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-1', 'application/pdf', 1234, 'invoices'));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(useToastStore.getState().toasts.some((toast) => toast.title === 'saved')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
|
||||
import { ViewSourceDialog } from "./view-source-dialog";
|
||||
import { MoveTargetList } from "./move-target-list";
|
||||
import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { ScheduleSendDialog } from "./schedule-send-dialog";
|
||||
import {
|
||||
Reply,
|
||||
@@ -1268,6 +1270,9 @@ export function EmailViewer({
|
||||
const legacyBlobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
const blobClient = blobSource ? blobSource.client : legacyBlobClient;
|
||||
const blobAccountId = blobSource ? blobSource.accountId : legacyBlobAccountId;
|
||||
const filesFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const canSaveToFiles = filesFeatureEnabled && !!client && blobClient === client
|
||||
&& !!client.supportsFiles?.() && (!blobAccountId || blobAccountId === client.getAccountId());
|
||||
|
||||
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
|
||||
// client, handing a mailto: URL to the OS mail handler goes nowhere for
|
||||
@@ -1371,6 +1376,9 @@ export function EmailViewer({
|
||||
};
|
||||
const [showSourceModal, setShowSourceModal] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
// "Save to Files" for an attachment: where the Files app is on and the
|
||||
// message is in the login's own account, whose files hold the blob too.
|
||||
const [saveToFilesTarget, setSaveToFilesTarget] = useState<SaveableAttachment | null>(null);
|
||||
// Reschedule picks the new time in the composer's Schedule send dialog.
|
||||
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||
// The mobile More panel sits off-canvas with a slide transition. Until
|
||||
@@ -5103,6 +5111,11 @@ export function EmailViewer({
|
||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{canSaveToFiles && attachment.blobId && (
|
||||
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -5165,6 +5178,11 @@ export function EmailViewer({
|
||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{canSaveToFiles && attachment.blobId && (
|
||||
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -5927,6 +5945,11 @@ export function EmailViewer({
|
||||
<Eye className="w-4 h-4 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{canSaveToFiles && attachment.blobId && (
|
||||
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -5989,6 +6012,11 @@ export function EmailViewer({
|
||||
<Eye className="w-4 h-4 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{canSaveToFiles && attachment.blobId && (
|
||||
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -6052,6 +6080,11 @@ export function EmailViewer({
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
{/* Phones have no hover: the plugin menu stays visible. */}
|
||||
{canSaveToFiles && attachment.blobId && (
|
||||
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className={cn(
|
||||
@@ -6117,6 +6150,11 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
{canSaveToFiles && attachment.blobId && (
|
||||
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
@@ -6358,6 +6396,10 @@ export function EmailViewer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{canSaveToFiles && client && (
|
||||
<SaveToFilesDialog attachment={saveToFilesTarget} client={client} onClose={() => setSaveToFilesTarget(null)} />
|
||||
)}
|
||||
|
||||
{/* View source: the raw message the server holds. */}
|
||||
<ViewSourceDialog
|
||||
isOpen={showSourceModal && !!email}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronRight, Folder, FolderInput, Home } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { PaneSpinner } from "@/components/ui/spinner";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { fileNodeKind } from "@/lib/jmap/filenode-kind";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
|
||||
/** What Save to Files needs of an attachment: its blob, name, type and size. */
|
||||
export interface SaveableAttachment {
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
size: number;
|
||||
blobId?: string | null;
|
||||
}
|
||||
|
||||
const isFolder = (node: FileNode) => fileNodeKind(node) === "directory";
|
||||
|
||||
interface SaveToFilesDialogProps {
|
||||
/** The attachment to save; the dialog is open while it is set. */
|
||||
attachment: SaveableAttachment | null;
|
||||
/** The mail login's client: the attachment's blob is in its own account, as are its files. */
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Save to Files" for an attachment: pick a folder of the Files app. The
|
||||
* attachment's blob is already on the server, in the same account as the
|
||||
* files, so it is filed by blobId with no download or upload.
|
||||
*/
|
||||
export function SaveToFilesDialog({ attachment, client, onClose }: SaveToFilesDialogProps) {
|
||||
const t = useTranslations("email_viewer.save_to_files");
|
||||
const tCommon = useTranslations("common");
|
||||
const [nodes, setNodes] = useState<FileNode[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [trail, setTrail] = useState<{ id: string; name: string }[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isOpen = !!attachment;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
setNodes(null);
|
||||
setFailed(false);
|
||||
setTrail([]);
|
||||
client.listAllFileNodes().then(
|
||||
(list) => { if (!cancelled) setNodes(list); },
|
||||
(error) => {
|
||||
console.error("Failed to list folders for Save to Files:", error);
|
||||
if (!cancelled) setFailed(true);
|
||||
},
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen, client]);
|
||||
|
||||
const parentId = trail.length > 0 ? trail[trail.length - 1].id : null;
|
||||
const folders = useMemo(
|
||||
() => (nodes ?? [])
|
||||
.filter((n) => isFolder(n) && (n.parentId ?? null) === parentId && !n.name.startsWith("."))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[nodes, parentId],
|
||||
);
|
||||
const here = trail.length > 0 ? trail[trail.length - 1].name : t("root");
|
||||
|
||||
const save = async () => {
|
||||
if (!attachment?.blobId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await client.createFileNode(
|
||||
attachment.name || "attachment",
|
||||
attachment.blobId,
|
||||
attachment.type || "application/octet-stream",
|
||||
attachment.size,
|
||||
parentId,
|
||||
);
|
||||
toast.success(t("saved"), t("saved_to", { folder: here }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save the attachment to Files:", error);
|
||||
toast.error(t("failed"), error instanceof Error ? error.message : undefined);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t("title")}
|
||||
description={attachment?.name}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>{tCommon("cancel")}</Button>
|
||||
<Button onClick={() => void save()} disabled={saving || !nodes}>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
{t("save_here")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<nav aria-label={t("location")} className="mt-3 flex items-center gap-1 text-sm min-w-0 flex-wrap">
|
||||
<IconButton label={t("root")} onClick={() => setTrail([])}>
|
||||
<Home />
|
||||
</IconButton>
|
||||
{trail.map((crumb, i) => (
|
||||
<span key={crumb.id} className="flex items-center gap-1 min-w-0">
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate rounded px-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setTrail(trail.slice(0, i + 1))}
|
||||
title={crumb.name}
|
||||
dir="auto"
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-2 border border-border rounded-md min-h-40 max-h-72 overflow-y-auto">
|
||||
{failed ? (
|
||||
<p role="alert" className="m-3 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{t("load_failed")}</p>
|
||||
) : !nodes ? (
|
||||
<PaneSpinner className="min-h-40" />
|
||||
) : folders.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">{t("no_folders")}</p>
|
||||
) : (
|
||||
<ul>
|
||||
{folders.map((folder) => (
|
||||
<li key={folder.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 max-md:min-h-11 text-sm text-start hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
onClick={() => setTrail([...trail, { id: folder.id, name: folder.name }])}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className="truncate flex-1" dir="auto" title={folder.name}>{folder.name}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "إظهار لوحة الإضافة",
|
||||
"plugin_panel_hide": "إخفاء لوحة الإضافة",
|
||||
"copy": "نسخ",
|
||||
"contact_added": "أُضيفت جهة الاتصال"
|
||||
"contact_added": "أُضيفت جهة الاتصال",
|
||||
"save_to_files": {
|
||||
"title": "حفظ في الملفات",
|
||||
"location": "المجلد",
|
||||
"root": "ملفاتي",
|
||||
"save_here": "حفظ هنا",
|
||||
"saved": "حُفظ في الملفات",
|
||||
"saved_to": "في {folder}",
|
||||
"failed": "تعذّر الحفظ في الملفات",
|
||||
"load_failed": "تعذّر تحميل مجلداتك. حاول مرة أخرى.",
|
||||
"no_folders": "لا توجد مجلدات هنا. احفظ هنا أو ارجع للخلف."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "الاختيار من الدليل",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Mostra el tauler del connector",
|
||||
"plugin_panel_hide": "Amaga el tauler del connector",
|
||||
"copy": "Copia",
|
||||
"contact_added": "S'ha afegit el contacte"
|
||||
"contact_added": "S'ha afegit el contacte",
|
||||
"save_to_files": {
|
||||
"title": "Desa a Fitxers",
|
||||
"location": "Carpeta",
|
||||
"root": "Els meus fitxers",
|
||||
"save_here": "Desa aquí",
|
||||
"saved": "S'ha desat a Fitxers",
|
||||
"saved_to": "A {folder}",
|
||||
"failed": "No s'ha pogut desar a Fitxers",
|
||||
"load_failed": "No s'han pogut carregar les carpetes. Torna-ho a provar.",
|
||||
"no_folders": "Aquí no hi ha carpetes. Desa-ho aquí o torna enrere."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Selecciona del directori",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Zobrazit panel pluginu",
|
||||
"plugin_panel_hide": "Skrýt panel pluginu",
|
||||
"copy": "Kopírovat",
|
||||
"contact_added": "Kontakt přidán"
|
||||
"contact_added": "Kontakt přidán",
|
||||
"save_to_files": {
|
||||
"title": "Uložit do Souborů",
|
||||
"location": "Složka",
|
||||
"root": "Moje soubory",
|
||||
"save_here": "Uložit sem",
|
||||
"saved": "Uloženo do Souborů",
|
||||
"saved_to": "Do složky {folder}",
|
||||
"failed": "Nepodařilo se uložit do Souborů",
|
||||
"load_failed": "Složky se nepodařilo načíst. Zkuste to znovu.",
|
||||
"no_folders": "Nejsou tu žádné složky. Uložte sem nebo se vraťte."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Vybrat z adresáře",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Vis pluginpanel",
|
||||
"plugin_panel_hide": "Skjul pluginpanel",
|
||||
"copy": "Kopiér",
|
||||
"contact_added": "Kontakt tilføjet"
|
||||
"contact_added": "Kontakt tilføjet",
|
||||
"save_to_files": {
|
||||
"title": "Gem i Filer",
|
||||
"location": "Mappe",
|
||||
"root": "Mine filer",
|
||||
"save_here": "Gem her",
|
||||
"saved": "Gemt i Filer",
|
||||
"saved_to": "I {folder}",
|
||||
"failed": "Kunne ikke gemme i Filer",
|
||||
"load_failed": "Mapperne kunne ikke indlæses. Prøv igen.",
|
||||
"no_folders": "Der er ingen mapper her. Gem her, eller gå tilbage."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Vælg fra adressefortegnelse",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Plugin-Bereich einblenden",
|
||||
"plugin_panel_hide": "Plugin-Bereich ausblenden",
|
||||
"copy": "Kopieren",
|
||||
"contact_added": "Kontakt hinzugefügt"
|
||||
"contact_added": "Kontakt hinzugefügt",
|
||||
"save_to_files": {
|
||||
"title": "In Dateien speichern",
|
||||
"location": "Ordner",
|
||||
"root": "Meine Dateien",
|
||||
"save_here": "Hier speichern",
|
||||
"saved": "In Dateien gespeichert",
|
||||
"saved_to": "In {folder}",
|
||||
"failed": "Konnte nicht in Dateien gespeichert werden",
|
||||
"load_failed": "Ihre Ordner konnten nicht geladen werden. Versuchen Sie es erneut.",
|
||||
"no_folders": "Hier gibt es keine Ordner. Speichern Sie hier oder gehen Sie zurück."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Aus Verzeichnis auswählen",
|
||||
|
||||
@@ -697,6 +697,17 @@
|
||||
"mv_position": "{index} of {total}",
|
||||
"copy": "Copy",
|
||||
"contact_added": "Contact added",
|
||||
"save_to_files": {
|
||||
"title": "Save to Files",
|
||||
"location": "Folder",
|
||||
"root": "My files",
|
||||
"save_here": "Save here",
|
||||
"saved": "Saved to Files",
|
||||
"saved_to": "In {folder}",
|
||||
"failed": "Couldn't save to Files",
|
||||
"load_failed": "Couldn't load your folders. Try again.",
|
||||
"no_folders": "No folders here. Save here or go back."
|
||||
},
|
||||
"plugin_panel_show": "Show plugin panel",
|
||||
"plugin_panel_hide": "Hide plugin panel"
|
||||
},
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Mostrar el panel del complemento",
|
||||
"plugin_panel_hide": "Ocultar el panel del complemento",
|
||||
"copy": "Copiar",
|
||||
"contact_added": "Contacto añadido"
|
||||
"contact_added": "Contacto añadido",
|
||||
"save_to_files": {
|
||||
"title": "Guardar en Archivos",
|
||||
"location": "Carpeta",
|
||||
"root": "Mis archivos",
|
||||
"save_here": "Guardar aquí",
|
||||
"saved": "Guardado en Archivos",
|
||||
"saved_to": "En {folder}",
|
||||
"failed": "No se pudo guardar en Archivos",
|
||||
"load_failed": "No se pudieron cargar tus carpetas. Vuelve a intentarlo.",
|
||||
"no_folders": "Aquí no hay carpetas. Guarda aquí o vuelve atrás."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Seleccionar del directorio",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Näita pistikprogrammi paneeli",
|
||||
"plugin_panel_hide": "Peida pistikprogrammi paneel",
|
||||
"copy": "Kopeeri",
|
||||
"contact_added": "Kontakt lisatud"
|
||||
"contact_added": "Kontakt lisatud",
|
||||
"save_to_files": {
|
||||
"title": "Salvesta failidesse",
|
||||
"location": "Kaust",
|
||||
"root": "Minu failid",
|
||||
"save_here": "Salvesta siia",
|
||||
"saved": "Salvestatud failidesse",
|
||||
"saved_to": "Kausta {folder}",
|
||||
"failed": "Failidesse salvestamine ei õnnestunud",
|
||||
"load_failed": "Kaustu ei õnnestunud laadida. Proovige uuesti.",
|
||||
"no_folders": "Siin pole kaustu. Salvestage siia või minge tagasi."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Vali kataloogist",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "نمایش پنل افزونه",
|
||||
"plugin_panel_hide": "پنهان کردن پنل افزونه",
|
||||
"copy": "کپی",
|
||||
"contact_added": "مخاطب افزوده شد"
|
||||
"contact_added": "مخاطب افزوده شد",
|
||||
"save_to_files": {
|
||||
"title": "ذخیره در فایلها",
|
||||
"location": "پوشه",
|
||||
"root": "فایلهای من",
|
||||
"save_here": "ذخیره در اینجا",
|
||||
"saved": "در فایلها ذخیره شد",
|
||||
"saved_to": "در {folder}",
|
||||
"failed": "ذخیره در فایلها ممکن نشد",
|
||||
"load_failed": "پوشهها بارگذاری نشدند. دوباره امتحان کنید.",
|
||||
"no_folders": "اینجا پوشهای نیست. همینجا ذخیره کنید یا برگردید."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "انتخاب از فهرست",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Afficher le panneau du plugin",
|
||||
"plugin_panel_hide": "Masquer le panneau du plugin",
|
||||
"copy": "Copier",
|
||||
"contact_added": "Contact ajouté"
|
||||
"contact_added": "Contact ajouté",
|
||||
"save_to_files": {
|
||||
"title": "Enregistrer dans Fichiers",
|
||||
"location": "Dossier",
|
||||
"root": "Mes fichiers",
|
||||
"save_here": "Enregistrer ici",
|
||||
"saved": "Enregistré dans Fichiers",
|
||||
"saved_to": "Dans {folder}",
|
||||
"failed": "Impossible d'enregistrer dans Fichiers",
|
||||
"load_failed": "Impossible de charger vos dossiers. Réessayez.",
|
||||
"no_folders": "Aucun dossier ici. Enregistrez ici ou revenez en arrière."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Choisir dans l'annuaire",
|
||||
|
||||
+12
-1
@@ -663,7 +663,18 @@
|
||||
"plugin_panel_show": "הצגת חלונית התוסף",
|
||||
"plugin_panel_hide": "הסתרת חלונית התוסף",
|
||||
"copy": "העתק",
|
||||
"contact_added": "איש הקשר נוסף"
|
||||
"contact_added": "איש הקשר נוסף",
|
||||
"save_to_files": {
|
||||
"title": "שמירה בקבצים",
|
||||
"location": "תיקייה",
|
||||
"root": "הקבצים שלי",
|
||||
"save_here": "שמירה כאן",
|
||||
"saved": "נשמר בקבצים",
|
||||
"saved_to": "ב{folder}",
|
||||
"failed": "לא ניתן היה לשמור בקבצים",
|
||||
"load_failed": "לא ניתן היה לטעון את התיקיות. נסו שוב.",
|
||||
"no_folders": "אין כאן תיקיות. שמרו כאן או חזרו אחורה."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "בחירה מהספרייה",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Bővítménypanel megjelenítése",
|
||||
"plugin_panel_hide": "Bővítménypanel elrejtése",
|
||||
"copy": "Másolás",
|
||||
"contact_added": "Névjegy hozzáadva"
|
||||
"contact_added": "Névjegy hozzáadva",
|
||||
"save_to_files": {
|
||||
"title": "Mentés a Fájlokba",
|
||||
"location": "Mappa",
|
||||
"root": "Saját fájlok",
|
||||
"save_here": "Mentés ide",
|
||||
"saved": "Mentve a Fájlokba",
|
||||
"saved_to": "Ide: {folder}",
|
||||
"failed": "Nem sikerült menteni a Fájlokba",
|
||||
"load_failed": "Nem sikerült betölteni a mappákat. Próbálja újra.",
|
||||
"no_folders": "Itt nincsenek mappák. Mentsen ide, vagy lépjen vissza."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Kiválasztás a címtárból",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Mostra il pannello del plugin",
|
||||
"plugin_panel_hide": "Nascondi il pannello del plugin",
|
||||
"copy": "Copia",
|
||||
"contact_added": "Contatto aggiunto"
|
||||
"contact_added": "Contatto aggiunto",
|
||||
"save_to_files": {
|
||||
"title": "Salva in File",
|
||||
"location": "Cartella",
|
||||
"root": "I miei file",
|
||||
"save_here": "Salva qui",
|
||||
"saved": "Salvato in File",
|
||||
"saved_to": "In {folder}",
|
||||
"failed": "Impossibile salvare in File",
|
||||
"load_failed": "Impossibile caricare le cartelle. Riprova.",
|
||||
"no_folders": "Nessuna cartella qui. Salva qui o torna indietro."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Seleziona dalla rubrica aziendale",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "プラグインパネルを表示",
|
||||
"plugin_panel_hide": "プラグインパネルを隠す",
|
||||
"copy": "コピー",
|
||||
"contact_added": "連絡先を追加しました"
|
||||
"contact_added": "連絡先を追加しました",
|
||||
"save_to_files": {
|
||||
"title": "ファイルに保存",
|
||||
"location": "フォルダ",
|
||||
"root": "マイファイル",
|
||||
"save_here": "ここに保存",
|
||||
"saved": "ファイルに保存しました",
|
||||
"saved_to": "{folder} に保存",
|
||||
"failed": "ファイルに保存できませんでした",
|
||||
"load_failed": "フォルダを読み込めませんでした。もう一度お試しください。",
|
||||
"no_folders": "ここにはフォルダがありません。ここに保存するか、戻ってください。"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "ディレクトリから選択",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "플러그인 패널 표시",
|
||||
"plugin_panel_hide": "플러그인 패널 숨기기",
|
||||
"copy": "복사",
|
||||
"contact_added": "연락처를 추가했어요"
|
||||
"contact_added": "연락처를 추가했어요",
|
||||
"save_to_files": {
|
||||
"title": "파일에 저장",
|
||||
"location": "폴더",
|
||||
"root": "내 파일",
|
||||
"save_here": "여기에 저장",
|
||||
"saved": "파일에 저장했어요",
|
||||
"saved_to": "{folder}에 저장",
|
||||
"failed": "파일에 저장하지 못했어요",
|
||||
"load_failed": "폴더를 불러오지 못했어요. 다시 시도하세요.",
|
||||
"no_folders": "여기에는 폴더가 없어요. 여기에 저장하거나 뒤로 가세요."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "디렉터리에서 선택",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Rādīt spraudņa paneli",
|
||||
"plugin_panel_hide": "Slēpt spraudņa paneli",
|
||||
"copy": "Kopēt",
|
||||
"contact_added": "Kontakts pievienots"
|
||||
"contact_added": "Kontakts pievienots",
|
||||
"save_to_files": {
|
||||
"title": "Saglabāt failos",
|
||||
"location": "Mape",
|
||||
"root": "Mani faili",
|
||||
"save_here": "Saglabāt šeit",
|
||||
"saved": "Saglabāts failos",
|
||||
"saved_to": "Mapē {folder}",
|
||||
"failed": "Neizdevās saglabāt failos",
|
||||
"load_failed": "Neizdevās ielādēt mapes. Mēģiniet vēlreiz.",
|
||||
"no_folders": "Šeit nav mapju. Saglabājiet šeit vai atgriezieties."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Izvēlēties no kataloga",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Залгаасын самбарыг харуулах",
|
||||
"plugin_panel_hide": "Залгаасын самбарыг нуух",
|
||||
"copy": "Хуулах",
|
||||
"contact_added": "Харилцагч нэмэгдлээ"
|
||||
"contact_added": "Харилцагч нэмэгдлээ",
|
||||
"save_to_files": {
|
||||
"title": "Файлд хадгалах",
|
||||
"location": "Хавтас",
|
||||
"root": "Миний файлууд",
|
||||
"save_here": "Энд хадгалах",
|
||||
"saved": "Файлд хадгаллаа",
|
||||
"saved_to": "{folder}-д",
|
||||
"failed": "Файлд хадгалж чадсангүй",
|
||||
"load_failed": "Хавтсуудыг ачаалж чадсангүй. Дахин оролдоно уу.",
|
||||
"no_folders": "Энд хавтас алга. Энд хадгалах эсвэл буцна уу."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Лавлахаас сонгох",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Vis tilleggspanel",
|
||||
"plugin_panel_hide": "Skjul tilleggspanel",
|
||||
"copy": "Kopier",
|
||||
"contact_added": "Kontakt lagt til"
|
||||
"contact_added": "Kontakt lagt til",
|
||||
"save_to_files": {
|
||||
"title": "Lagre i Filer",
|
||||
"location": "Mappe",
|
||||
"root": "Mine filer",
|
||||
"save_here": "Lagre her",
|
||||
"saved": "Lagret i Filer",
|
||||
"saved_to": "I {folder}",
|
||||
"failed": "Kunne ikke lagre i Filer",
|
||||
"load_failed": "Kunne ikke laste inn mappene. Prøv igjen.",
|
||||
"no_folders": "Ingen mapper her. Lagre her eller gå tilbake."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Velg fra katalog",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Plug-inpaneel tonen",
|
||||
"plugin_panel_hide": "Plug-inpaneel verbergen",
|
||||
"copy": "Kopiëren",
|
||||
"contact_added": "Contact toegevoegd"
|
||||
"contact_added": "Contact toegevoegd",
|
||||
"save_to_files": {
|
||||
"title": "Opslaan in Bestanden",
|
||||
"location": "Map",
|
||||
"root": "Mijn bestanden",
|
||||
"save_here": "Hier opslaan",
|
||||
"saved": "Opgeslagen in Bestanden",
|
||||
"saved_to": "In {folder}",
|
||||
"failed": "Kon niet opslaan in Bestanden",
|
||||
"load_failed": "Kon je mappen niet laden. Probeer het opnieuw.",
|
||||
"no_folders": "Hier zijn geen mappen. Sla hier op of ga terug."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Kiezen uit adresboek van de organisatie",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Pokaż panel wtyczki",
|
||||
"plugin_panel_hide": "Ukryj panel wtyczki",
|
||||
"copy": "Kopiuj",
|
||||
"contact_added": "Dodano kontakt"
|
||||
"contact_added": "Dodano kontakt",
|
||||
"save_to_files": {
|
||||
"title": "Zapisz w Plikach",
|
||||
"location": "Folder",
|
||||
"root": "Moje pliki",
|
||||
"save_here": "Zapisz tutaj",
|
||||
"saved": "Zapisano w Plikach",
|
||||
"saved_to": "W folderze {folder}",
|
||||
"failed": "Nie udało się zapisać w Plikach",
|
||||
"load_failed": "Nie udało się wczytać folderów. Spróbuj ponownie.",
|
||||
"no_folders": "Brak folderów. Zapisz tutaj lub wróć."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Wybierz z katalogu",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Mostrar o painel do plugin",
|
||||
"plugin_panel_hide": "Ocultar o painel do plugin",
|
||||
"copy": "Copiar",
|
||||
"contact_added": "Contacto adicionado"
|
||||
"contact_added": "Contacto adicionado",
|
||||
"save_to_files": {
|
||||
"title": "Guardar em Ficheiros",
|
||||
"location": "Pasta",
|
||||
"root": "Os meus ficheiros",
|
||||
"save_here": "Guardar aqui",
|
||||
"saved": "Guardado em Ficheiros",
|
||||
"saved_to": "Em {folder}",
|
||||
"failed": "Não foi possível guardar em Ficheiros",
|
||||
"load_failed": "Não foi possível carregar as pastas. Tente novamente.",
|
||||
"no_folders": "Não há pastas aqui. Guarde aqui ou volte atrás."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Selecionar do diretório",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Afișează panoul pluginului",
|
||||
"plugin_panel_hide": "Ascunde panoul pluginului",
|
||||
"copy": "Copiază",
|
||||
"contact_added": "Contact adăugat"
|
||||
"contact_added": "Contact adăugat",
|
||||
"save_to_files": {
|
||||
"title": "Salvează în Fișiere",
|
||||
"location": "Dosar",
|
||||
"root": "Fișierele mele",
|
||||
"save_here": "Salvează aici",
|
||||
"saved": "Salvat în Fișiere",
|
||||
"saved_to": "În {folder}",
|
||||
"failed": "Nu s-a putut salva în Fișiere",
|
||||
"load_failed": "Dosarele nu au putut fi încărcate. Încearcă din nou.",
|
||||
"no_folders": "Nu există dosare aici. Salvează aici sau întoarce-te."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Selectează din director",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Показать панель плагина",
|
||||
"plugin_panel_hide": "Скрыть панель плагина",
|
||||
"copy": "Копировать",
|
||||
"contact_added": "Контакт добавлен"
|
||||
"contact_added": "Контакт добавлен",
|
||||
"save_to_files": {
|
||||
"title": "Сохранить в «Файлы»",
|
||||
"location": "Папка",
|
||||
"root": "Мои файлы",
|
||||
"save_here": "Сохранить сюда",
|
||||
"saved": "Сохранено в «Файлы»",
|
||||
"saved_to": "В папку {folder}",
|
||||
"failed": "Не удалось сохранить в «Файлы»",
|
||||
"load_failed": "Не удалось загрузить папки. Повторите попытку.",
|
||||
"no_folders": "Здесь нет папок. Сохраните сюда или вернитесь назад."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Выбрать из справочника",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Zobraziť panel doplnku",
|
||||
"plugin_panel_hide": "Skryť panel doplnku",
|
||||
"copy": "Kopírovať",
|
||||
"contact_added": "Kontakt pridaný"
|
||||
"contact_added": "Kontakt pridaný",
|
||||
"save_to_files": {
|
||||
"title": "Uložiť do Súborov",
|
||||
"location": "Priečinok",
|
||||
"root": "Moje súbory",
|
||||
"save_here": "Uložiť sem",
|
||||
"saved": "Uložené do Súborov",
|
||||
"saved_to": "Do priečinka {folder}",
|
||||
"failed": "Nepodarilo sa uložiť do Súborov",
|
||||
"load_failed": "Priečinky sa nepodarilo načítať. Skúste to znova.",
|
||||
"no_folders": "Nie sú tu žiadne priečinky. Uložte sem alebo sa vráťte."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Vybrať z adresára",
|
||||
|
||||
@@ -1452,6 +1452,17 @@
|
||||
"mv_position": "{index} จาก {total}",
|
||||
"copy": "คัดลอก",
|
||||
"contact_added": "เพิ่มผู้ติดต่อแล้ว",
|
||||
"save_to_files": {
|
||||
"title": "บันทึกลงไฟล์",
|
||||
"location": "โฟลเดอร์",
|
||||
"root": "ไฟล์ของฉัน",
|
||||
"save_here": "บันทึกที่นี่",
|
||||
"saved": "บันทึกลงไฟล์แล้ว",
|
||||
"saved_to": "ใน {folder}",
|
||||
"failed": "ไม่สามารถบันทึกลงไฟล์ได้",
|
||||
"load_failed": "ไม่สามารถโหลดโฟลเดอร์ได้ ลองอีกครั้ง",
|
||||
"no_folders": "ไม่มีโฟลเดอร์ที่นี่ บันทึกที่นี่หรือย้อนกลับ"
|
||||
},
|
||||
"plugin_panel_show": "แสดงแถบปลั๊กอิน",
|
||||
"plugin_panel_hide": "ซ่อนแถบปลั๊กอิน"
|
||||
},
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Eklenti panelini göster",
|
||||
"plugin_panel_hide": "Eklenti panelini gizle",
|
||||
"copy": "Kopyala",
|
||||
"contact_added": "Kişi eklendi"
|
||||
"contact_added": "Kişi eklendi",
|
||||
"save_to_files": {
|
||||
"title": "Dosyalar'a kaydet",
|
||||
"location": "Klasör",
|
||||
"root": "Dosyalarım",
|
||||
"save_here": "Buraya kaydet",
|
||||
"saved": "Dosyalar'a kaydedildi",
|
||||
"saved_to": "{folder} içine",
|
||||
"failed": "Dosyalar'a kaydedilemedi",
|
||||
"load_failed": "Klasörler yüklenemedi. Yeniden deneyin.",
|
||||
"no_folders": "Burada klasör yok. Buraya kaydedin veya geri dönün."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Dizinden seç",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "Показати панель плагіна",
|
||||
"plugin_panel_hide": "Сховати панель плагіна",
|
||||
"copy": "Копіювати",
|
||||
"contact_added": "Контакт додано"
|
||||
"contact_added": "Контакт додано",
|
||||
"save_to_files": {
|
||||
"title": "Зберегти у «Файли»",
|
||||
"location": "Тека",
|
||||
"root": "Мої файли",
|
||||
"save_here": "Зберегти сюди",
|
||||
"saved": "Збережено у «Файли»",
|
||||
"saved_to": "У теку {folder}",
|
||||
"failed": "Не вдалося зберегти у «Файли»",
|
||||
"load_failed": "Не вдалося завантажити теки. Спробуйте ще раз.",
|
||||
"no_folders": "Тут немає тек. Збережіть сюди або поверніться назад."
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "Вибрати з довідника",
|
||||
|
||||
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "顯示外掛程式面板",
|
||||
"plugin_panel_hide": "隱藏外掛程式面板",
|
||||
"copy": "複製",
|
||||
"contact_added": "已新增聯絡人"
|
||||
"contact_added": "已新增聯絡人",
|
||||
"save_to_files": {
|
||||
"title": "儲存到檔案",
|
||||
"location": "資料夾",
|
||||
"root": "我的檔案",
|
||||
"save_here": "儲存在這裡",
|
||||
"saved": "已儲存到檔案",
|
||||
"saved_to": "在 {folder} 中",
|
||||
"failed": "無法儲存到檔案",
|
||||
"load_failed": "無法載入資料夾。請再試一次。",
|
||||
"no_folders": "這裡沒有資料夾。儲存在這裡或返回。"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "從目錄中選取",
|
||||
|
||||
+12
-1
@@ -698,7 +698,18 @@
|
||||
"plugin_panel_show": "显示插件面板",
|
||||
"plugin_panel_hide": "隐藏插件面板",
|
||||
"copy": "复制",
|
||||
"contact_added": "已添加联系人"
|
||||
"contact_added": "已添加联系人",
|
||||
"save_to_files": {
|
||||
"title": "保存到文件",
|
||||
"location": "文件夹",
|
||||
"root": "我的文件",
|
||||
"save_here": "保存到此处",
|
||||
"saved": "已保存到文件",
|
||||
"saved_to": "在 {folder} 中",
|
||||
"failed": "无法保存到文件",
|
||||
"load_failed": "无法加载文件夹。请重试。",
|
||||
"no_folders": "此处没有文件夹。保存到此处或返回。"
|
||||
}
|
||||
},
|
||||
"email_composer": {
|
||||
"select_from_directory": "从目录中选择",
|
||||
|
||||
Reference in New Issue
Block a user