diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index b44514e6f..9f6da218b 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -2,7 +2,10 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BulwarkDesktopBridge } from '@/lib/desktop/types'; import { registerDesktopSurface, resetDesktopCommands } from '@/lib/desktop/commands'; -import { resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences'; +import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences'; +import { resetDesktopLanguages } from '@/lib/desktop/languages'; +import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; +import { useToastStore } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useLocaleStore } from '@/stores/locale-store'; import { useOfflineStore } from '@/lib/offline/state'; @@ -13,36 +16,65 @@ vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) })); const canPrint = vi.fn(() => true); const canDownload = vi.fn(() => true); vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() })); +vi.mock('@/hooks/use-device-policy', () => ({ useStrictestDevices: () => strictest.current })); +const strictest = vi.hoisted(() => ({ current: {} as object })); + +const resolveExternalLink = vi.fn(async (ctx: { href: string }) => ctx.href as string | null); +vi.mock('@/lib/plugin-hook-result', () => ({ resolveExternalLink: (ctx: { href: string }) => resolveExternalLink(ctx) })); +const requestInternalMailto = vi.fn((_raw: string) => true); +vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto: (raw: string) => requestInternalMailto(raw) })); +const allowed = vi.hoisted(() => ({ dictionary: true })); +vi.mock('@/lib/desktop/dictionary-download', () => ({ dictionaryDownloadAllowed: () => allowed.dictionary })); +const signOut = vi.hoisted(() => ({ full: [] as (() => void)[], before: [] as (() => Promise)[] })); +vi.mock('@/lib/sign-out-cleanup', async (original) => ({ + ...(await original()), + onFullSignOut: (task: () => void) => signOut.full.push(task), + onBeforeSignOut: (task: () => Promise) => signOut.before.push(task), +})); const { DesktopHost } = await import('../desktop-host'); function fakeBridge() { let command: ((id: string) => void) | null = null; let flush: (() => Promise) | null = null; + let missing: ((language: string) => void) | null = null; const parts = { setState: vi.fn(), setLanguages: vi.fn(), sessionReady: vi.fn(), setRestoreEnabled: vi.fn(), setTheme: vi.fn(), + openExternal: vi.fn(), + allowDownload: vi.fn(), + clearFiles: vi.fn(), }; const bridge = { version: 1, platform: 'win32', menu: { setState: parts.setState, onCommand: (listener: (id: string) => void) => { command = listener; return () => {}; } }, - shell: { openExternal: () => {}, setLanguages: parts.setLanguages }, + shell: { openExternal: parts.openExternal, setLanguages: parts.setLanguages }, + spellcheck: { missing: async () => [], onMissing: (listener: (language: string) => void) => { missing = listener; return () => {}; }, allowDownload: parts.allowDownload }, + clipboard: { copyFile: async () => true, clearFiles: parts.clearFiles }, windows: { sessionReady: parts.sessionReady, setRestoreEnabled: parts.setRestoreEnabled }, theme: { set: parts.setTheme, system: async () => ({ dark: false, highContrast: false }) }, lifecycle: { onFlushRequest: (handler: () => Promise) => { flush = handler; return () => {}; } }, }; window.bulwarkDesktop = bridge as unknown as BulwarkDesktopBridge; - return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!() }; + return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!(), missing: (language: string) => act(() => missing!(language)) }; } const original = useAuthStore.getState(); beforeEach(() => { push.mockClear(); + resolveExternalLink.mockClear(); + resolveExternalLink.mockImplementation(async (ctx) => ctx.href); + requestInternalMailto.mockClear(); + requestInternalMailto.mockReturnValue(true); + allowed.dictionary = true; + signOut.full.length = 0; + signOut.before.length = 0; + resetDesktopLanguages(); canPrint.mockReturnValue(true); canDownload.mockReturnValue(true); localStorage.clear(); @@ -141,6 +173,136 @@ describe('DesktopHost', () => { expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' }); }); + describe('Open Link in a native menu', () => { + const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`; + + it('goes past the same check as a click on it', async () => { + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://example.org/a')); + expect(resolveExternalLink).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://example.org/a', text: 'click' })); + }); + + it('a plugin can stop it', async () => { + resolveExternalLink.mockResolvedValue(null); + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(resolveExternalLink).toHaveBeenCalled()); + await act(async () => {}); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('a plugin can send it elsewhere', async () => { + resolveExternalLink.mockResolvedValue('https://safe.example.org/'); + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://safe.example.org/')); + }); + + it('a mail address goes to the app\'s composer', async () => { + const shell = fakeBridge(); + render(); + shell.run(openLink('mailto:a@example.org')); + await waitFor(() => expect(requestInternalMailto).toHaveBeenCalledWith('mailto:a@example.org')); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('tells the shell it has this path, so an older page keeps the direct way', async () => { + const shell = fakeBridge(); + render(); + await waitFor(() => expect(shell.setState).toHaveBeenCalled()); + expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'link.check': true }); + }); + }); + + it('hands the shell its menus in the interface language', async () => { + const shell = fakeBridge(); + act(() => useLocaleStore.setState({ locale: 'fr' })); + render(); + await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); + const { labels } = shell.setLanguages.mock.lastCall![0]; + // The test's next-intl gives the key back. + expect(labels.menuFile).toBe('desktopUi.menu.file'); + expect(labels.menuReply).toBe('context_menu.reply'); + expect(labels.unsentMany).toBe('desktopUi.closing.unsent_many'); + }); + + describe('a language with no checker in the OS', () => { + afterEach(() => resetComposeFlushRegistry()); + const toasts = () => useToastStore.getState().toasts; + + it('the composer asks once, and only a yes opens the downloads', async () => { + useToastStore.setState({ toasts: [] }); + const shell = fakeBridge(); + render(); + await act(async () => {}); + shell.missing('nl-NL'); + // No composer open: nobody is writing yet. + expect(toasts()).toHaveLength(0); + const stop = registerComposeFlush(async () => {}); + await waitFor(() => expect(toasts()).toHaveLength(1)); + expect(shell.allowDownload).not.toHaveBeenCalled(); + act(() => toasts()[0].action!.onClick()); + expect(shell.allowDownload).toHaveBeenCalledTimes(1); + expect(readDesktopPrefs().dictionaryDownload).toBe('yes'); + stop(); + }); + + it('never a second time', async () => { + useToastStore.setState({ toasts: [] }); + setDesktopPref('dictionaryDownload', 'no'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + stop(); + }); + + it('never in no-third-party mode, even after an old yes', async () => { + useToastStore.setState({ toasts: [] }); + allowed.dictionary = false; + setDesktopPref('dictionaryDownload', 'yes'); + const first = fakeBridge(); + const view = render(); + await act(async () => {}); + expect(first.allowDownload).not.toHaveBeenCalled(); + view.unmount(); + setDesktopPref('dictionaryDownload', 'ask'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + stop(); + }); + + it('a yes from before opens them at the next start', async () => { + setDesktopPref('dictionaryDownload', 'yes'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + expect(shell.allowDownload).toHaveBeenCalledTimes(1); + }); + }); + + it('signing out takes the files copied to the clipboard with it', async () => { + const shell = fakeBridge(); + render(); + await act(async () => {}); + signOut.full[0](); + expect(shell.clearFiles).toHaveBeenCalledTimes(1); + await signOut.before[0](); + expect(shell.clearFiles).toHaveBeenCalledTimes(2); + }); + it('a quit is told how much mail is waiting', async () => { const shell = fakeBridge(); render(); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 83c6722e3..f8295a3ad 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -1,22 +1,30 @@ "use client"; import { useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { useStrictestDevices } from "@/hooks/use-device-policy"; import { useRouter } from "@/i18n/navigation"; import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands"; +import { onComposerOpened, composerIsOpen } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { runDesktopCommand, setDesktopBaseState } from "@/lib/desktop/commands"; -import { useDesktopPrefs } from "@/lib/desktop/preferences"; +import { dictionaryDownloadAllowed } from "@/lib/desktop/dictionary-download"; +import { noteComposerLanguage, setDesktopInterfaceLanguage, shellLabels } from "@/lib/desktop/languages"; +import { openLinkFromNativeMenu } from "@/lib/desktop/open-link"; +import { readDesktopPrefs, setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; import { applySystemAccent } from "@/lib/desktop/system-accent"; import type { DesktopSystemTheme } from "@/lib/desktop/types"; import { canDownloadAnywhere, canPrintAnywhere } from "@/lib/device-policy"; import { useOfflineStore } from "@/lib/offline/state"; import { openNewComposerWindow } from "@/lib/popout/open"; import { isPopoutWindow } from "@/lib/popout/window-role"; +import { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup"; import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; import { useLocaleStore } from "@/stores/locale-store"; -import { usePolicyStore } from "@/stores/policy-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; +import { toast } from "@/stores/toast-store"; /** * The webmail's side of what every desktop window needs: runs menu commands nothing @@ -28,13 +36,15 @@ export function DesktopHost() { const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; + const t = useTranslations(); + const tRef = useRef(t); + tRef.current = t; const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const uiLocale = useLocaleStore((s) => s.locale); - const policyLoaded = usePolicyStore((s) => s.loaded); - const accounts = useAuthStore((s) => s.getAllConnectedClients().size); + const strictest = useStrictestDevices(); const { restoreWindows } = useDesktopPrefs(); const theme = useThemeStore((s) => s.theme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); @@ -68,6 +78,16 @@ export function DesktopHost() { routerRef.current.push(`/mail/folder/${encodeURIComponent(id.slice("link.folder:".length))}`); return; } + // Open Link in a native menu. + if (id.startsWith("link.open:")) { + try { + const { url, text } = JSON.parse(id.slice("link.open:".length)) as { url: string; text: string }; + void openLinkFromNativeMenu(url, text, useEmailStore.getState().selectedEmail?.id); + } catch { + // Only the shell sends this, and it sends JSON. + } + return; + } if (!runDesktopCommand(id)) fallback[id]?.(); }); }, []); @@ -79,6 +99,7 @@ export function DesktopHost() { const signedIn = isAuthenticated; const popout = isPopoutWindow(); setDesktopBaseState({ + "link.check": true, "shortcuts.singleKeys": singleKeys, "policy.download": canDownloadAnywhere(), "file.print": canPrint, @@ -92,24 +113,79 @@ export function DesktopHost() { "go.files": signedIn, "help.shortcuts": true, }); - }, [isAuthenticated, singleKeys, policyLoaded, accounts]); + }, [isAuthenticated, singleKeys, strictest]); - // Menus and spellcheck follow the interface, and the composer's language too. + // Menus and spellcheck follow the interface. useEffect(() => { - const shell = getDesktopBridge()?.shell; - if (!shell?.setLanguages) return; const ui = uiLocale || document.documentElement.lang; - if (!ui) return; - const composer = writingLanguage && writingLanguage !== "auto" ? [writingLanguage] : []; - shell.setLanguages({ ui, composer }); - }, [uiLocale, writingLanguage]); + if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as (key: string) => string)); + }, [uiLocale]); + + // What the user writes in, whichever composer asks for it too. + useEffect(() => { + noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null); + }, [writingLanguage]); + + // A language with no checker in the OS needs a dictionary from Google: the composer + // asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to + // fetch from at all. + useEffect(() => { + const spellcheck = getDesktopBridge()?.spellcheck; + if (!spellcheck) return; + if (readDesktopPrefs().dictionaryDownload === "yes" && dictionaryDownloadAllowed()) spellcheck.allowDownload(); + const waiting = new Set(); + const offer = () => { + const [language] = waiting; + if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return; + if (readDesktopPrefs().dictionaryDownload !== "ask") return; + // Asked once, whatever comes of it. + setDesktopPref("dictionaryDownload", "no"); + const translate = tRef.current; + let name = language; + try { + name = new Intl.DisplayNames([document.documentElement.lang || "en"], { type: "language" }).of(language) ?? language; + } catch { + // The tag itself will do. + } + toast.info(translate("desktopUi.spellcheck.offer", { language: name }), { + duration: 0, + action: { + label: translate("desktopUi.spellcheck.download"), + onClick: () => { + setDesktopPref("dictionaryDownload", "yes"); + if (dictionaryDownloadAllowed()) spellcheck.allowDownload(); + }, + }, + }); + }; + const heard = (language: string) => { + waiting.add(language); + offer(); + }; + void spellcheck.missing().then((list) => list.forEach(heard)); + const stopMissing = spellcheck.onMissing(heard); + const stopComposer = onComposerOpened(offer); + return () => { + stopMissing(); + stopComposer(); + }; + }, []); + + // Signed out: nothing of that account stays on the file clipboard. + useEffect(() => { + const clipboard = getDesktopBridge()?.clipboard; + if (!clipboard?.clearFiles) return; + const clear = () => clipboard.clearFiles?.(); + onFullSignOut(clear); + onBeforeSignOut(async () => clear()); + }, []); // The windows' behaviour lives here, the shell only needs its copy. useEffect(() => { getDesktopBridge()?.windows?.setRestoreEnabled?.(restoreWindows); }, [restoreWindows]); - // Signed in: this window has a session to lend, so pop-outs from last time can open. + // Signed in: this window has a session to lend, for pop-outs from last time and links. useEffect(() => { if (!isAuthenticated || isPopoutWindow()) return; getDesktopBridge()?.windows?.sessionReady?.(); diff --git a/components/email/__tests__/viewer-mark-read.test.tsx b/components/email/__tests__/viewer-mark-read.test.tsx index 86e0f4dd8..569b0c285 100644 --- a/components/email/__tests__/viewer-mark-read.test.tsx +++ b/components/email/__tests__/viewer-mark-read.test.tsx @@ -75,6 +75,14 @@ describe('EmailViewer mark as read', () => { await waitFor(() => expect(onMarkAsRead).toHaveBeenCalledWith('e1', true, { automatic: true })); }); + it('a message opened from a link stays unread until the user says', async () => { + useSettingsStore.setState({ markAsReadDelay: 0 }); + const onMarkAsRead = vi.fn(); + render(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(onMarkAsRead).not.toHaveBeenCalled(); + }); + it("passes the toolbar button on as the user's own change", () => { useSettingsStore.setState({ markAsReadDelay: -1 }); const onMarkAsRead = vi.fn(); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 759465667..fd95d5e73 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -271,6 +271,14 @@ describe('viewer send failures', () => { useAuthStore.setState({ client: null, identities: [] } as never); }); + it('a message opened from a link sends none by itself', async () => { + render(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(sendReadReceipt).not.toHaveBeenCalled(); + }); + it('tries once per opening, not once per refresh, when the quota is full on Stalwart 1.0', async () => { const props = { mailboxes, selectedMailbox: 'inbox', currentMailboxRole: 'inbox' }; const { rerender } = render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 67be255df..b3037cede 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -170,6 +170,7 @@ import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { noteComposerLanguage } from "@/lib/desktop/languages"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; @@ -385,7 +386,7 @@ interface EmailComposerProps { /** The state as the window unloads, for a reload to start from. */ onUnloadState?: (data: ComposerDraftData) => void; /** A save reached the server, for other windows showing the Drafts folder. */ - onDraftSaved?: () => void; + onDraftSaved?: (draftId: string) => void; /** * Hands a draft to another window to save as this one closes: a request * started now wouldn't survive. False when that can't be done safely. @@ -1948,7 +1949,7 @@ export function EmailComposer({ // A composer that comes back with the app: a draft it opened on counts from the start. // Gone with the composer: sent, discarded or closed. useEffect(() => { - if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }); + if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountIdRef.current }); return () => noteOpenDraft(effectiveSessionKey, null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); @@ -3028,7 +3029,7 @@ export function EmailComposer({ draftAccountIdRef.current = draftAccountId; draftLocalAccountIdRef.current = savingLogin; // Before a save's promise settles: quitting waits for it, then keeps this list. - noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin, ownerAccountId: draftAccountId }); + noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountId }); setDraftId(savedDraftId); setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); @@ -3109,7 +3110,7 @@ export function EmailComposer({ // Only the save the user asked for is read out: autosave would say it // every minute, and the save before sending is followed by "Sent". if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved')); - if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(); + if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(id); return id; })(); inflightSaveRef.current = promise; @@ -3372,6 +3373,11 @@ export function EmailComposer({ const uiLocale = useLocale(); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const textLang = writingLanguage && writingLanguage !== 'auto' ? writingLanguage : uiLocale; + // The desktop app's spellcheck follows what's being written. + useEffect(() => { + noteComposerLanguage(effectiveSessionKey, textLang); + return () => noteComposerLanguage(effectiveSessionKey, null); + }, [effectiveSessionKey, textLang]); const proofreading = useProofreading({ composerId: pluginComposerId, sessionKey: effectiveSessionKey, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cd85eacd2..d4fd058dd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -290,6 +290,11 @@ interface EmailViewerProps { onToggleFullscreen?: () => void; /** Opens this message in its own window; left out where that isn't on offer. */ onOpenInWindow?: () => void; + /** + * Opened by something outside the app (a bulwark:// link), not by the user in it. + * It shows the message and does nothing on its own: no mark as read, no read receipt. + */ + noAutomaticActions?: boolean; isFullscreen?: boolean; currentUserEmail?: string; currentUserName?: string; @@ -1324,6 +1329,7 @@ export function EmailViewer({ onCompose, onToggleFullscreen, onOpenInWindow, + noAutomaticActions = false, isFullscreen = false, currentUserEmail, currentUserName, @@ -2200,7 +2206,7 @@ export function EmailViewer({ useEffect(() => { // Mark as read when email is viewed, respecting the delay setting - if (!email || !onMarkAsRead) { + if (!email || !onMarkAsRead || noAutomaticActions) { return; } @@ -3948,7 +3954,7 @@ ${printWatermark ? printWatermark.html : ''} if (autoMdnRef.current !== email?.id) autoMdnRef.current = null; }, [email?.id]); useEffect(() => { - if (readReceiptResponse !== 'always') return; + if (readReceiptResponse !== 'always' || noAutomaticActions) return; if (!shouldOfferReadReceipt || !email) return; if (autoMdnRef.current === email.id) return; autoMdnRef.current = email.id; diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 2a02faf0f..7fb96e09b 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -1,10 +1,12 @@ -import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff'; import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; import { usePluginStore } from '@/stores/plugin-store'; +import { emailHooks } from '@/lib/plugin-hooks'; // The composer's window: when it tells the opening window to let go, and what // happens on a reload. The composer itself is stubbed; its own tests cover it. @@ -25,9 +27,29 @@ vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children: vi.mock('../popout-shell', () => ({ PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}, PopoutSkeleton: () =>
, + PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) =>

{title}

{children}
, })); vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() })); +const identityMocks = vi.hoisted(() => ({ + multi: { enabled: false, allIdentities: [] as unknown[], groups: [] as unknown[] }, + bump: undefined as undefined | (() => void), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', async () => { + const { useReducer } = await import('react'); + return { + useProMultiAccountIdentities: () => { + const [, bump] = useReducer((n: number) => n + 1, 0); + identityMocks.bump = bump; + return identityMocks.multi; + }, + stripCrossAccountIdentityPrefix: (id: string) => { + const at = id.indexOf('::'); + return at >= 0 ? { localAccountId: id.slice(0, at), rawId: id.slice(at + 2) } : { localAccountId: null, rawId: id }; + }, + }; +}); + const { PopoutComposeApp } = await import('../popout-compose-app'); const original = useAuthStore.getState(); @@ -69,25 +91,40 @@ function openAt(handoffId: string) { describe('a composer coming back on its draft', () => { const closeSelf = vi.fn(); + const setRestore = vi.fn(); + const LOGIN = 'alice@example.org@mail.example.org'; + const MAILBOXES = [ + { id: 'mDrafts', role: 'drafts' }, + { id: 'mSent', role: 'sent' }, + { id: 'mTrash', role: 'trash' }, + { id: 'mInbox', role: 'inbox' }, + ]; beforeEach(() => { closeSelf.mockClear(); - window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never; + setRestore.mockClear(); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore } } as never; }); afterEach(() => { window.bulwarkDesktop = undefined; + vi.useRealTimers(); }); const draft = { - id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }], + id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }], }; - const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => { - useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never); + const openDraft = (getEmail: ReturnType, search = `draft=d1&account=${encodeURIComponent(LOGIN)}&owner=group`) => { + const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: { other: true } as never, getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never); window.history.replaceState(null, '', `/en/popout/compose?${search}`); render(); + return client; }; it('reads the draft from where it lives, and opens the composer on it, id and all', async () => { @@ -96,19 +133,106 @@ describe('a composer coming back on its draft', () => { await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written')); expect(getEmail).toHaveBeenCalledWith('d1', 'group'); // Saving goes over this draft, not next to it, and its files stay. - expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' }); + expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: LOGIN, to: 'a@example.org' }); expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1); }); - it('a draft that has gone since (sent elsewhere) closes the window', async () => { + // Sending keeps the id. A composer on it would destroy the Sent copy at the next + // autosave, Send would mail it twice, and Discard would delete it. + it.each([ + ['a message that was sent', { keywords: { $seen: true }, mailboxIds: { mSent: true } }], + ['a sent message still marked as a draft', { keywords: { $draft: true }, mailboxIds: { mSent: true } }], + ['a draft in the Trash', { keywords: { $draft: true }, mailboxIds: { mTrash: true } }], + ['mail in the Inbox that is no draft at all', { keywords: {}, mailboxIds: { mInbox: true } }], + ])('%s is not opened', async (_name, shape) => { + openDraft(vi.fn(async () => ({ ...draft, ...shape }))); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('composer')).toBeNull(); + }); + + it('a send brought back has no $draft, and in Drafts it opens', async () => { + openDraft(vi.fn(async () => ({ ...draft, keywords: {}, mailboxIds: { mDrafts: true } }))); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + }); + + it('an address with no login opens nothing, not the active account\'s mail under the same id', async () => { + const getEmail = vi.fn(async () => draft); + useAuthStore.setState({ client: { getEmail, getMailboxes: async () => MAILBOXES } as never, getClientForAccount: () => undefined } as never); + window.history.replaceState(null, '', '/en/popout/compose?draft=d1'); + render(); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(getEmail).not.toHaveBeenCalled(); + }); + + it('a draft gone since closes the window', async () => { openDraft(vi.fn(async () => null)); await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); expect(screen.queryByTestId('composer')).toBeNull(); }); - it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => { - openDraft(vi.fn(async () => { throw new Error('offline'); })); - await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + it('a server not answering keeps the window and the entry, and Try again works', async () => { + let up = false; + const getEmail = vi.fn(async () => { + if (!up) throw new Error('offline'); + return draft; + }); + openDraft(getEmail); + await screen.findByText('unavailable_title'); + expect(closeSelf).not.toHaveBeenCalled(); + // On the list for next time. + expect(setRestore.mock.lastCall![0]).toEqual([expect.stringContaining('draft=d1')]); + up = true; + fireEvent.click(screen.getByText('try_again')); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + }); + + it('plugins get their say on the draft, as when it opens from the list', async () => { + const hook = emailHooks.onBeforeEditDraft.register('test', ((value: unknown) => ({ ...(value as object), subject: 'Changed by a plugin' })) as never); + try { + openDraft(vi.fn(async () => draft)); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Changed by a plugin')); + } finally { + hook.dispose(); + } + }); + + describe('with several accounts', () => { + const OTHER = { id: 'other@x@h::id-9', email: 'other@example.org', name: 'Other', localAccountId: LOGIN }; + + it('waits for the identity that wrote it, however late it loads, and starts on that', async () => { + identityMocks.multi = { enabled: true, allIdentities: [], groups: [] }; + const fromOther = { ...draft, from: [{ name: 'Other', email: 'other@example.org' }] }; + openDraft(vi.fn(async () => fromOther)); + await settle(); + // Not on the default in the meantime: it would save that. + expect(screen.queryByTestId('composer')).toBeNull(); + act(() => { + identityMocks.multi = { enabled: true, allIdentities: [OTHER], groups: [] }; + identityMocks.bump?.(); + }); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + expect(composer?.initialData).toMatchObject({ selectedIdentityId: OTHER.id }); + expect((composer?.initialData as unknown as { fromOverrideEnabled?: boolean }).fromOverrideEnabled).toBeUndefined(); + }); + + it('an address no identity has any more stays as the From, not the default', async () => { + identityMocks.multi = { enabled: true, allIdentities: [], groups: [] }; + vi.useFakeTimers({ shouldAdvanceTime: true }); + openDraft(vi.fn(async () => ({ ...draft, from: [{ name: 'Gone', email: 'gone@example.org' }] }))); + await act(async () => { + await vi.advanceTimersByTimeAsync(4100); + }); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + expect(composer?.initialData).toMatchObject({ selectedIdentityId: null, fromOverrideEnabled: true, fromOverrideEmail: 'gone@example.org', fromOverrideName: 'Gone' }); + }); + }); + + it('a save moves the address on, or a reload would look for a version that is gone', async () => { + openDraft(vi.fn(async () => draft)); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + act(() => (composer as unknown as { ownWindow: { onDraftSaved: (id: string) => void } }).ownWindow.onDraftSaved('d2')); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('d2'); + expect(new URLSearchParams(window.location.search).get('account')).toBe(LOGIN); }); }); diff --git a/components/popout/__tests__/popout-message-app.test.tsx b/components/popout/__tests__/popout-message-app.test.tsx index 8d877a955..82e6a7d69 100644 --- a/components/popout/__tests__/popout-message-app.test.tsx +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -78,6 +78,21 @@ afterEach(() => { window.history.replaceState(null, '', '/'); }); +describe('a message opened from a bulwark:// link', () => { + it('asks for nothing automatic: such a link may show a message, not act on it', async () => { + window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&via=link'); + render(); + await settle(); + expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBe(true); + }); + + it('an ordinary pop-out acts as ever', async () => { + render(); + await settle(); + expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBeFalsy(); + }); +}); + describe('PopoutMessageApp', () => { it('opens the message of the address, through its login and account', async () => { render(); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 7aaa4f709..11e02e952 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -18,9 +18,15 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { closePopoutWindow } from "@/lib/popout/window-role"; -import { composerDataFromDraft } from "@/lib/draft-composer-data"; +import { Button } from "@/components/ui/button"; +import { composerDataFromDraft, isRestorableDraft } from "@/lib/draft-composer-data"; +import { noteOpenDraft } from "@/lib/desktop/window-restore"; +import { findDraftIdentityIdForLogin } from "@/lib/draft-source"; +import { emailHooks } from "@/lib/plugin-hooks"; +import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; -import { PopoutShell, PopoutSkeleton } from "./popout-shell"; +import type { Email } from "@/lib/jmap/types"; +import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ const WARNING_READ_MS = 8000; @@ -44,9 +50,20 @@ function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: }; } +/** How long a restored draft waits for the identity that wrote it to load. */ +const IDENTITY_WAIT_MS = 4000; + /** * The composer that was open when the desktop app quit, back on its draft. A draft * that has gone since (sent or deleted elsewhere) means there's nothing to show. + * A server that doesn't answer says nothing about it: the window stays, offers to try + * again, and the draft stays on the list for the next start. + * + * Only through the login the address names, and never without one: ids repeat across + * accounts, so another login could hold someone else's message under this id. + * + * The From stays what it was. With several accounts the other accounts' identities + * load later, and the composer would otherwise start on the default and save that. */ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { draftId: string; @@ -55,37 +72,92 @@ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { onReady: (payload: ComposePopoutPayload) => void; }) { const t = useTranslations(); + const tRestore = useTranslations("desktopUi.restore"); const tWindows = useTranslations("pwaWindows"); - const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client); - const identities = useIdentityStore((s) => s.identities); + const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined)); + const ownIdentities = useIdentityStore((s) => s.identities); + const multi = useProMultiAccountIdentities(); + const identities = multi.enabled ? multi.allIdentities : ownIdentities; + const [draft, setDraft] = useState(null); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + const [waitOver, setWaitOver] = useState(false); + + // Until the composer takes over, this window still stands for the draft. useEffect(() => { + const key = `restored-${draftId}`; + noteOpenDraft(key, { draftId, localAccountId: accountId, ownerAccountId }); + return () => noteOpenDraft(key, null); + }, [draftId, accountId, ownerAccountId]); + + useEffect(() => { + if (!accountId) { + closePopoutWindow(); + return; + } if (!client) return; let cancelled = false; - void client.getEmail(draftId, ownerAccountId).then((draft) => { + setFailed(false); + void Promise.all([client.getEmail(draftId, ownerAccountId), client.getMailboxes(ownerAccountId)]).then(async ([found, mailboxes]) => { if (cancelled) return; - if (!draft) { + if (!found || !isRestorableDraft(found, mailboxes)) { closePopoutWindow(); return; } - onReady({ - mode: "compose", - initialData: composerDataFromDraft(draft, { - accountId: ownerAccountId, - localAccountId: accountId, - identities, - noSubject: t("email_composer.no_subject"), - }), - }); + const transformed = await emailHooks.onBeforeEditDraft.transform(found); + if (!cancelled) setDraft(transformed); }).catch((error) => { debug.error("Failed to reopen a draft:", error); - if (!cancelled) closePopoutWindow(); + if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; - // Once per client: the draft doesn't change under a window still opening. + }, [client, accountId, draftId, ownerAccountId, attempt]); + + // Back online: try by itself. + useEffect(() => { + if (!failed) return; + const retry = () => setAttempt((n) => n + 1); + window.addEventListener("online", retry); + return () => window.removeEventListener("online", retry); + }, [failed]); + + useEffect(() => { + const timer = setTimeout(() => setWaitOver(true), IDENTITY_WAIT_MS); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!draft) return; + const from = draft.from?.[0]; + const matched = findDraftIdentityIdForLogin(identities, from, accountId); + // The identity may still be on its way: wait for it, then keep the address as it was. + if (!matched && from && !waitOver) return; + const data = composerDataFromDraft(draft, { + accountId: ownerAccountId, + localAccountId: accountId, + identities, + noSubject: t("email_composer.no_subject"), + }); + onReady({ + mode: "compose", + initialData: !matched && from?.email + ? { ...data, fromOverrideEnabled: true, fromOverrideEmail: from.email, fromOverrideName: from.name ?? "" } + : data, + }); + // onReady swaps this component out; `t` changes nothing here. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [client, draftId, ownerAccountId]); + }, [draft, identities, waitOver]); + + if (failed) { + return ( + + + + + ); + } return ; } @@ -205,7 +277,15 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; onUnloadState: (state: ComposerDraftData) => { if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null }); }, - onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId), + onDraftSaved: (draftId: string) => { + announceMailChanged(useAuthStore.getState().activeAccountId); + // A save makes a new id, and this address is what a reload starts from. + const url = new URL(window.location.href); + if (url.searchParams.has("draft")) { + url.searchParams.set("draft", draftId); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + } + }, }), [handoffId, mainWindow, payload]); return ( diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index c7841a2b1..4f0ae12ad 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -24,7 +24,7 @@ const CHECK_INTERVAL_MS = 60_000; * The id from the address bar: a page the service worker kept for a start * without the network was rendered for another id. */ -function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } { +function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean } { if (typeof window === "undefined") return { emailId: paramId }; const segments = window.location.pathname.split("/").filter(Boolean); const at = segments.lastIndexOf("message"); @@ -35,7 +35,7 @@ function readLaunch(paramId: string): { emailId: string; accountId?: string; own // A malformed address: the route's own value. } const search = new URLSearchParams(window.location.search); - return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined }; + return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined, fromLink: search.get("via") === "link" }; } type Presence = "present" | "gone"; @@ -96,7 +96,7 @@ function useMessagePresence(emailId: string, ownerAccountId: string | undefined) return { presence, mailboxIds, liveState }; } -function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) { +function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean }) { const t = useTranslations("pwaWindows"); const tSidebar = useTranslations("sidebar"); const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined); @@ -194,6 +194,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string onOpenCompose={openCompose} liveState={liveState} onMailChanged={handleMailChanged} + noAutomaticActions={fromLink} className="min-h-0 w-full flex-1" /> @@ -205,7 +206,7 @@ export function PopoutMessageApp({ id }: { id: string }) { const [launch] = useState(() => readLaunch(id)); return ( - + ); } diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index aeaf0f204..4a8adc45f 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -79,6 +79,8 @@ interface ProEmailViewProps { liveState?: Pick | null; /** This view changed the message's flags, for a host other windows should hear from. */ onMailChanged?: () => void; + /** See EmailViewer. */ + noAutomaticActions?: boolean; /** * The message left this view: deleted, archived, moved, or reopened in the * composer. The host decides what that means (close the tab / clear the @@ -134,7 +136,7 @@ function buildReplyContext(email: Email): ProReplyContext { * Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's * reading pane. */ -export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) { +export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) { const t = useTranslations(); const tNotifications = useTranslations('notifications'); const tRemove = useTranslations('email_list.confirm_remove'); @@ -574,6 +576,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
( const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable(); const positionRef = useRef(position); positionRef.current = position; + const tDesktop = useTranslations("desktopUi.menu"); + const tDesktopRef = useRef(tDesktop); + tDesktopRef.current = tDesktop; useEffect(() => { setMounted(true); @@ -280,8 +285,15 @@ export const ContextMenu = forwardRef( const place = at ? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP } : undefined; - await showNativeMenu(read, place); - if (!cancelled) onCloseRef.current(); + const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins")); + if (cancelled) return; + // "More from plugins": the menu itself, with what only it can show. + if (outcome === "html") { + setNativeRefused(true); + return; + } + if (outcome === "gone") toast.warning(tDesktopRef.current("pick_gone")); + onCloseRef.current(); })(); return () => { cancelled = true; diff --git a/lib/__tests__/outgoing-hosts-mode.test.ts b/lib/__tests__/outgoing-hosts-mode.test.ts index fd4b4d713..182cb26e1 100644 --- a/lib/__tests__/outgoing-hosts-mode.test.ts +++ b/lib/__tests__/outgoing-hosts-mode.test.ts @@ -82,6 +82,10 @@ const probes: Record Promise> = { await GET(new NextRequest('https://webmail.example/api/bimi?domain=probe.org')); return bimiResolve.mock.calls.length > 0; }, + 'spellcheck-dictionaries': async () => { + const { dictionaryDownloadAllowed } = await import('@/lib/desktop/dictionary-download'); + return dictionaryDownloadAllowed(); + }, translate: async () => { const { POST } = await import('@/app/api/translate/route'); await POST(new NextRequest('https://webmail.example/api/translate', { diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index dae391d33..dc585707f 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -106,6 +106,16 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [ inMode: 'Avatars show no brand logos; /api/bimi answers 403 before any lookup.', lite: false, }, + { + id: 'spellcheck-dictionaries', + host: 'redirector.gvt1.com', + files: ['lib/desktop/dictionary-download.ts'], + side: 'browser', + usedBy: 'Desktop app only: a spellcheck dictionary from Google, for a language the OS has no checker for, after the user said yes when the composer asked. The text being written is not sent.', + off: 'Answer no when the composer asks once. Nothing is fetched until a yes.', + inMode: 'The composer does not ask, and the app has no address to fetch dictionaries from.', + lite: false, + }, { id: 'translate', host: 'api.mymemory.translated.net', diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts index b0215caab..ffa595310 100644 --- a/lib/compose-flush-registry.ts +++ b/lib/compose-flush-registry.ts @@ -14,6 +14,21 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; export type ComposeFlush = () => Promise; +const openedListeners = new Set<() => void>(); + +/** A composer is open in this window. */ +export function composerIsOpen(): boolean { + return flushes.size > 0; +} + +/** Calls `listener` as a composer opens. Returns the unsubscribe. */ +export function onComposerOpened(listener: () => void): () => void { + openedListeners.add(listener); + return () => { + openedListeners.delete(listener); + }; +} + /** Less than the desktop shell waits for a closing window. */ const DESKTOP_FLUSH_MS = 3000; @@ -44,6 +59,7 @@ export function registerComposeFlush(flush: ComposeFlush): () => void { flushes.set(id, flush); for (const waiter of [...waiters]) waiter(flush); syncDesktopFlush(); + for (const listener of [...openedListeners]) listener(); return () => { flushes.delete(id); syncDesktopFlush(); diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx index 2bab20eb1..58df7bba2 100644 --- a/lib/desktop/__tests__/native-ui.test.tsx +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -8,6 +8,7 @@ import { readMenu } from '../native-menu'; import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore'; import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences'; import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role'; +import { toast } from '@/stores/toast-store'; function setBridge(bridge: Partial | undefined) { window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; @@ -124,6 +125,42 @@ describe('an HTML menu shown natively', () => { expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy(); }); + it('a plugin\'s frame keeps the rest native, with a line to the HTML menu', async () => { + const frame = ( +
'; + expect(readMenu(root).unsupported).toBe(true); + }); + + it('a row that went while the menu was up says so, instead of doing nothing', async () => { + const warning = vi.spyOn(toast, 'warning').mockImplementation(() => '' as never); + const reply = vi.fn(); + const { items, onClose } = await openMenu(testMenu(undefined, { reply })); + const row = screen.getAllByRole('menuitem', { hidden: true }).find((el) => el.textContent?.includes('Reply'))!; + row.parentElement!.remove(); + await act(async () => { + answer(items[1].id!); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(reply).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledWith('pick_gone'); + warning.mockRestore(); + }); + it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => { const root = document.createElement('div'); root.innerHTML = ''; @@ -242,7 +279,7 @@ describe('a pop-out closing itself', () => { describe('window settings', () => { it('both default to on, and stay on this computer', () => { - expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true }); + expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' }); setDesktopPref('restoreWindows', false); resetDesktopPrefsCache(); expect(readDesktopPrefs().restoreWindows).toBe(false); diff --git a/lib/desktop/dictionary-download.ts b/lib/desktop/dictionary-download.ts new file mode 100644 index 000000000..587b7af39 --- /dev/null +++ b/lib/desktop/dictionary-download.ts @@ -0,0 +1,14 @@ +/** + * Spellcheck dictionaries for languages the OS has no checker for. They'd come from + * this Google host, so nothing is fetched until the user says yes in the composer, + * and never in no-third-party mode. The shell starts every session with the address + * pointing nowhere (src/main/spellcheck.ts in the desktop repo). + */ + +import { noThirdPartyTraffic } from '@/lib/third-party-mode'; + +export const DICTIONARY_HOST_URL = 'https://redirector.gvt1.com/edgedl/chrome/dict/'; + +export function dictionaryDownloadAllowed(): boolean { + return !noThirdPartyTraffic(); +} diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts new file mode 100644 index 000000000..5c054dc28 --- /dev/null +++ b/lib/desktop/languages.ts @@ -0,0 +1,128 @@ +/** + * What the shell's menus and spellcheck should speak. The interface language and + * its translations of the menus come from the host, and each open composer adds the + * language its text is in, so spelling is checked in what's being written. + */ + +import { getDesktopBridge } from './bridge'; + +let ui = ''; +let labels: Record | undefined; +const composers = new Map(); + +function push(): void { + const shell = getDesktopBridge()?.shell; + if (!shell?.setLanguages || !ui) return; + shell.setLanguages({ ui, composer: [...new Set(composers.values())], labels }); +} + +export function setDesktopInterfaceLanguage(language: string, translated: Record): void { + ui = language; + labels = translated; + push(); +} + +/** With null, this composer has gone. */ +export function noteComposerLanguage(key: string, language: string | null): void { + if (language) { + if (composers.get(key) === language) return; + composers.set(key, language); + } else if (!composers.delete(key)) { + return; + } + push(); +} + +/** The shell's text in the interface language, by its keys in src/main/strings.ts. */ +export function shellLabels(t: (key: string) => string): Record { + return { + menuFile: t('desktopUi.menu.file'), + menuEdit: t('desktopUi.menu.edit'), + menuView: t('desktopUi.menu.view'), + menuMessage: t('desktopUi.menu.message'), + menuGo: t('desktopUi.menu.go'), + menuWindow: t('desktopUi.menu.window'), + menuHelp: t('desktopUi.menu.help'), + menuNewMessage: t('email_composer.new_message'), + menuNewWindow: t('desktopUi.menu.new_window'), + menuPrint: t('desktopUi.menu.print'), + menuSavePdf: t('desktopUi.menu.save_pdf'), + menuSwitchMode: t('desktopUi.menu.switch_connection'), + menuClose: t('desktopUi.menu.close'), + menuQuit: t('desktopUi.menu.quit'), + menuUndo: t('desktopUi.menu.undo'), + menuRedo: t('desktopUi.menu.redo'), + menuCut: t('desktopUi.menu.cut'), + menuCopy: t('desktopUi.menu.copy'), + menuPaste: t('desktopUi.menu.paste'), + menuSelectAll: t('desktopUi.menu.select_all'), + menuSearch: t('desktopUi.menu.search'), + menuRefresh: t('desktopUi.menu.refresh'), + menuReadingPane: t('desktopUi.menu.reading_pane'), + menuPaneRight: t('desktopUi.menu.pane_right'), + menuPaneBelow: t('desktopUi.menu.pane_below'), + menuPaneOff: t('desktopUi.menu.pane_off'), + menuZoomIn: t('desktopUi.menu.zoom_in'), + menuZoomOut: t('desktopUi.menu.zoom_out'), + menuZoomReset: t('desktopUi.menu.zoom_reset'), + menuFullScreen: t('desktopUi.menu.full_screen'), + menuReply: t('context_menu.reply'), + menuReplyAll: t('context_menu.reply_all'), + menuForward: t('context_menu.forward'), + menuMarkRead: t('context_menu.mark_read'), + menuMarkUnread: t('context_menu.mark_unread'), + menuStar: t('context_menu.star'), + menuMove: t('context_menu.move_to_dialog'), + menuArchive: t('context_menu.archive'), + menuSpam: t('context_menu.mark_as_spam'), + menuDelete: t('context_menu.delete'), + menuOpenInWindow: t('desktopUi.menu.open_in_window'), + menuGoInbox: t('sidebar.mailboxes.inbox'), + menuGoSent: t('sidebar.mailboxes.sent'), + menuGoDrafts: t('sidebar.mailboxes.drafts'), + menuGoNext: t('desktopUi.menu.next_message'), + menuGoPrevious: t('desktopUi.menu.previous_message'), + menuGoNextUnread: t('desktopUi.menu.next_unread'), + menuGoMail: t('desktopUi.menu.mail'), + menuGoCalendar: t('sidebar.calendar'), + menuGoContacts: t('sidebar.contacts'), + menuGoFiles: t('desktopUi.menu.files'), + menuGoBack: t('desktopUi.menu.back'), + menuGoForward: t('desktopUi.menu.forward'), + menuMinimize: t('desktopUi.menu.minimize'), + menuCycle: t('desktopUi.menu.next_window'), + menuShortcuts: t('desktopUi.menu.shortcuts'), + menuDocs: t('desktopUi.menu.docs'), + menuSettings: t('desktopUi.menu.settings'), + menuAbout: t('desktopUi.menu.about'), + contextCut: t('desktopUi.menu.cut'), + contextCopy: t('desktopUi.menu.copy'), + contextPaste: t('desktopUi.menu.paste'), + contextSelectAll: t('desktopUi.menu.select_all'), + contextAddToDictionary: t('desktopUi.context.add_to_dictionary'), + contextNoSuggestions: t('desktopUi.context.no_suggestions'), + contextOpenLink: t('desktopUi.context.open_link'), + contextCopyLink: t('desktopUi.context.copy_link'), + contextCopyAddress: t('desktopUi.context.copy_address'), + contextSaveImage: t('desktopUi.context.save_image'), + contextCopyImage: t('desktopUi.context.copy_image'), + unsavedTitle: t('desktopUi.closing.unsaved_title'), + unsavedDetail: t('desktopUi.closing.unsaved_detail'), + closeAnyway: t('desktopUi.closing.close_anyway'), + unsentTitle: t('desktopUi.closing.unsent_title'), + unsentOne: t('desktopUi.closing.unsent_one'), + unsentMany: t('desktopUi.closing.unsent_many'), + closeNow: t('desktopUi.closing.quit'), + leaveTitle: t('desktopUi.closing.leave_title'), + leaveDetail: t('desktopUi.closing.leave_detail'), + leave: t('desktopUi.closing.leave'), + stay: t('desktopUi.closing.stay'), + }; +} + +/** Test helper. */ +export function resetDesktopLanguages(): void { + ui = ''; + labels = undefined; + composers.clear(); +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts index ae8eca8a5..5fac69c9e 100644 --- a/lib/desktop/native-menu.ts +++ b/lib/desktop/native-menu.ts @@ -4,8 +4,9 @@ * natively. A pick clicks the row the HTML menu has for it, so a menu has one list of * entries with one set of handlers and the two can't drift. * - * A menu with something a native one can't show (a plugin's frame, a colour picker, - * a row with no text) stays HTML. So does anything marked data-native-menu="html". + * A menu with something a native one can't show (a colour picker, a row with no text) + * stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the + * menu doesn't: the rest shows natively, with one line that opens the HTML menu. */ import { getDesktopBridge } from './bridge'; @@ -21,10 +22,15 @@ export interface ReadMenu { elements: Map; /** There's something here a native menu can't show. */ unsupported: boolean; + /** A plugin drew something of its own in the menu. */ + hasFrames: boolean; } const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; +/** The native line that opens the HTML menu instead. */ +export const HTML_MENU_ITEM = "html-menu"; + function isDisabled(el: HTMLElement): boolean { return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; } @@ -40,7 +46,8 @@ function isHidden(el: Element): boolean { export function readMenu(root: HTMLElement): ReadMenu { const elements = new Map(); - let unsupported = root.querySelector('[data-native-menu="html"], iframe, select, textarea, input[type="color"]') !== null; + let unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null; + let hasFrames = false; let next = 1; const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { @@ -50,6 +57,12 @@ export function readMenu(root: HTMLElement): ReadMenu { into.push({ type: 'separator' }); continue; } + if (child.tagName === 'IFRAME') { + // Only a plugin's own slot has this coming. Anything else stays HTML whole. + if (child.closest('[data-plugin-slot]')) hasFrames = true; + else unsupported = true; + continue; + } if (child.dataset.submenuPanel !== undefined) continue; if (child.dataset.contextMenuHeader !== undefined) { const label = labelOf(child); @@ -105,7 +118,7 @@ export function readMenu(root: HTMLElement): ReadMenu { const items: DesktopNativeMenuItem[] = []; walk(root, items); - return { items, elements, unsupported }; + return { items, elements, unsupported, hasFrames }; } const SETTLE_MS = 30; @@ -127,17 +140,46 @@ export async function readSettledMenu(root: HTMLElement, cancelled: () => boolea return previous; } -/** Shows it and clicks the row picked. Resolves when the menu has gone. */ -export async function showNativeMenu(read: ReadMenu, at?: { x: number; y: number }): Promise { +function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined { + for (const item of items) { + if (item.id === id) return item.label; + const below = item.submenu && labelOfId(item.submenu, id); + if (below) return below; + } + return undefined; +} + +export type NativeMenuOutcome = 'done' | 'html' | 'gone'; + +/** + * Shows it and clicks the row picked. Says when the pick found nothing: the page can + * re-render under a menu that stays up (the list refreshing), and a row that's gone + * would otherwise do nothing with no word about it. If the same line is back at the + * same place, that's the one. + */ +export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise { const popup = getDesktopBridge()?.menu?.popup; - if (!popup) return; + if (!popup) return 'done'; + const items = read.hasFrames && fromPlugins + ? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }] + : read.items; document.documentElement.dataset.nativeMenuOpen = ''; + let picked: string | null; try { - const picked = await popup(read.items, at); - if (picked) read.elements.get(picked)?.click(); + picked = await popup(items, at); } finally { delete document.documentElement.dataset.nativeMenuOpen; } + if (!picked) return 'done'; + if (picked === HTML_MENU_ITEM) return 'html'; + let row = read.elements.get(picked); + if (!row?.isConnected) { + const again = root.isConnected ? readMenu(root) : null; + row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined; + } + if (!row?.isConnected) return 'gone'; + row.click(); + return 'done'; } /** A native menu is up: the window's blur or a stray event says nothing about it. */ diff --git a/lib/desktop/open-link.ts b/lib/desktop/open-link.ts new file mode 100644 index 000000000..96c9c8f6f --- /dev/null +++ b/lib/desktop/open-link.ts @@ -0,0 +1,22 @@ +/** + * "Open Link" in the desktop app's native menu. It has to go where a click on the + * link goes: a mail address to the app's composer, anything else past + * resolveExternalLink, where a plugin can stop it or send it elsewhere. + */ + +import { isOpenableLinkHref } from '@/lib/email-sanitization'; +import { resolveExternalLink } from '@/lib/plugin-hook-result'; +import { requestInternalMailto } from '@/lib/protocol-handlers/mailto'; +import { getDesktopBridge } from './bridge'; + +export async function openLinkFromNativeMenu(href: string, text: string, emailId?: string): Promise { + const shell = getDesktopBridge()?.shell; + if (!shell) return; + if (/^mailto:/i.test(href)) { + if (!requestInternalMailto(href)) shell.openExternal(href); + return; + } + if (!isOpenableLinkHref(href)) return; + const target = await resolveExternalLink({ href, target: '_blank', emailId, text }); + if (target) shell.openExternal(target); +} diff --git a/lib/desktop/preferences.ts b/lib/desktop/preferences.ts index f9894c5c8..7cd499858 100644 --- a/lib/desktop/preferences.ts +++ b/lib/desktop/preferences.ts @@ -13,9 +13,11 @@ export interface DesktopWindowPrefs { openOnDoubleClick: boolean; /** The windows open at quit come back at the next start. */ restoreWindows: boolean; + /** Spellcheck dictionaries from Google, for a language the OS has no checker for. Asked once. */ + dictionaryDownload: 'ask' | 'yes' | 'no'; } -const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true }; +const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' }; let cached: DesktopWindowPrefs | null = null; const listeners = new Set<() => void>(); @@ -32,6 +34,7 @@ export function readDesktopPrefs(): DesktopWindowPrefs { cached = { openOnDoubleClick: typeof stored.openOnDoubleClick === 'boolean' ? stored.openOnDoubleClick : DEFAULTS.openOnDoubleClick, restoreWindows: typeof stored.restoreWindows === 'boolean' ? stored.restoreWindows : DEFAULTS.restoreWindows, + dictionaryDownload: stored.dictionaryDownload === 'yes' || stored.dictionaryDownload === 'no' ? stored.dictionaryDownload : DEFAULTS.dictionaryDownload, }; return cached; } diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index f9eb6109b..485a95ff0 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -126,8 +126,16 @@ export interface DesktopMenuBridge { export interface DesktopShellBridge { /** http, https and mailto only. */ openExternal: (url: string) => void; - /** The shell's menus follow `ui`. Spellcheck follows both. */ - setLanguages?: (languages: { ui: string; composer?: string[] }) => void; + /** The shell's menus follow `ui`, in `labels` (lib/desktop/languages.ts). Spellcheck follows `ui` and `composer`. */ + setLanguages?: (languages: { ui: string; composer?: string[]; labels?: Record }) => void; +} + +export interface DesktopSpellcheckBridge { + /** Languages with neither an OS checker nor a dictionary here. */ + missing: () => Promise; + onMissing: (listener: (language: string) => void) => () => void; + /** The user said yes: dictionaries may come from Google. Never with no-third-party mode on. */ + allowDownload: () => void; } export interface DesktopDialogsBridge { @@ -144,6 +152,8 @@ export interface DesktopDialogsBridge { export interface DesktopClipboardBridge { /** Puts a file on the clipboard, for pasting into a folder. False when that didn't work. */ copyFile: (name: string, data: ArrayBuffer) => Promise; + /** Signed out: nothing copied from that account stays on disk. */ + clearFiles?: () => void; } export type DesktopServerPrepareResult = @@ -281,6 +291,7 @@ export interface BulwarkDesktopBridge { menu?: DesktopMenuBridge; shell?: DesktopShellBridge; dialogs?: DesktopDialogsBridge; + spellcheck?: DesktopSpellcheckBridge; clipboard?: DesktopClipboardBridge; lifecycle?: DesktopLifecycleBridge; servers?: DesktopServersBridge; diff --git a/lib/draft-composer-data.ts b/lib/draft-composer-data.ts index 82eebc52d..8459d2e8d 100644 --- a/lib/draft-composer-data.ts +++ b/lib/draft-composer-data.ts @@ -1,10 +1,25 @@ import type { ComposerDraftData } from '@/components/email/email-composer'; import { findDraftIdentityIdForLogin, type DraftSource } from '@/lib/draft-source'; import { getDraftComposerBody } from '@/lib/email-body-selection'; -import type { Email, Identity } from '@/lib/jmap/types'; +import type { Email, Identity, Mailbox } from '@/lib/jmap/types'; import { messagePriorityFromHeaders } from '@/lib/message-priority'; import { reopenedLabelChoice } from '@/lib/outgoing/classification'; +/** + * Whether a message may reopen as a draft from an id kept since last time. Sending + * keeps the id, and a composer on it would replace, resend or delete the message. A + * draft has `$draft` or sits in Drafts (a send undone comes back without the keyword); + * and in Sent or Trash it's nothing to edit, whatever its keywords say. + */ +export function isRestorableDraft( + draft: Pick, + mailboxes: Pick[], +): boolean { + const roles = Object.keys(draft.mailboxIds ?? {}).map((id) => mailboxes.find((m) => m.id === id || m.originalId === id)?.role); + if (roles.includes('sent') || roles.includes('trash')) return false; + return !!draft.keywords?.$draft || roles.includes('drafts'); +} + /** * What a composer starts from when a saved draft is opened: from the list, or from * last time the desktop app was running. `draft` has its full body. diff --git a/lib/outgoing-hosts.ts b/lib/outgoing-hosts.ts index c58c8608b..91f3f4dc7 100644 --- a/lib/outgoing-hosts.ts +++ b/lib/outgoing-hosts.ts @@ -21,6 +21,7 @@ export const BUILT_IN_HOSTNAMES: readonly string[] = [ 'www.google.com', 'randomuser.me', 'img.freepik.com', + 'redirector.gvt1.com', ]; function hostnameOf(url: string): string | null { diff --git a/locales/ar/common.json b/locales/ar/common.json index b19d3aeeb..f0b57fb42 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6558,6 +6558,85 @@ "save_as": "حفظ باسم…", "copy": "نسخ", "copy_failed": "تعذّر نسخ الملف." + }, + "menu": { + "file": "ملف", + "edit": "تحرير", + "view": "عرض", + "message": "رسالة", + "go": "انتقال", + "window": "نافذة", + "help": "مساعدة", + "new_window": "نافذة جديدة", + "print": "طباعة…", + "save_pdf": "حفظ بصيغة PDF…", + "switch_connection": "تغيير الاتصال…", + "close": "إغلاق", + "quit": "إنهاء", + "undo": "تراجع", + "redo": "إعادة", + "cut": "قص", + "copy": "نسخ", + "paste": "لصق", + "select_all": "تحديد الكل", + "search": "بحث في البريد", + "refresh": "التحقق من البريد الجديد", + "reading_pane": "جزء القراءة", + "pane_right": "على اليمين", + "pane_below": "أسفل القائمة", + "pane_off": "إيقاف", + "zoom_in": "تكبير", + "zoom_out": "تصغير", + "zoom_reset": "الحجم الفعلي", + "full_screen": "ملء الشاشة", + "open_in_window": "فتح في نافذة جديدة", + "next_message": "الرسالة التالية", + "previous_message": "الرسالة السابقة", + "next_unread": "الرسالة التالية غير المقروءة", + "mail": "البريد", + "files": "الملفات", + "back": "رجوع", + "forward": "للأمام", + "minimize": "تصغير", + "next_window": "النافذة التالية", + "shortcuts": "اختصارات لوحة المفاتيح", + "docs": "الوثائق", + "settings": "الإعدادات…", + "about": "حول Bulwark", + "from_plugins": "المزيد من الإضافات…", + "pick_gone": "هذه القائمة قديمة. افتحها مرة أخرى وحاول ثانية." + }, + "context": { + "add_to_dictionary": "إضافة «{word}» إلى القاموس", + "no_suggestions": "لا توجد اقتراحات", + "open_link": "فتح الرابط", + "copy_link": "نسخ الرابط", + "copy_address": "نسخ العنوان", + "save_image": "حفظ الصورة…", + "copy_image": "نسخ الصورة" + }, + "closing": { + "unsaved_title": "تعذّر حفظ مسودة", + "unsaved_detail": "إذا أغلقت الآن، سيضيع ما كتبته منذ آخر حفظ.", + "close_anyway": "إغلاق على أي حال", + "unsent_title": "توجد رسائل لم تُرسل بعد", + "unsent_one": "توجد رسالة واحدة في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.", + "unsent_many": "توجد {count} رسائل في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.", + "quit": "إنهاء", + "leave_title": "المغادرة مع تغييرات غير محفوظة؟", + "leave_detail": "لم تُحفظ بعد التغييرات التي أجريتها في هذه الصفحة.", + "leave": "مغادرة", + "stay": "بقاء" + }, + "spellcheck": { + "offer": "يحتاج التدقيق الإملائي بلغة {language} إلى قاموس. يمكن لـ Bulwark تنزيله من Google. لا يُرسل نصك.", + "download": "تنزيل" + }, + "restore": { + "unavailable_title": "تعذّر فتح هذه المسودة الآن", + "unavailable_body": "إنها محفوظة بأمان على الخادم في المسودات. حاول مرة أخرى عند عودة الاتصال.", + "try_again": "إعادة المحاولة", + "close": "إغلاق" } }, "desktop_sign_in": { diff --git a/locales/ca/common.json b/locales/ca/common.json index b4c960e21..556caead6 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6558,6 +6558,85 @@ "save_as": "Desa com a…", "copy": "Copia", "copy_failed": "No s’ha pogut copiar el fitxer." + }, + "menu": { + "file": "Fitxer", + "edit": "Edita", + "view": "Visualitza", + "message": "Missatge", + "go": "Vés-hi", + "window": "Finestra", + "help": "Ajuda", + "new_window": "Finestra nova", + "print": "Imprimeix…", + "save_pdf": "Desa com a PDF…", + "switch_connection": "Canvia de connexió…", + "close": "Tanca", + "quit": "Surt", + "undo": "Desfés", + "redo": "Refés", + "cut": "Retalla", + "copy": "Copia", + "paste": "Enganxa", + "select_all": "Selecciona-ho tot", + "search": "Cerca al correu", + "refresh": "Comprova si hi ha correu nou", + "reading_pane": "Tauler de lectura", + "pane_right": "A la dreta", + "pane_below": "Sota la llista", + "pane_off": "Desactivat", + "zoom_in": "Amplia", + "zoom_out": "Redueix", + "zoom_reset": "Mida real", + "full_screen": "Pantalla completa", + "open_in_window": "Obre en una finestra nova", + "next_message": "Missatge següent", + "previous_message": "Missatge anterior", + "next_unread": "Següent missatge no llegit", + "mail": "Correu", + "files": "Fitxers", + "back": "Enrere", + "forward": "Endavant", + "minimize": "Minimitza", + "next_window": "Finestra següent", + "shortcuts": "Dreceres de teclat", + "docs": "Documentació", + "settings": "Configuració…", + "about": "Quant a Bulwark", + "from_plugins": "Més dels connectors…", + "pick_gone": "Aquest menú estava desfasat. Torneu-lo a obrir i proveu-ho una altra vegada." + }, + "context": { + "add_to_dictionary": "Afegeix «{word}» al diccionari", + "no_suggestions": "Cap suggeriment", + "open_link": "Obre l’enllaç", + "copy_link": "Copia l’enllaç", + "copy_address": "Copia l’adreça", + "save_image": "Desa la imatge…", + "copy_image": "Copia la imatge" + }, + "closing": { + "unsaved_title": "No s’ha pogut desar un esborrany", + "unsaved_detail": "Si tanqueu ara, es perdrà el que heu escrit des de l’últim desament.", + "close_anyway": "Tanca igualment", + "unsent_title": "Hi ha correu sense enviar", + "unsent_one": "Hi ha un missatge pendent d’enviar. Se n’anirà la propera vegada que obriu Bulwark.", + "unsent_many": "Hi ha {count} missatges pendents d’enviar. Se n’aniran la propera vegada que obriu Bulwark.", + "quit": "Surt", + "leave_title": "Voleu sortir amb canvis sense desar?", + "leave_detail": "Els canvis fets en aquesta pàgina encara no s’han desat.", + "leave": "Surt", + "stay": "Queda’t" + }, + "spellcheck": { + "offer": "La revisió ortogràfica en {language} necessita un diccionari. Bulwark el pot baixar de Google. El vostre text no s’envia.", + "download": "Baixa" + }, + "restore": { + "unavailable_title": "Aquest esborrany no es pot obrir ara", + "unavailable_body": "Està segur al servidor, a Esborranys. Torneu-ho a provar quan hi hagi connexió.", + "try_again": "Torna-ho a provar", + "close": "Tanca" } }, "desktop_sign_in": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 28c8b0968..170d6ce85 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6558,6 +6558,85 @@ "save_as": "Uložit jako…", "copy": "Kopírovat", "copy_failed": "Soubor se nepodařilo zkopírovat." + }, + "menu": { + "file": "Soubor", + "edit": "Úpravy", + "view": "Zobrazení", + "message": "Zpráva", + "go": "Přejít", + "window": "Okno", + "help": "Nápověda", + "new_window": "Nové okno", + "print": "Tisk…", + "save_pdf": "Uložit jako PDF…", + "switch_connection": "Změnit připojení…", + "close": "Zavřít", + "quit": "Ukončit", + "undo": "Zpět", + "redo": "Znovu", + "cut": "Vyjmout", + "copy": "Kopírovat", + "paste": "Vložit", + "select_all": "Vybrat vše", + "search": "Hledat v poště", + "refresh": "Zkontrolovat novou poštu", + "reading_pane": "Podokno čtení", + "pane_right": "Vpravo", + "pane_below": "Pod seznamem", + "pane_off": "Vypnuto", + "zoom_in": "Přiblížit", + "zoom_out": "Oddálit", + "zoom_reset": "Skutečná velikost", + "full_screen": "Celá obrazovka", + "open_in_window": "Otevřít v novém okně", + "next_message": "Další zpráva", + "previous_message": "Předchozí zpráva", + "next_unread": "Další nepřečtená zpráva", + "mail": "Pošta", + "files": "Soubory", + "back": "Zpět", + "forward": "Vpřed", + "minimize": "Minimalizovat", + "next_window": "Další okno", + "shortcuts": "Klávesové zkratky", + "docs": "Dokumentace", + "settings": "Nastavení…", + "about": "O aplikaci Bulwark", + "from_plugins": "Více z doplňků…", + "pick_gone": "Tato nabídka byla zastaralá. Otevřete ji znovu a zkuste to ještě jednou." + }, + "context": { + "add_to_dictionary": "Přidat „{word}“ do slovníku", + "no_suggestions": "Žádné návrhy", + "open_link": "Otevřít odkaz", + "copy_link": "Kopírovat odkaz", + "copy_address": "Kopírovat adresu", + "save_image": "Uložit obrázek…", + "copy_image": "Kopírovat obrázek" + }, + "closing": { + "unsaved_title": "Koncept se nepodařilo uložit", + "unsaved_detail": "Pokud nyní okno zavřete, přijdete o to, co jste napsali od posledního uložení.", + "close_anyway": "Přesto zavřít", + "unsent_title": "Některá pošta ještě nebyla odeslána", + "unsent_one": "Jedna zpráva čeká na odeslání. Odejde při dalším spuštění Bulwark.", + "unsent_many": "Počet zpráv čekajících na odeslání: {count}. Odejdou při dalším spuštění Bulwark.", + "quit": "Ukončit", + "leave_title": "Odejít s neuloženými změnami?", + "leave_detail": "Změny na této stránce zatím nejsou uloženy.", + "leave": "Odejít", + "stay": "Zůstat" + }, + "spellcheck": { + "offer": "Kontrola pravopisu pro jazyk {language} potřebuje slovník. Bulwark ho může stáhnout od společnosti Google. Váš text se neodesílá.", + "download": "Stáhnout" + }, + "restore": { + "unavailable_title": "Tento koncept se teď nedá otevřít", + "unavailable_body": "Je v bezpečí na serveru ve složce Koncepty. Zkuste to znovu, až se obnoví připojení.", + "try_again": "Zkusit znovu", + "close": "Zavřít" } }, "desktop_sign_in": { diff --git a/locales/da/common.json b/locales/da/common.json index e47034d0e..0122e60fe 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6558,6 +6558,85 @@ "save_as": "Gem som…", "copy": "Kopiér", "copy_failed": "Filen kunne ikke kopieres." + }, + "menu": { + "file": "Filer", + "edit": "Rediger", + "view": "Vis", + "message": "Besked", + "go": "Gå", + "window": "Vindue", + "help": "Hjælp", + "new_window": "Nyt vindue", + "print": "Udskriv…", + "save_pdf": "Gem som PDF…", + "switch_connection": "Skift forbindelse…", + "close": "Luk", + "quit": "Afslut", + "undo": "Fortryd", + "redo": "Gentag", + "cut": "Klip", + "copy": "Kopiér", + "paste": "Indsæt", + "select_all": "Markér alt", + "search": "Søg i mail", + "refresh": "Hent nye mails", + "reading_pane": "Læserude", + "pane_right": "Til højre", + "pane_below": "Under listen", + "pane_off": "Fra", + "zoom_in": "Zoom ind", + "zoom_out": "Zoom ud", + "zoom_reset": "Faktisk størrelse", + "full_screen": "Fuld skærm", + "open_in_window": "Åbn i nyt vindue", + "next_message": "Næste besked", + "previous_message": "Forrige besked", + "next_unread": "Næste ulæste besked", + "mail": "Mail", + "files": "Filer", + "back": "Tilbage", + "forward": "Frem", + "minimize": "Minimer", + "next_window": "Næste vindue", + "shortcuts": "Tastaturgenveje", + "docs": "Dokumentation", + "settings": "Indstillinger…", + "about": "Om Bulwark", + "from_plugins": "Mere fra plugins…", + "pick_gone": "Den menu var forældet. Åbn den igen, og prøv en gang til." + }, + "context": { + "add_to_dictionary": "Føj “{word}” til ordbogen", + "no_suggestions": "Ingen forslag", + "open_link": "Åbn link", + "copy_link": "Kopiér link", + "copy_address": "Kopiér adresse", + "save_image": "Gem billede…", + "copy_image": "Kopiér billede" + }, + "closing": { + "unsaved_title": "En kladde kunne ikke gemmes", + "unsaved_detail": "Hvis du lukker nu, går det tabt, du har skrevet siden sidste gang, du gemte.", + "close_anyway": "Luk alligevel", + "unsent_title": "Nogle mails er ikke sendt endnu", + "unsent_one": "Én besked venter på at blive sendt. Den bliver sendt, næste gang du åbner Bulwark.", + "unsent_many": "{count} beskeder venter på at blive sendt. De bliver sendt, næste gang du åbner Bulwark.", + "quit": "Afslut", + "leave_title": "Forlad siden med ugemte ændringer?", + "leave_detail": "Dine ændringer på denne side er ikke gemt endnu.", + "leave": "Forlad", + "stay": "Bliv" + }, + "spellcheck": { + "offer": "Stavekontrol på {language} kræver en ordbog. Bulwark kan hente den fra Google. Din tekst bliver ikke sendt.", + "download": "Hent" + }, + "restore": { + "unavailable_title": "Denne kladde kan ikke åbnes lige nu", + "unavailable_body": "Den er sikker på serveren i Kladder. Prøv igen, når forbindelsen er tilbage.", + "try_again": "Prøv igen", + "close": "Luk" } }, "desktop_sign_in": { diff --git a/locales/de/common.json b/locales/de/common.json index ca3134e5d..fa98785e4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6558,6 +6558,85 @@ "save_as": "Speichern unter…", "copy": "Kopieren", "copy_failed": "Die Datei konnte nicht kopiert werden." + }, + "menu": { + "file": "Datei", + "edit": "Bearbeiten", + "view": "Ansicht", + "message": "Nachricht", + "go": "Gehe zu", + "window": "Fenster", + "help": "Hilfe", + "new_window": "Neues Fenster", + "print": "Drucken…", + "save_pdf": "Als PDF sichern…", + "switch_connection": "Verbindung wechseln…", + "close": "Schließen", + "quit": "Beenden", + "undo": "Rückgängig", + "redo": "Wiederholen", + "cut": "Ausschneiden", + "copy": "Kopieren", + "paste": "Einfügen", + "select_all": "Alles auswählen", + "search": "E-Mails durchsuchen", + "refresh": "Neue E-Mails abrufen", + "reading_pane": "Lesebereich", + "pane_right": "Rechts", + "pane_below": "Unter der Liste", + "pane_off": "Aus", + "zoom_in": "Vergrößern", + "zoom_out": "Verkleinern", + "zoom_reset": "Tatsächliche Größe", + "full_screen": "Vollbild", + "open_in_window": "In neuem Fenster öffnen", + "next_message": "Nächste Nachricht", + "previous_message": "Vorherige Nachricht", + "next_unread": "Nächste ungelesene Nachricht", + "mail": "E-Mail", + "files": "Dateien", + "back": "Zurück", + "forward": "Vorwärts", + "minimize": "Minimieren", + "next_window": "Nächstes Fenster", + "shortcuts": "Tastenkürzel", + "docs": "Dokumentation", + "settings": "Einstellungen…", + "about": "Über Bulwark", + "from_plugins": "Mehr von Plugins…", + "pick_gone": "Dieses Menü war veraltet. Öffnen Sie es erneut und versuchen Sie es noch einmal." + }, + "context": { + "add_to_dictionary": "„{word}“ zum Wörterbuch hinzufügen", + "no_suggestions": "Keine Vorschläge", + "open_link": "Link öffnen", + "copy_link": "Link kopieren", + "copy_address": "Adresse kopieren", + "save_image": "Bild sichern…", + "copy_image": "Bild kopieren" + }, + "closing": { + "unsaved_title": "Ein Entwurf konnte nicht gespeichert werden", + "unsaved_detail": "Wenn Sie jetzt schließen, geht verloren, was Sie seit dem letzten Speichern geschrieben haben.", + "close_anyway": "Trotzdem schließen", + "unsent_title": "Einige E-Mails wurden noch nicht gesendet", + "unsent_one": "Eine Nachricht wartet auf den Versand. Sie wird beim nächsten Start von Bulwark gesendet.", + "unsent_many": "{count} Nachrichten warten auf den Versand. Sie werden beim nächsten Start von Bulwark gesendet.", + "quit": "Beenden", + "leave_title": "Mit ungespeicherten Änderungen verlassen?", + "leave_detail": "Ihre Änderungen auf dieser Seite sind noch nicht gespeichert.", + "leave": "Verlassen", + "stay": "Bleiben" + }, + "spellcheck": { + "offer": "Die Rechtschreibprüfung für {language} benötigt ein Wörterbuch. Bulwark kann es von Google herunterladen. Ihr Text wird nicht gesendet.", + "download": "Herunterladen" + }, + "restore": { + "unavailable_title": "Dieser Entwurf kann gerade nicht geöffnet werden", + "unavailable_body": "Er ist auf dem Server im Ordner „Entwürfe“ gesichert. Versuchen Sie es erneut, sobald die Verbindung wieder besteht.", + "try_again": "Erneut versuchen", + "close": "Schließen" } }, "desktop_sign_in": { diff --git a/locales/en/common.json b/locales/en/common.json index e178107a0..6112c3ddd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6558,6 +6558,85 @@ "save_as": "Save as…", "copy": "Copy", "copy_failed": "The file could not be copied." + }, + "menu": { + "file": "File", + "edit": "Edit", + "view": "View", + "message": "Message", + "go": "Go", + "window": "Window", + "help": "Help", + "new_window": "New Window", + "print": "Print…", + "save_pdf": "Save as PDF…", + "switch_connection": "Change Connection…", + "close": "Close", + "quit": "Quit", + "undo": "Undo", + "redo": "Redo", + "cut": "Cut", + "copy": "Copy", + "paste": "Paste", + "select_all": "Select All", + "search": "Search Mail", + "refresh": "Check for New Mail", + "reading_pane": "Reading Pane", + "pane_right": "On the Right", + "pane_below": "Below the List", + "pane_off": "Off", + "zoom_in": "Zoom In", + "zoom_out": "Zoom Out", + "zoom_reset": "Actual Size", + "full_screen": "Full Screen", + "open_in_window": "Open in New Window", + "next_message": "Next Message", + "previous_message": "Previous Message", + "next_unread": "Next Unread Message", + "mail": "Mail", + "files": "Files", + "back": "Back", + "forward": "Forward", + "minimize": "Minimize", + "next_window": "Next Window", + "shortcuts": "Keyboard Shortcuts", + "docs": "Documentation", + "settings": "Settings…", + "about": "About Bulwark", + "from_plugins": "More from plugins…", + "pick_gone": "That menu was out of date. Open it again and try once more." + }, + "context": { + "add_to_dictionary": "Add \"{word}\" to Dictionary", + "no_suggestions": "No Suggestions", + "open_link": "Open Link", + "copy_link": "Copy Link", + "copy_address": "Copy Address", + "save_image": "Save Image…", + "copy_image": "Copy Image" + }, + "closing": { + "unsaved_title": "A draft could not be saved", + "unsaved_detail": "If you close now, what you wrote since the last save is lost.", + "close_anyway": "Close anyway", + "unsent_title": "Some mail has not been sent yet", + "unsent_one": "One message is waiting to be sent. It goes out the next time you open Bulwark.", + "unsent_many": "{count} messages are waiting to be sent. They go out the next time you open Bulwark.", + "quit": "Quit", + "leave_title": "Leave with unsaved changes?", + "leave_detail": "What you changed on this page is not saved yet.", + "leave": "Leave", + "stay": "Stay" + }, + "spellcheck": { + "offer": "Spell checking in {language} needs a dictionary. Bulwark can download it from Google. Your text isn't sent.", + "download": "Download" + }, + "restore": { + "unavailable_title": "This draft can't be opened right now", + "unavailable_body": "It's safe on the server, in Drafts. Try again when the connection is back.", + "try_again": "Try again", + "close": "Close" } }, "desktop_sign_in": { diff --git a/locales/es/common.json b/locales/es/common.json index 92ae5cfc1..7b1a9627c 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6558,6 +6558,85 @@ "save_as": "Guardar como…", "copy": "Copiar", "copy_failed": "No se pudo copiar el archivo." + }, + "menu": { + "file": "Archivo", + "edit": "Edición", + "view": "Visualización", + "message": "Mensaje", + "go": "Ir", + "window": "Ventana", + "help": "Ayuda", + "new_window": "Nueva ventana", + "print": "Imprimir…", + "save_pdf": "Guardar como PDF…", + "switch_connection": "Cambiar de conexión…", + "close": "Cerrar", + "quit": "Salir", + "undo": "Deshacer", + "redo": "Rehacer", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Pegar", + "select_all": "Seleccionar todo", + "search": "Buscar en el correo", + "refresh": "Comprobar correo nuevo", + "reading_pane": "Panel de lectura", + "pane_right": "A la derecha", + "pane_below": "Debajo de la lista", + "pane_off": "Desactivado", + "zoom_in": "Acercar", + "zoom_out": "Alejar", + "zoom_reset": "Tamaño real", + "full_screen": "Pantalla completa", + "open_in_window": "Abrir en una ventana nueva", + "next_message": "Mensaje siguiente", + "previous_message": "Mensaje anterior", + "next_unread": "Siguiente mensaje no leído", + "mail": "Correo", + "files": "Archivos", + "back": "Atrás", + "forward": "Adelante", + "minimize": "Minimizar", + "next_window": "Ventana siguiente", + "shortcuts": "Métodos abreviados de teclado", + "docs": "Documentación", + "settings": "Configuración…", + "about": "Acerca de Bulwark", + "from_plugins": "Más de los complementos…", + "pick_gone": "Ese menú estaba desactualizado. Ábralo de nuevo e inténtelo otra vez." + }, + "context": { + "add_to_dictionary": "Añadir «{word}» al diccionario", + "no_suggestions": "Sin sugerencias", + "open_link": "Abrir enlace", + "copy_link": "Copiar enlace", + "copy_address": "Copiar dirección", + "save_image": "Guardar imagen…", + "copy_image": "Copiar imagen" + }, + "closing": { + "unsaved_title": "No se pudo guardar un borrador", + "unsaved_detail": "Si cierra ahora, se perderá lo que escribió desde el último guardado.", + "close_anyway": "Cerrar de todos modos", + "unsent_title": "Hay correo sin enviar", + "unsent_one": "Hay un mensaje esperando para enviarse. Saldrá la próxima vez que abra Bulwark.", + "unsent_many": "Hay {count} mensajes esperando para enviarse. Saldrán la próxima vez que abra Bulwark.", + "quit": "Salir", + "leave_title": "¿Salir con cambios sin guardar?", + "leave_detail": "Los cambios que hizo en esta página aún no se han guardado.", + "leave": "Salir", + "stay": "Quedarse" + }, + "spellcheck": { + "offer": "La revisión ortográfica en {language} necesita un diccionario. Bulwark puede descargarlo de Google. Su texto no se envía.", + "download": "Descargar" + }, + "restore": { + "unavailable_title": "Este borrador no se puede abrir ahora", + "unavailable_body": "Está a salvo en el servidor, en Borradores. Inténtelo de nuevo cuando vuelva la conexión.", + "try_again": "Reintentar", + "close": "Cerrar" } }, "desktop_sign_in": { diff --git a/locales/et/common.json b/locales/et/common.json index af5abde97..5933106bb 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salvesta nimega…", "copy": "Kopeeri", "copy_failed": "Faili ei saanud kopeerida." + }, + "menu": { + "file": "Fail", + "edit": "Redigeerimine", + "view": "Vaade", + "message": "Kiri", + "go": "Mine", + "window": "Aken", + "help": "Spikker", + "new_window": "Uus aken", + "print": "Prindi…", + "save_pdf": "Salvesta PDF-ina…", + "switch_connection": "Vaheta ühendust…", + "close": "Sulge", + "quit": "Välju", + "undo": "Võta tagasi", + "redo": "Tee uuesti", + "cut": "Lõika", + "copy": "Kopeeri", + "paste": "Kleebi", + "select_all": "Vali kõik", + "search": "Otsi kirjadest", + "refresh": "Kontrolli uusi kirju", + "reading_pane": "Lugemisvaade", + "pane_right": "Paremal", + "pane_below": "Loendi all", + "pane_off": "Väljas", + "zoom_in": "Suurenda", + "zoom_out": "Vähenda", + "zoom_reset": "Tegelik suurus", + "full_screen": "Täisekraan", + "open_in_window": "Ava uues aknas", + "next_message": "Järgmine kiri", + "previous_message": "Eelmine kiri", + "next_unread": "Järgmine lugemata kiri", + "mail": "Post", + "files": "Failid", + "back": "Tagasi", + "forward": "Edasi", + "minimize": "Minimeeri", + "next_window": "Järgmine aken", + "shortcuts": "Klaviatuuri otseteed", + "docs": "Dokumentatsioon", + "settings": "Seaded…", + "about": "Teave Bulwarki kohta", + "from_plugins": "Veel pistikprogrammidelt…", + "pick_gone": "See menüü oli aegunud. Avage see uuesti ja proovige veel kord." + }, + "context": { + "add_to_dictionary": "Lisa „{word}” sõnastikku", + "no_suggestions": "Soovitusi pole", + "open_link": "Ava link", + "copy_link": "Kopeeri link", + "copy_address": "Kopeeri aadress", + "save_image": "Salvesta pilt…", + "copy_image": "Kopeeri pilt" + }, + "closing": { + "unsaved_title": "Mustandit ei õnnestunud salvestada", + "unsaved_detail": "Kui sulgete nüüd, läheb kaduma see, mille olete pärast viimast salvestamist kirjutanud.", + "close_anyway": "Sulge siiski", + "unsent_title": "Mõni kiri on veel saatmata", + "unsent_one": "Üks kiri ootab saatmist. See läheb teele järgmine kord, kui Bulwarki avate.", + "unsent_many": "{count} kirja ootab saatmist. Need lähevad teele järgmine kord, kui Bulwarki avate.", + "quit": "Välju", + "leave_title": "Lahkuda salvestamata muudatustega?", + "leave_detail": "Selle lehe muudatused pole veel salvestatud.", + "leave": "Lahku", + "stay": "Jää" + }, + "spellcheck": { + "offer": "Õigekirjakontroll keelele {language} vajab sõnastikku. Bulwark saab selle Google’ilt alla laadida. Teie teksti ei saadeta.", + "download": "Laadi alla" + }, + "restore": { + "unavailable_title": "Seda mustandit ei saa praegu avada", + "unavailable_body": "See on serveris kaustas Mustandid turvaliselt alles. Proovige uuesti, kui ühendus on tagasi.", + "try_again": "Proovi uuesti", + "close": "Sulge" } }, "desktop_sign_in": { diff --git a/locales/fa/common.json b/locales/fa/common.json index b25da5a9b..e7ed42adf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6558,6 +6558,85 @@ "save_as": "ذخیره با نام…", "copy": "کپی", "copy_failed": "فایل کپی نشد." + }, + "menu": { + "file": "پرونده", + "edit": "ویرایش", + "view": "نما", + "message": "پیام", + "go": "برو به", + "window": "پنجره", + "help": "راهنما", + "new_window": "پنجره جدید", + "print": "چاپ…", + "save_pdf": "ذخیره به‌صورت PDF…", + "switch_connection": "تغییر اتصال…", + "close": "بستن", + "quit": "خروج", + "undo": "واگرد", + "redo": "ازنو", + "cut": "برش", + "copy": "رونوشت", + "paste": "چسباندن", + "select_all": "انتخاب همه", + "search": "جستجو در نامه‌ها", + "refresh": "بررسی نامه‌های جدید", + "reading_pane": "قاب خواندن", + "pane_right": "سمت راست", + "pane_below": "زیر فهرست", + "pane_off": "خاموش", + "zoom_in": "بزرگ‌نمایی", + "zoom_out": "کوچک‌نمایی", + "zoom_reset": "اندازه واقعی", + "full_screen": "تمام‌صفحه", + "open_in_window": "باز کردن در پنجره جدید", + "next_message": "پیام بعدی", + "previous_message": "پیام قبلی", + "next_unread": "پیام خوانده‌نشده بعدی", + "mail": "نامه", + "files": "پرونده‌ها", + "back": "بازگشت", + "forward": "جلو", + "minimize": "کوچک کردن", + "next_window": "پنجره بعدی", + "shortcuts": "میان‌برهای صفحه‌کلید", + "docs": "مستندات", + "settings": "تنظیمات…", + "about": "درباره Bulwark", + "from_plugins": "موارد بیشتر از افزونه‌ها…", + "pick_gone": "این منو قدیمی شده بود. دوباره آن را باز کنید و یک بار دیگر امتحان کنید." + }, + "context": { + "add_to_dictionary": "افزودن «{word}» به واژه‌نامه", + "no_suggestions": "پیشنهادی نیست", + "open_link": "باز کردن پیوند", + "copy_link": "رونوشت پیوند", + "copy_address": "رونوشت نشانی", + "save_image": "ذخیره تصویر…", + "copy_image": "رونوشت تصویر" + }, + "closing": { + "unsaved_title": "یک پیش‌نویس ذخیره نشد", + "unsaved_detail": "اگر اکنون ببندید، آنچه از آخرین ذخیره نوشته‌اید از بین می‌رود.", + "close_anyway": "با این حال بسته شود", + "unsent_title": "برخی نامه‌ها هنوز ارسال نشده‌اند", + "unsent_one": "یک پیام در انتظار ارسال است. دفعه بعد که Bulwark را باز کنید ارسال می‌شود.", + "unsent_many": "{count} پیام در انتظار ارسال هستند. دفعه بعد که Bulwark را باز کنید ارسال می‌شوند.", + "quit": "خروج", + "leave_title": "با تغییرات ذخیره‌نشده خارج می‌شوید؟", + "leave_detail": "تغییراتی که در این صفحه داده‌اید هنوز ذخیره نشده است.", + "leave": "خروج", + "stay": "ماندن" + }, + "spellcheck": { + "offer": "بررسی املای {language} به واژه‌نامه نیاز دارد. Bulwark می‌تواند آن را از Google دانلود کند. متن شما ارسال نمی‌شود.", + "download": "دانلود" + }, + "restore": { + "unavailable_title": "اکنون نمی‌توان این پیش‌نویس را باز کرد", + "unavailable_body": "این پیش‌نویس در پوشه پیش‌نویس‌ها روی سرور سالم است. وقتی اتصال برگشت دوباره امتحان کنید.", + "try_again": "تلاش دوباره", + "close": "بستن" } }, "desktop_sign_in": { diff --git a/locales/fr/common.json b/locales/fr/common.json index a283bf2a2..db3f72230 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6558,6 +6558,85 @@ "save_as": "Enregistrer sous…", "copy": "Copier", "copy_failed": "Impossible de copier le fichier." + }, + "menu": { + "file": "Fichier", + "edit": "Édition", + "view": "Présentation", + "message": "Message", + "go": "Aller", + "window": "Fenêtre", + "help": "Aide", + "new_window": "Nouvelle fenêtre", + "print": "Imprimer…", + "save_pdf": "Enregistrer au format PDF…", + "switch_connection": "Changer de connexion…", + "close": "Fermer", + "quit": "Quitter", + "undo": "Annuler", + "redo": "Rétablir", + "cut": "Couper", + "copy": "Copier", + "paste": "Coller", + "select_all": "Tout sélectionner", + "search": "Rechercher dans les messages", + "refresh": "Relever le courrier", + "reading_pane": "Volet de lecture", + "pane_right": "À droite", + "pane_below": "Sous la liste", + "pane_off": "Désactivé", + "zoom_in": "Zoom avant", + "zoom_out": "Zoom arrière", + "zoom_reset": "Taille réelle", + "full_screen": "Plein écran", + "open_in_window": "Ouvrir dans une nouvelle fenêtre", + "next_message": "Message suivant", + "previous_message": "Message précédent", + "next_unread": "Prochain message non lu", + "mail": "Courrier", + "files": "Fichiers", + "back": "Précédent", + "forward": "Suivant", + "minimize": "Réduire", + "next_window": "Fenêtre suivante", + "shortcuts": "Raccourcis clavier", + "docs": "Documentation", + "settings": "Paramètres…", + "about": "À propos de Bulwark", + "from_plugins": "Plus d’options des modules…", + "pick_gone": "Ce menu n’était plus à jour. Rouvrez-le et réessayez." + }, + "context": { + "add_to_dictionary": "Ajouter « {word} » au dictionnaire", + "no_suggestions": "Aucune suggestion", + "open_link": "Ouvrir le lien", + "copy_link": "Copier le lien", + "copy_address": "Copier l’adresse", + "save_image": "Enregistrer l’image…", + "copy_image": "Copier l’image" + }, + "closing": { + "unsaved_title": "Un brouillon n’a pas pu être enregistré", + "unsaved_detail": "Si vous fermez maintenant, ce que vous avez écrit depuis le dernier enregistrement sera perdu.", + "close_anyway": "Fermer quand même", + "unsent_title": "Des messages n’ont pas encore été envoyés", + "unsent_one": "Un message attend d’être envoyé. Il partira à la prochaine ouverture de Bulwark.", + "unsent_many": "{count} messages attendent d’être envoyés. Ils partiront à la prochaine ouverture de Bulwark.", + "quit": "Quitter", + "leave_title": "Quitter sans enregistrer les modifications ?", + "leave_detail": "Vos modifications sur cette page ne sont pas encore enregistrées.", + "leave": "Quitter la page", + "stay": "Rester" + }, + "spellcheck": { + "offer": "La correction orthographique en {language} nécessite un dictionnaire. Bulwark peut le télécharger depuis Google. Votre texte n’est pas envoyé.", + "download": "Télécharger" + }, + "restore": { + "unavailable_title": "Ce brouillon ne peut pas être ouvert pour le moment", + "unavailable_body": "Il est en sécurité sur le serveur, dans Brouillons. Réessayez quand la connexion sera rétablie.", + "try_again": "Réessayer", + "close": "Fermer" } }, "desktop_sign_in": { diff --git a/locales/he/common.json b/locales/he/common.json index 8ca0148b4..cf7d7cafc 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6559,6 +6559,85 @@ "save_as": "שמירה בשם…", "copy": "העתקה", "copy_failed": "לא ניתן היה להעתיק את הקובץ." + }, + "menu": { + "file": "קובץ", + "edit": "עריכה", + "view": "תצוגה", + "message": "הודעה", + "go": "מעבר", + "window": "חלון", + "help": "עזרה", + "new_window": "חלון חדש", + "print": "הדפסה…", + "save_pdf": "שמירה כ-PDF…", + "switch_connection": "שינוי חיבור…", + "close": "סגירה", + "quit": "יציאה", + "undo": "ביטול", + "redo": "ביצוע חוזר", + "cut": "גזירה", + "copy": "העתקה", + "paste": "הדבקה", + "select_all": "בחירת הכול", + "search": "חיפוש בדואר", + "refresh": "בדיקת דואר חדש", + "reading_pane": "חלונית קריאה", + "pane_right": "מימין", + "pane_below": "מתחת לרשימה", + "pane_off": "כבויה", + "zoom_in": "הגדלה", + "zoom_out": "הקטנה", + "zoom_reset": "גודל בפועל", + "full_screen": "מסך מלא", + "open_in_window": "פתיחה בחלון חדש", + "next_message": "ההודעה הבאה", + "previous_message": "ההודעה הקודמת", + "next_unread": "ההודעה הבאה שלא נקראה", + "mail": "דואר", + "files": "קבצים", + "back": "חזרה", + "forward": "קדימה", + "minimize": "מזעור", + "next_window": "החלון הבא", + "shortcuts": "קיצורי מקלדת", + "docs": "תיעוד", + "settings": "הגדרות…", + "about": "מידע על Bulwark", + "from_plugins": "עוד מהתוספים…", + "pick_gone": "התפריט הזה היה מיושן. פתחו אותו שוב ונסו פעם נוספת." + }, + "context": { + "add_to_dictionary": "הוספת “{word}” למילון", + "no_suggestions": "אין הצעות", + "open_link": "פתיחת קישור", + "copy_link": "העתקת קישור", + "copy_address": "העתקת כתובת", + "save_image": "שמירת תמונה…", + "copy_image": "העתקת תמונה" + }, + "closing": { + "unsaved_title": "לא ניתן היה לשמור טיוטה", + "unsaved_detail": "אם תסגרו עכשיו, מה שכתבתם מאז השמירה האחרונה יאבד.", + "close_anyway": "לסגור בכל זאת", + "unsent_title": "יש הודעות שעדיין לא נשלחו", + "unsent_one": "הודעה אחת ממתינה לשליחה. היא תישלח בפעם הבאה שתפתחו את Bulwark.", + "unsent_many": "{count} הודעות ממתינות לשליחה. הן יישלחו בפעם הבאה שתפתחו את Bulwark.", + "quit": "יציאה", + "leave_title": "לעזוב עם שינויים שלא נשמרו?", + "leave_detail": "השינויים שביצעתם בדף הזה עדיין לא נשמרו.", + "leave": "עזיבה", + "stay": "להישאר" + }, + "spellcheck": { + "offer": "בדיקת איות ב{language} דורשת מילון. Bulwark יכול להוריד אותו מ-Google. הטקסט שלכם לא נשלח.", + "download": "הורדה" + }, + "restore": { + "unavailable_title": "אי אפשר לפתוח את הטיוטה הזאת כרגע", + "unavailable_body": "היא שמורה בשרת, בתיקיית הטיוטות. נסו שוב כשהחיבור יחזור.", + "try_again": "ניסיון חוזר", + "close": "סגירה" } }, "desktop_sign_in": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 0459a57b5..8de7b9d63 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6558,6 +6558,85 @@ "save_as": "Mentés másként…", "copy": "Másolás", "copy_failed": "A fájlt nem sikerült másolni." + }, + "menu": { + "file": "Fájl", + "edit": "Szerkesztés", + "view": "Nézet", + "message": "Üzenet", + "go": "Ugrás", + "window": "Ablak", + "help": "Súgó", + "new_window": "Új ablak", + "print": "Nyomtatás…", + "save_pdf": "Mentés PDF-ként…", + "switch_connection": "Kapcsolat váltása…", + "close": "Bezárás", + "quit": "Kilépés", + "undo": "Visszavonás", + "redo": "Ismétlés", + "cut": "Kivágás", + "copy": "Másolás", + "paste": "Beillesztés", + "select_all": "Összes kijelölése", + "search": "Keresés a levelek között", + "refresh": "Új levelek lekérése", + "reading_pane": "Olvasópanel", + "pane_right": "Jobbra", + "pane_below": "A lista alatt", + "pane_off": "Ki", + "zoom_in": "Nagyítás", + "zoom_out": "Kicsinyítés", + "zoom_reset": "Tényleges méret", + "full_screen": "Teljes képernyő", + "open_in_window": "Megnyitás új ablakban", + "next_message": "Következő üzenet", + "previous_message": "Előző üzenet", + "next_unread": "Következő olvasatlan üzenet", + "mail": "Levelek", + "files": "Fájlok", + "back": "Vissza", + "forward": "Előre", + "minimize": "Minimalizálás", + "next_window": "Következő ablak", + "shortcuts": "Billentyűparancsok", + "docs": "Dokumentáció", + "settings": "Beállítások…", + "about": "A Bulwark névjegye", + "from_plugins": "Továbbiak a bővítményektől…", + "pick_gone": "Ez a menü elavult volt. Nyissa meg újra, és próbálja meg még egyszer." + }, + "context": { + "add_to_dictionary": "„{word}” hozzáadása a szótárhoz", + "no_suggestions": "Nincs javaslat", + "open_link": "Hivatkozás megnyitása", + "copy_link": "Hivatkozás másolása", + "copy_address": "Cím másolása", + "save_image": "Kép mentése…", + "copy_image": "Kép másolása" + }, + "closing": { + "unsaved_title": "Egy piszkozatot nem sikerült menteni", + "unsaved_detail": "Ha most bezárja, elvész, amit az utolsó mentés óta írt.", + "close_anyway": "Bezárás mégis", + "unsent_title": "Néhány levél még nincs elküldve", + "unsent_one": "Egy üzenet küldésre vár. A Bulwark következő megnyitásakor elmegy.", + "unsent_many": "{count} üzenet küldésre vár. A Bulwark következő megnyitásakor elmennek.", + "quit": "Kilépés", + "leave_title": "Elhagyja az oldalt mentetlen módosításokkal?", + "leave_detail": "Az oldalon végzett módosítások még nincsenek mentve.", + "leave": "Elhagyás", + "stay": "Maradás" + }, + "spellcheck": { + "offer": "A(z) {language} nyelvű helyesírás-ellenőrzéshez szótár kell. A Bulwark letöltheti a Google-tól. A szövege nem kerül elküldésre.", + "download": "Letöltés" + }, + "restore": { + "unavailable_title": "Ez a piszkozat most nem nyitható meg", + "unavailable_body": "Biztonságban van a kiszolgálón, a Piszkozatok mappában. Próbálja újra, ha visszatért a kapcsolat.", + "try_again": "Újrapróbálás", + "close": "Bezárás" } }, "desktop_sign_in": { diff --git a/locales/it/common.json b/locales/it/common.json index 95db2212c..a5a66b6ab 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salva con nome…", "copy": "Copia", "copy_failed": "Impossibile copiare il file." + }, + "menu": { + "file": "File", + "edit": "Modifica", + "view": "Vista", + "message": "Messaggio", + "go": "Vai", + "window": "Finestra", + "help": "Aiuto", + "new_window": "Nuova finestra", + "print": "Stampa…", + "save_pdf": "Salva come PDF…", + "switch_connection": "Cambia connessione…", + "close": "Chiudi", + "quit": "Esci", + "undo": "Annulla", + "redo": "Ripeti", + "cut": "Taglia", + "copy": "Copia", + "paste": "Incolla", + "select_all": "Seleziona tutto", + "search": "Cerca nella posta", + "refresh": "Controlla nuova posta", + "reading_pane": "Pannello di lettura", + "pane_right": "A destra", + "pane_below": "Sotto l’elenco", + "pane_off": "Disattivato", + "zoom_in": "Ingrandisci", + "zoom_out": "Riduci", + "zoom_reset": "Dimensione effettiva", + "full_screen": "Schermo intero", + "open_in_window": "Apri in una nuova finestra", + "next_message": "Messaggio successivo", + "previous_message": "Messaggio precedente", + "next_unread": "Prossimo messaggio non letto", + "mail": "Posta", + "files": "File", + "back": "Indietro", + "forward": "Avanti", + "minimize": "Riduci a icona", + "next_window": "Finestra successiva", + "shortcuts": "Scorciatoie da tastiera", + "docs": "Documentazione", + "settings": "Impostazioni…", + "about": "Informazioni su Bulwark", + "from_plugins": "Altro dai plugin…", + "pick_gone": "Questo menu non era aggiornato. Riaprilo e riprova." + }, + "context": { + "add_to_dictionary": "Aggiungi «{word}» al dizionario", + "no_suggestions": "Nessun suggerimento", + "open_link": "Apri link", + "copy_link": "Copia link", + "copy_address": "Copia indirizzo", + "save_image": "Salva immagine…", + "copy_image": "Copia immagine" + }, + "closing": { + "unsaved_title": "Impossibile salvare una bozza", + "unsaved_detail": "Se chiudi ora, quello che hai scritto dall’ultimo salvataggio andrà perso.", + "close_anyway": "Chiudi comunque", + "unsent_title": "Alcuni messaggi non sono ancora stati inviati", + "unsent_one": "Un messaggio è in attesa di invio. Partirà alla prossima apertura di Bulwark.", + "unsent_many": "{count} messaggi sono in attesa di invio. Partiranno alla prossima apertura di Bulwark.", + "quit": "Esci", + "leave_title": "Uscire con modifiche non salvate?", + "leave_detail": "Le modifiche fatte in questa pagina non sono ancora salvate.", + "leave": "Esci", + "stay": "Rimani" + }, + "spellcheck": { + "offer": "Il controllo ortografico in {language} richiede un dizionario. Bulwark può scaricarlo da Google. Il tuo testo non viene inviato.", + "download": "Scarica" + }, + "restore": { + "unavailable_title": "Questa bozza non può essere aperta ora", + "unavailable_body": "È al sicuro sul server, in Bozze. Riprova quando la connessione sarà tornata.", + "try_again": "Riprova", + "close": "Chiudi" } }, "desktop_sign_in": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 78070a2f8..47229b06d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6558,6 +6558,85 @@ "save_as": "名前を付けて保存…", "copy": "コピー", "copy_failed": "ファイルをコピーできませんでした。" + }, + "menu": { + "file": "ファイル", + "edit": "編集", + "view": "表示", + "message": "メッセージ", + "go": "移動", + "window": "ウインドウ", + "help": "ヘルプ", + "new_window": "新規ウインドウ", + "print": "印刷…", + "save_pdf": "PDFとして保存…", + "switch_connection": "接続を変更…", + "close": "閉じる", + "quit": "終了", + "undo": "取り消す", + "redo": "やり直す", + "cut": "切り取り", + "copy": "コピー", + "paste": "貼り付け", + "select_all": "すべてを選択", + "search": "メールを検索", + "refresh": "新着メールを確認", + "reading_pane": "閲覧ウインドウ", + "pane_right": "右側", + "pane_below": "リストの下", + "pane_off": "オフ", + "zoom_in": "拡大", + "zoom_out": "縮小", + "zoom_reset": "実際のサイズ", + "full_screen": "フルスクリーン", + "open_in_window": "新規ウインドウで開く", + "next_message": "次のメッセージ", + "previous_message": "前のメッセージ", + "next_unread": "次の未読メッセージ", + "mail": "メール", + "files": "ファイル", + "back": "戻る", + "forward": "進む", + "minimize": "最小化", + "next_window": "次のウインドウ", + "shortcuts": "キーボードショートカット", + "docs": "ドキュメント", + "settings": "設定…", + "about": "Bulwarkについて", + "from_plugins": "プラグインのその他の項目…", + "pick_gone": "このメニューは古くなっています。もう一度開いて、やり直してください。" + }, + "context": { + "add_to_dictionary": "「{word}」を辞書に追加", + "no_suggestions": "候補なし", + "open_link": "リンクを開く", + "copy_link": "リンクをコピー", + "copy_address": "アドレスをコピー", + "save_image": "画像を保存…", + "copy_image": "画像をコピー" + }, + "closing": { + "unsaved_title": "下書きを保存できませんでした", + "unsaved_detail": "今閉じると、最後に保存した後に書いた内容は失われます。", + "close_anyway": "このまま閉じる", + "unsent_title": "未送信のメールがあります", + "unsent_one": "1件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。", + "unsent_many": "{count}件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。", + "quit": "終了", + "leave_title": "保存されていない変更を残して移動しますか?", + "leave_detail": "このページで行った変更はまだ保存されていません。", + "leave": "移動する", + "stay": "とどまる" + }, + "spellcheck": { + "offer": "{language}のスペルチェックには辞書が必要です。BulwarkはGoogleから辞書をダウンロードできます。入力した文章は送信されません。", + "download": "ダウンロード" + }, + "restore": { + "unavailable_title": "この下書きは今は開けません", + "unavailable_body": "下書きはサーバーの「下書き」に安全に保管されています。接続が戻ってから、もう一度お試しください。", + "try_again": "再試行", + "close": "閉じる" } }, "desktop_sign_in": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 96a87153f..ea74e31b0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6558,6 +6558,85 @@ "save_as": "다른 이름으로 저장…", "copy": "복사", "copy_failed": "파일을 복사할 수 없습니다." + }, + "menu": { + "file": "파일", + "edit": "편집", + "view": "보기", + "message": "메시지", + "go": "이동", + "window": "창", + "help": "도움말", + "new_window": "새 창", + "print": "인쇄…", + "save_pdf": "PDF로 저장…", + "switch_connection": "연결 변경…", + "close": "닫기", + "quit": "종료", + "undo": "실행 취소", + "redo": "다시 실행", + "cut": "잘라내기", + "copy": "복사", + "paste": "붙여넣기", + "select_all": "모두 선택", + "search": "메일 검색", + "refresh": "새 메일 확인", + "reading_pane": "읽기 창", + "pane_right": "오른쪽", + "pane_below": "목록 아래", + "pane_off": "끄기", + "zoom_in": "확대", + "zoom_out": "축소", + "zoom_reset": "실제 크기", + "full_screen": "전체 화면", + "open_in_window": "새 창에서 열기", + "next_message": "다음 메시지", + "previous_message": "이전 메시지", + "next_unread": "다음 읽지 않은 메시지", + "mail": "메일", + "files": "파일", + "back": "뒤로", + "forward": "앞으로", + "minimize": "최소화", + "next_window": "다음 창", + "shortcuts": "키보드 단축키", + "docs": "문서", + "settings": "설정…", + "about": "Bulwark 정보", + "from_plugins": "플러그인 더 보기…", + "pick_gone": "이 메뉴는 오래된 것입니다. 다시 열어 한 번 더 시도해 주세요." + }, + "context": { + "add_to_dictionary": "“{word}”을(를) 사전에 추가", + "no_suggestions": "추천 없음", + "open_link": "링크 열기", + "copy_link": "링크 복사", + "copy_address": "주소 복사", + "save_image": "이미지 저장…", + "copy_image": "이미지 복사" + }, + "closing": { + "unsaved_title": "임시 보관 메일을 저장하지 못했습니다", + "unsaved_detail": "지금 닫으면 마지막으로 저장한 이후에 작성한 내용이 사라집니다.", + "close_anyway": "그래도 닫기", + "unsent_title": "아직 보내지 않은 메일이 있습니다", + "unsent_one": "메시지 1개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.", + "unsent_many": "메시지 {count}개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.", + "quit": "종료", + "leave_title": "저장하지 않은 변경 사항이 있습니다. 나가시겠습니까?", + "leave_detail": "이 페이지에서 변경한 내용이 아직 저장되지 않았습니다.", + "leave": "나가기", + "stay": "머무르기" + }, + "spellcheck": { + "offer": "{language} 맞춤법 검사에는 사전이 필요합니다. Bulwark가 Google에서 내려받을 수 있습니다. 입력한 텍스트는 전송되지 않습니다.", + "download": "내려받기" + }, + "restore": { + "unavailable_title": "지금은 이 임시 보관 메일을 열 수 없습니다", + "unavailable_body": "서버의 임시 보관함에 안전하게 있습니다. 연결이 돌아오면 다시 시도해 주세요.", + "try_again": "다시 시도", + "close": "닫기" } }, "desktop_sign_in": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 1ca156d61..c78516d51 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6558,6 +6558,85 @@ "save_as": "Saglabāt kā…", "copy": "Kopēt", "copy_failed": "Failu neizdevās nokopēt." + }, + "menu": { + "file": "Fails", + "edit": "Rediģēt", + "view": "Skats", + "message": "Ziņojums", + "go": "Doties uz", + "window": "Logs", + "help": "Palīdzība", + "new_window": "Jauns logs", + "print": "Drukāt…", + "save_pdf": "Saglabāt kā PDF…", + "switch_connection": "Mainīt savienojumu…", + "close": "Aizvērt", + "quit": "Iziet", + "undo": "Atsaukt", + "redo": "Atkārtot", + "cut": "Izgriezt", + "copy": "Kopēt", + "paste": "Ielīmēt", + "select_all": "Atlasīt visu", + "search": "Meklēt pastā", + "refresh": "Pārbaudīt jaunu pastu", + "reading_pane": "Lasīšanas rūts", + "pane_right": "Labajā pusē", + "pane_below": "Zem saraksta", + "pane_off": "Izslēgta", + "zoom_in": "Tuvināt", + "zoom_out": "Tālināt", + "zoom_reset": "Faktiskais izmērs", + "full_screen": "Pilnekrāna režīms", + "open_in_window": "Atvērt jaunā logā", + "next_message": "Nākamais ziņojums", + "previous_message": "Iepriekšējais ziņojums", + "next_unread": "Nākamais nelasītais ziņojums", + "mail": "Pasts", + "files": "Faili", + "back": "Atpakaļ", + "forward": "Uz priekšu", + "minimize": "Minimizēt", + "next_window": "Nākamais logs", + "shortcuts": "Tastatūras īsinājumtaustiņi", + "docs": "Dokumentācija", + "settings": "Iestatījumi…", + "about": "Par Bulwark", + "from_plugins": "Vairāk no spraudņiem…", + "pick_gone": "Šī izvēlne bija novecojusi. Atveriet to vēlreiz un mēģiniet vēlreiz." + }, + "context": { + "add_to_dictionary": "Pievienot “{word}” vārdnīcai", + "no_suggestions": "Nav ieteikumu", + "open_link": "Atvērt saiti", + "copy_link": "Kopēt saiti", + "copy_address": "Kopēt adresi", + "save_image": "Saglabāt attēlu…", + "copy_image": "Kopēt attēlu" + }, + "closing": { + "unsaved_title": "Melnrakstu neizdevās saglabāt", + "unsaved_detail": "Ja aizvērsiet tagad, kas uzrakstīts kopš pēdējās saglabāšanas, tiks zaudēts.", + "close_anyway": "Tomēr aizvērt", + "unsent_title": "Daļa pasta vēl nav nosūtīta", + "unsent_one": "Viens ziņojums gaida nosūtīšanu. Tas tiks nosūtīts nākamreiz, kad atvērsiet Bulwark.", + "unsent_many": "Ziņojumi, kas gaida nosūtīšanu: {count}. Tie tiks nosūtīti nākamreiz, kad atvērsiet Bulwark.", + "quit": "Iziet", + "leave_title": "Pamest lapu ar nesaglabātām izmaiņām?", + "leave_detail": "Šajā lapā veiktās izmaiņas vēl nav saglabātas.", + "leave": "Pamest", + "stay": "Palikt" + }, + "spellcheck": { + "offer": "Pareizrakstības pārbaudei valodā {language} nepieciešama vārdnīca. Bulwark to var lejupielādēt no Google. Jūsu teksts netiek sūtīts.", + "download": "Lejupielādēt" + }, + "restore": { + "unavailable_title": "Šo melnrakstu pašlaik nevar atvērt", + "unavailable_body": "Tas ir drošībā serverī, mapē Melnraksti. Mēģiniet vēlreiz, kad savienojums būs atjaunots.", + "try_again": "Mēģināt vēlreiz", + "close": "Aizvērt" } }, "desktop_sign_in": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 52cc39b48..955bb2cc8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6558,6 +6558,85 @@ "save_as": "Өөрөөр хадгалах…", "copy": "Хуулах", "copy_failed": "Файлыг хуулж чадсангүй." + }, + "menu": { + "file": "Файл", + "edit": "Засах", + "view": "Харах", + "message": "Зурвас", + "go": "Очих", + "window": "Цонх", + "help": "Тусламж", + "new_window": "Шинэ цонх", + "print": "Хэвлэх…", + "save_pdf": "PDF хэлбэрээр хадгалах…", + "switch_connection": "Холболт солих…", + "close": "Хаах", + "quit": "Гарах", + "undo": "Буцаах", + "redo": "Дахин хийх", + "cut": "Хайчлах", + "copy": "Хуулах", + "paste": "Буулгах", + "select_all": "Бүгдийг сонгох", + "search": "Захидлаас хайх", + "refresh": "Шинэ захидал шалгах", + "reading_pane": "Унших хэсэг", + "pane_right": "Баруун талд", + "pane_below": "Жагсаалтын доор", + "pane_off": "Идэвхгүй", + "zoom_in": "Томруулах", + "zoom_out": "Жижигрүүлэх", + "zoom_reset": "Бодит хэмжээ", + "full_screen": "Бүтэн дэлгэц", + "open_in_window": "Шинэ цонхонд нээх", + "next_message": "Дараагийн зурвас", + "previous_message": "Өмнөх зурвас", + "next_unread": "Дараагийн уншаагүй зурвас", + "mail": "Имэйл", + "files": "Файлууд", + "back": "Буцах", + "forward": "Урагш", + "minimize": "Жижигрүүлэх", + "next_window": "Дараагийн цонх", + "shortcuts": "Гарын товчлол", + "docs": "Баримт бичиг", + "settings": "Тохиргоо…", + "about": "Bulwark-ийн тухай", + "from_plugins": "Залгаснуудын бусад зүйл…", + "pick_gone": "Энэ цэс хуучирсан байна. Дахин нээгээд нэг удаа оролдоно уу." + }, + "context": { + "add_to_dictionary": "“{word}”-ийг толь бичигт нэмэх", + "no_suggestions": "Санал алга", + "open_link": "Холбоос нээх", + "copy_link": "Холбоос хуулах", + "copy_address": "Хаяг хуулах", + "save_image": "Зураг хадгалах…", + "copy_image": "Зураг хуулах" + }, + "closing": { + "unsaved_title": "Ноорогийг хадгалж чадсангүй", + "unsaved_detail": "Одоо хаавал сүүлд хадгалснаас хойш бичсэн зүйл тань алдагдана.", + "close_anyway": "Ямартай ч хаах", + "unsent_title": "Зарим захидал хараахан илгээгдээгүй байна", + "unsent_one": "Нэг зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.", + "unsent_many": "{count} зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.", + "quit": "Гарах", + "leave_title": "Хадгалаагүй өөрчлөлттэй гарах уу?", + "leave_detail": "Энэ хуудсанд хийсэн өөрчлөлт тань хараахан хадгалагдаагүй байна.", + "leave": "Гарах", + "stay": "Үлдэх" + }, + "spellcheck": { + "offer": "{language} хэлний үсгийн алдаа шалгагчид толь бичиг хэрэгтэй. Bulwark үүнийг Google-ээс татаж авч чадна. Таны бичвэр илгээгдэхгүй.", + "download": "Татах" + }, + "restore": { + "unavailable_title": "Энэ ноорогийг одоо нээх боломжгүй", + "unavailable_body": "Ноорог серверийн Ноорог хавтсанд аюулгүй байна. Холболт сэргэхэд дахин оролдоно уу.", + "try_again": "Дахин оролдох", + "close": "Хаах" } }, "desktop_sign_in": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 0d7083171..a7ebdcb30 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6558,6 +6558,85 @@ "save_as": "Lagre som…", "copy": "Kopier", "copy_failed": "Filen kunne ikke kopieres." + }, + "menu": { + "file": "Fil", + "edit": "Rediger", + "view": "Vis", + "message": "Melding", + "go": "Gå", + "window": "Vindu", + "help": "Hjelp", + "new_window": "Nytt vindu", + "print": "Skriv ut…", + "save_pdf": "Lagre som PDF…", + "switch_connection": "Bytt tilkobling…", + "close": "Lukk", + "quit": "Avslutt", + "undo": "Angre", + "redo": "Gjør om", + "cut": "Klipp ut", + "copy": "Kopier", + "paste": "Lim inn", + "select_all": "Velg alt", + "search": "Søk i e-post", + "refresh": "Se etter ny e-post", + "reading_pane": "Leserute", + "pane_right": "Til høyre", + "pane_below": "Under listen", + "pane_off": "Av", + "zoom_in": "Zoom inn", + "zoom_out": "Zoom ut", + "zoom_reset": "Faktisk størrelse", + "full_screen": "Fullskjerm", + "open_in_window": "Åpne i nytt vindu", + "next_message": "Neste melding", + "previous_message": "Forrige melding", + "next_unread": "Neste uleste melding", + "mail": "E-post", + "files": "Filer", + "back": "Tilbake", + "forward": "Fremover", + "minimize": "Minimer", + "next_window": "Neste vindu", + "shortcuts": "Tastatursnarveier", + "docs": "Dokumentasjon", + "settings": "Innstillinger…", + "about": "Om Bulwark", + "from_plugins": "Mer fra utvidelser…", + "pick_gone": "Den menyen var utdatert. Åpne den på nytt og prøv igjen." + }, + "context": { + "add_to_dictionary": "Legg til «{word}» i ordboken", + "no_suggestions": "Ingen forslag", + "open_link": "Åpne lenke", + "copy_link": "Kopier lenke", + "copy_address": "Kopier adresse", + "save_image": "Lagre bilde…", + "copy_image": "Kopier bilde" + }, + "closing": { + "unsaved_title": "Et utkast kunne ikke lagres", + "unsaved_detail": "Hvis du lukker nå, går det du har skrevet siden forrige lagring tapt.", + "close_anyway": "Lukk likevel", + "unsent_title": "Noe e-post er ikke sendt ennå", + "unsent_one": "Én melding venter på å bli sendt. Den sendes neste gang du åpner Bulwark.", + "unsent_many": "{count} meldinger venter på å bli sendt. De sendes neste gang du åpner Bulwark.", + "quit": "Avslutt", + "leave_title": "Forlate siden med ulagrede endringer?", + "leave_detail": "Endringene dine på denne siden er ikke lagret ennå.", + "leave": "Forlat", + "stay": "Bli" + }, + "spellcheck": { + "offer": "Stavekontroll for {language} trenger en ordbok. Bulwark kan laste den ned fra Google. Teksten din blir ikke sendt.", + "download": "Last ned" + }, + "restore": { + "unavailable_title": "Dette utkastet kan ikke åpnes akkurat nå", + "unavailable_body": "Det er trygt på serveren, i Utkast. Prøv igjen når tilkoblingen er tilbake.", + "try_again": "Prøv igjen", + "close": "Lukk" } }, "desktop_sign_in": { diff --git a/locales/nl/common.json b/locales/nl/common.json index b0e75ada3..7dfec60f3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6558,6 +6558,85 @@ "save_as": "Opslaan als…", "copy": "Kopiëren", "copy_failed": "Het bestand kon niet worden gekopieerd." + }, + "menu": { + "file": "Bestand", + "edit": "Bewerken", + "view": "Weergave", + "message": "Bericht", + "go": "Ga", + "window": "Venster", + "help": "Help", + "new_window": "Nieuw venster", + "print": "Afdrukken…", + "save_pdf": "Bewaar als pdf…", + "switch_connection": "Verbinding wijzigen…", + "close": "Sluit", + "quit": "Stop", + "undo": "Herstel", + "redo": "Opnieuw", + "cut": "Knip", + "copy": "Kopieer", + "paste": "Plak", + "select_all": "Selecteer alles", + "search": "Zoek in e-mail", + "refresh": "Controleer op nieuwe e-mail", + "reading_pane": "Leesvenster", + "pane_right": "Rechts", + "pane_below": "Onder de lijst", + "pane_off": "Uit", + "zoom_in": "Zoom in", + "zoom_out": "Zoom uit", + "zoom_reset": "Werkelijke grootte", + "full_screen": "Volledig scherm", + "open_in_window": "Open in nieuw venster", + "next_message": "Volgend bericht", + "previous_message": "Vorig bericht", + "next_unread": "Volgend ongelezen bericht", + "mail": "E-mail", + "files": "Bestanden", + "back": "Terug", + "forward": "Vooruit", + "minimize": "Minimaliseer", + "next_window": "Volgend venster", + "shortcuts": "Toetsenbordsneltoetsen", + "docs": "Documentatie", + "settings": "Instellingen…", + "about": "Over Bulwark", + "from_plugins": "Meer van plug-ins…", + "pick_gone": "Dat menu was verouderd. Open het opnieuw en probeer het nog eens." + }, + "context": { + "add_to_dictionary": "Voeg ‘{word}’ toe aan woordenboek", + "no_suggestions": "Geen suggesties", + "open_link": "Open link", + "copy_link": "Kopieer link", + "copy_address": "Kopieer adres", + "save_image": "Bewaar afbeelding…", + "copy_image": "Kopieer afbeelding" + }, + "closing": { + "unsaved_title": "Een concept kon niet worden bewaard", + "unsaved_detail": "Als u nu sluit, gaat verloren wat u sinds de laatste keer bewaren hebt geschreven.", + "close_anyway": "Toch sluiten", + "unsent_title": "Er is e-mail die nog niet is verzonden", + "unsent_one": "Eén bericht wacht om te worden verzonden. Het wordt verzonden de volgende keer dat u Bulwark opent.", + "unsent_many": "{count} berichten wachten om te worden verzonden. Ze worden verzonden de volgende keer dat u Bulwark opent.", + "quit": "Stop", + "leave_title": "Verlaten met niet-bewaarde wijzigingen?", + "leave_detail": "Uw wijzigingen op deze pagina zijn nog niet bewaard.", + "leave": "Verlaat", + "stay": "Blijf" + }, + "spellcheck": { + "offer": "Voor spellingcontrole in het {language} is een woordenboek nodig. Bulwark kan dit downloaden van Google. Uw tekst wordt niet verzonden.", + "download": "Download" + }, + "restore": { + "unavailable_title": "Dit concept kan nu niet worden geopend", + "unavailable_body": "Het staat veilig op de server, in Concepten. Probeer het opnieuw zodra de verbinding terug is.", + "try_again": "Probeer opnieuw", + "close": "Sluit" } }, "desktop_sign_in": { diff --git a/locales/pl/common.json b/locales/pl/common.json index eb4f9f8fc..628f0113e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6558,6 +6558,85 @@ "save_as": "Zapisz jako…", "copy": "Kopiuj", "copy_failed": "Nie udało się skopiować pliku." + }, + "menu": { + "file": "Plik", + "edit": "Edycja", + "view": "Widok", + "message": "Wiadomość", + "go": "Przejdź", + "window": "Okno", + "help": "Pomoc", + "new_window": "Nowe okno", + "print": "Drukuj…", + "save_pdf": "Zapisz jako PDF…", + "switch_connection": "Zmień połączenie…", + "close": "Zamknij", + "quit": "Zakończ", + "undo": "Cofnij", + "redo": "Ponów", + "cut": "Wytnij", + "copy": "Kopiuj", + "paste": "Wklej", + "select_all": "Zaznacz wszystko", + "search": "Szukaj w poczcie", + "refresh": "Sprawdź nową pocztę", + "reading_pane": "Okienko podglądu", + "pane_right": "Po prawej", + "pane_below": "Pod listą", + "pane_off": "Wyłączone", + "zoom_in": "Powiększ", + "zoom_out": "Pomniejsz", + "zoom_reset": "Rzeczywisty rozmiar", + "full_screen": "Pełny ekran", + "open_in_window": "Otwórz w nowym oknie", + "next_message": "Następna wiadomość", + "previous_message": "Poprzednia wiadomość", + "next_unread": "Następna nieprzeczytana wiadomość", + "mail": "Poczta", + "files": "Pliki", + "back": "Wstecz", + "forward": "Dalej", + "minimize": "Minimalizuj", + "next_window": "Następne okno", + "shortcuts": "Skróty klawiszowe", + "docs": "Dokumentacja", + "settings": "Ustawienia…", + "about": "O programie Bulwark", + "from_plugins": "Więcej z wtyczek…", + "pick_gone": "To menu było nieaktualne. Otwórz je ponownie i spróbuj jeszcze raz." + }, + "context": { + "add_to_dictionary": "Dodaj „{word}” do słownika", + "no_suggestions": "Brak podpowiedzi", + "open_link": "Otwórz łącze", + "copy_link": "Kopiuj łącze", + "copy_address": "Kopiuj adres", + "save_image": "Zapisz obraz…", + "copy_image": "Kopiuj obraz" + }, + "closing": { + "unsaved_title": "Nie udało się zapisać wersji roboczej", + "unsaved_detail": "Jeśli zamkniesz teraz, utracisz wszystko, co zostało napisane od ostatniego zapisu.", + "close_anyway": "Zamknij mimo to", + "unsent_title": "Część poczty nie została jeszcze wysłana", + "unsent_one": "Jedna wiadomość czeka na wysłanie. Zostanie wysłana przy następnym uruchomieniu Bulwark.", + "unsent_many": "Wiadomości czekające na wysłanie: {count}. Zostaną wysłane przy następnym uruchomieniu Bulwark.", + "quit": "Zakończ", + "leave_title": "Opuścić stronę bez zapisania zmian?", + "leave_detail": "Zmiany wprowadzone na tej stronie nie zostały jeszcze zapisane.", + "leave": "Opuść", + "stay": "Zostań" + }, + "spellcheck": { + "offer": "Sprawdzanie pisowni dla języka {language} wymaga słownika. Bulwark może go pobrać od Google. Twój tekst nie jest wysyłany.", + "download": "Pobierz" + }, + "restore": { + "unavailable_title": "Nie można teraz otworzyć tej wersji roboczej", + "unavailable_body": "Jest bezpieczna na serwerze, w folderze Wersje robocze. Spróbuj ponownie, gdy połączenie wróci.", + "try_again": "Spróbuj ponownie", + "close": "Zamknij" } }, "desktop_sign_in": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 5235f5317..933c7141a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6558,6 +6558,85 @@ "save_as": "Guardar como…", "copy": "Copiar", "copy_failed": "Não foi possível copiar o ficheiro." + }, + "menu": { + "file": "Ficheiro", + "edit": "Editar", + "view": "Ver", + "message": "Mensagem", + "go": "Ir", + "window": "Janela", + "help": "Ajuda", + "new_window": "Nova janela", + "print": "Imprimir…", + "save_pdf": "Guardar como PDF…", + "switch_connection": "Mudar de ligação…", + "close": "Fechar", + "quit": "Sair", + "undo": "Anular", + "redo": "Refazer", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Colar", + "select_all": "Selecionar tudo", + "search": "Pesquisar no correio", + "refresh": "Verificar novo correio", + "reading_pane": "Painel de leitura", + "pane_right": "À direita", + "pane_below": "Abaixo da lista", + "pane_off": "Desativado", + "zoom_in": "Ampliar", + "zoom_out": "Reduzir", + "zoom_reset": "Tamanho real", + "full_screen": "Ecrã inteiro", + "open_in_window": "Abrir numa nova janela", + "next_message": "Mensagem seguinte", + "previous_message": "Mensagem anterior", + "next_unread": "Próxima mensagem por ler", + "mail": "Correio", + "files": "Ficheiros", + "back": "Anterior", + "forward": "Seguinte", + "minimize": "Minimizar", + "next_window": "Janela seguinte", + "shortcuts": "Atalhos de teclado", + "docs": "Documentação", + "settings": "Definições…", + "about": "Acerca do Bulwark", + "from_plugins": "Mais dos plugins…", + "pick_gone": "Esse menu estava desatualizado. Abra-o de novo e tente outra vez." + }, + "context": { + "add_to_dictionary": "Adicionar «{word}» ao dicionário", + "no_suggestions": "Sem sugestões", + "open_link": "Abrir ligação", + "copy_link": "Copiar ligação", + "copy_address": "Copiar endereço", + "save_image": "Guardar imagem…", + "copy_image": "Copiar imagem" + }, + "closing": { + "unsaved_title": "Não foi possível guardar um rascunho", + "unsaved_detail": "Se fechar agora, perde o que escreveu desde a última gravação.", + "close_anyway": "Fechar mesmo assim", + "unsent_title": "Há mensagens por enviar", + "unsent_one": "Uma mensagem aguarda envio. Será enviada na próxima vez que abrir o Bulwark.", + "unsent_many": "{count} mensagens aguardam envio. Serão enviadas na próxima vez que abrir o Bulwark.", + "quit": "Sair", + "leave_title": "Sair com alterações não guardadas?", + "leave_detail": "As alterações feitas nesta página ainda não foram guardadas.", + "leave": "Sair", + "stay": "Ficar" + }, + "spellcheck": { + "offer": "A verificação ortográfica em {language} precisa de um dicionário. O Bulwark pode transferi-lo da Google. O seu texto não é enviado.", + "download": "Transferir" + }, + "restore": { + "unavailable_title": "Este rascunho não pode ser aberto agora", + "unavailable_body": "Está seguro no servidor, em Rascunhos. Tente novamente quando a ligação voltar.", + "try_again": "Tentar novamente", + "close": "Fechar" } }, "desktop_sign_in": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 5076823a5..f4e721732 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salvează ca…", "copy": "Copiază", "copy_failed": "Fișierul nu a putut fi copiat." + }, + "menu": { + "file": "Fișier", + "edit": "Editare", + "view": "Vizualizare", + "message": "Mesaj", + "go": "Salt la", + "window": "Fereastră", + "help": "Ajutor", + "new_window": "Fereastră nouă", + "print": "Imprimare…", + "save_pdf": "Salvare ca PDF…", + "switch_connection": "Schimbare conexiune…", + "close": "Închidere", + "quit": "Ieșire", + "undo": "Anulare", + "redo": "Refacere", + "cut": "Decupare", + "copy": "Copiere", + "paste": "Lipire", + "select_all": "Selectare totală", + "search": "Căutare în e-mail", + "refresh": "Verificare e-mailuri noi", + "reading_pane": "Panou de citire", + "pane_right": "În dreapta", + "pane_below": "Sub listă", + "pane_off": "Dezactivat", + "zoom_in": "Mărire", + "zoom_out": "Micșorare", + "zoom_reset": "Dimensiune reală", + "full_screen": "Ecran complet", + "open_in_window": "Deschidere în fereastră nouă", + "next_message": "Mesajul următor", + "previous_message": "Mesajul anterior", + "next_unread": "Următorul mesaj necitit", + "mail": "E-mail", + "files": "Fișiere", + "back": "Înapoi", + "forward": "Înainte", + "minimize": "Minimizare", + "next_window": "Fereastra următoare", + "shortcuts": "Comenzi rapide de la tastatură", + "docs": "Documentație", + "settings": "Setări…", + "about": "Despre Bulwark", + "from_plugins": "Mai multe de la pluginuri…", + "pick_gone": "Meniul era depășit. Deschideți-l din nou și încercați încă o dată." + }, + "context": { + "add_to_dictionary": "Adăugare „{word}” în dicționar", + "no_suggestions": "Fără sugestii", + "open_link": "Deschidere link", + "copy_link": "Copiere link", + "copy_address": "Copiere adresă", + "save_image": "Salvare imagine…", + "copy_image": "Copiere imagine" + }, + "closing": { + "unsaved_title": "O ciornă nu a putut fi salvată", + "unsaved_detail": "Dacă închideți acum, ce ați scris de la ultima salvare se pierde.", + "close_anyway": "Închidere oricum", + "unsent_title": "Unele mesaje nu au fost trimise încă", + "unsent_one": "Un mesaj așteaptă să fie trimis. Pleacă la următoarea deschidere a Bulwark.", + "unsent_many": "{count} mesaje așteaptă să fie trimise. Pleacă la următoarea deschidere a Bulwark.", + "quit": "Ieșire", + "leave_title": "Părăsiți pagina cu modificări nesalvate?", + "leave_detail": "Modificările făcute pe această pagină nu au fost salvate încă.", + "leave": "Părăsire", + "stay": "Rămâneți" + }, + "spellcheck": { + "offer": "Verificarea ortografică pentru {language} are nevoie de un dicționar. Bulwark îl poate descărca de la Google. Textul dvs. nu este trimis.", + "download": "Descărcare" + }, + "restore": { + "unavailable_title": "Această ciornă nu poate fi deschisă acum", + "unavailable_body": "Este în siguranță pe server, în Ciorne. Încercați din nou când revine conexiunea.", + "try_again": "Încercați din nou", + "close": "Închidere" } }, "desktop_sign_in": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 05ce8b7b8..ba407117a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6558,6 +6558,85 @@ "save_as": "Сохранить как…", "copy": "Копировать", "copy_failed": "Не удалось скопировать файл." + }, + "menu": { + "file": "Файл", + "edit": "Правка", + "view": "Вид", + "message": "Сообщение", + "go": "Переход", + "window": "Окно", + "help": "Справка", + "new_window": "Новое окно", + "print": "Печать…", + "save_pdf": "Сохранить как PDF…", + "switch_connection": "Сменить подключение…", + "close": "Закрыть", + "quit": "Выйти", + "undo": "Отменить", + "redo": "Повторить", + "cut": "Вырезать", + "copy": "Копировать", + "paste": "Вставить", + "select_all": "Выделить всё", + "search": "Поиск по почте", + "refresh": "Проверить новую почту", + "reading_pane": "Область чтения", + "pane_right": "Справа", + "pane_below": "Под списком", + "pane_off": "Выключена", + "zoom_in": "Увеличить", + "zoom_out": "Уменьшить", + "zoom_reset": "Реальный размер", + "full_screen": "Полноэкранный режим", + "open_in_window": "Открыть в новом окне", + "next_message": "Следующее сообщение", + "previous_message": "Предыдущее сообщение", + "next_unread": "Следующее непрочитанное сообщение", + "mail": "Почта", + "files": "Файлы", + "back": "Назад", + "forward": "Вперёд", + "minimize": "Свернуть", + "next_window": "Следующее окно", + "shortcuts": "Сочетания клавиш", + "docs": "Документация", + "settings": "Настройки…", + "about": "О программе Bulwark", + "from_plugins": "Ещё из плагинов…", + "pick_gone": "Это меню устарело. Откройте его снова и повторите попытку." + }, + "context": { + "add_to_dictionary": "Добавить «{word}» в словарь", + "no_suggestions": "Нет вариантов", + "open_link": "Открыть ссылку", + "copy_link": "Копировать ссылку", + "copy_address": "Копировать адрес", + "save_image": "Сохранить изображение…", + "copy_image": "Копировать изображение" + }, + "closing": { + "unsaved_title": "Не удалось сохранить черновик", + "unsaved_detail": "Если закрыть сейчас, всё написанное после последнего сохранения будет потеряно.", + "close_anyway": "Всё равно закрыть", + "unsent_title": "Часть писем ещё не отправлена", + "unsent_one": "Одно сообщение ждёт отправки. Оно уйдёт при следующем запуске Bulwark.", + "unsent_many": "Сообщений, ожидающих отправки: {count}. Они уйдут при следующем запуске Bulwark.", + "quit": "Выйти", + "leave_title": "Уйти с несохранёнными изменениями?", + "leave_detail": "Изменения на этой странице ещё не сохранены.", + "leave": "Уйти", + "stay": "Остаться" + }, + "spellcheck": { + "offer": "Для проверки орфографии на языке «{language}» нужен словарь. Bulwark может скачать его у Google. Ваш текст не отправляется.", + "download": "Скачать" + }, + "restore": { + "unavailable_title": "Этот черновик сейчас нельзя открыть", + "unavailable_body": "Он в сохранности на сервере, в папке «Черновики». Повторите попытку, когда появится соединение.", + "try_again": "Повторить", + "close": "Закрыть" } }, "desktop_sign_in": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 392915139..ff71bfbcb 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6558,6 +6558,85 @@ "save_as": "Uložiť ako…", "copy": "Kopírovať", "copy_failed": "Súbor sa nepodarilo skopírovať." + }, + "menu": { + "file": "Súbor", + "edit": "Upraviť", + "view": "Zobrazenie", + "message": "Správa", + "go": "Prejsť", + "window": "Okno", + "help": "Pomocník", + "new_window": "Nové okno", + "print": "Tlačiť…", + "save_pdf": "Uložiť ako PDF…", + "switch_connection": "Zmeniť pripojenie…", + "close": "Zavrieť", + "quit": "Ukončiť", + "undo": "Späť", + "redo": "Znova", + "cut": "Vystrihnúť", + "copy": "Kopírovať", + "paste": "Prilepiť", + "select_all": "Vybrať všetko", + "search": "Hľadať v pošte", + "refresh": "Skontrolovať novú poštu", + "reading_pane": "Panel čítania", + "pane_right": "Vpravo", + "pane_below": "Pod zoznamom", + "pane_off": "Vypnuté", + "zoom_in": "Priblížiť", + "zoom_out": "Oddialiť", + "zoom_reset": "Skutočná veľkosť", + "full_screen": "Celá obrazovka", + "open_in_window": "Otvoriť v novom okne", + "next_message": "Ďalšia správa", + "previous_message": "Predchádzajúca správa", + "next_unread": "Ďalšia neprečítaná správa", + "mail": "Pošta", + "files": "Súbory", + "back": "Späť", + "forward": "Dopredu", + "minimize": "Minimalizovať", + "next_window": "Ďalšie okno", + "shortcuts": "Klávesové skratky", + "docs": "Dokumentácia", + "settings": "Nastavenia…", + "about": "O aplikácii Bulwark", + "from_plugins": "Viac z doplnkov…", + "pick_gone": "Táto ponuka bola zastaraná. Otvorte ju znova a skúste to ešte raz." + }, + "context": { + "add_to_dictionary": "Pridať „{word}“ do slovníka", + "no_suggestions": "Žiadne návrhy", + "open_link": "Otvoriť odkaz", + "copy_link": "Kopírovať odkaz", + "copy_address": "Kopírovať adresu", + "save_image": "Uložiť obrázok…", + "copy_image": "Kopírovať obrázok" + }, + "closing": { + "unsaved_title": "Koncept sa nepodarilo uložiť", + "unsaved_detail": "Ak teraz zatvoríte okno, prídete o to, čo ste napísali od posledného uloženia.", + "close_anyway": "Napriek tomu zavrieť", + "unsent_title": "Niektorá pošta ešte nebola odoslaná", + "unsent_one": "Jedna správa čaká na odoslanie. Odíde pri ďalšom spustení Bulwark.", + "unsent_many": "Počet správ čakajúcich na odoslanie: {count}. Odídu pri ďalšom spustení Bulwark.", + "quit": "Ukončiť", + "leave_title": "Odísť s neuloženými zmenami?", + "leave_detail": "Zmeny na tejto stránke zatiaľ nie sú uložené.", + "leave": "Odísť", + "stay": "Zostať" + }, + "spellcheck": { + "offer": "Kontrola pravopisu pre jazyk {language} potrebuje slovník. Bulwark ho môže stiahnuť od spoločnosti Google. Váš text sa neodosiela.", + "download": "Stiahnuť" + }, + "restore": { + "unavailable_title": "Tento koncept sa teraz nedá otvoriť", + "unavailable_body": "Je v bezpečí na serveri v priečinku Koncepty. Skúste to znova, keď sa obnoví pripojenie.", + "try_again": "Skúsiť znova", + "close": "Zavrieť" } }, "desktop_sign_in": { diff --git a/locales/th/common.json b/locales/th/common.json index 0c34470ff..128d8564b 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6566,6 +6566,85 @@ "save_as": "บันทึกเป็น…", "copy": "คัดลอก", "copy_failed": "คัดลอกไฟล์ไม่ได้" + }, + "menu": { + "file": "ไฟล์", + "edit": "แก้ไข", + "view": "มุมมอง", + "message": "ข้อความ", + "go": "ไป", + "window": "หน้าต่าง", + "help": "ช่วยเหลือ", + "new_window": "หน้าต่างใหม่", + "print": "พิมพ์…", + "save_pdf": "บันทึกเป็น PDF…", + "switch_connection": "เปลี่ยนการเชื่อมต่อ…", + "close": "ปิด", + "quit": "ออก", + "undo": "เลิกทำ", + "redo": "ทำซ้ำ", + "cut": "ตัด", + "copy": "คัดลอก", + "paste": "วาง", + "select_all": "เลือกทั้งหมด", + "search": "ค้นหาอีเมล", + "refresh": "ตรวจสอบอีเมลใหม่", + "reading_pane": "บานหน้าต่างการอ่าน", + "pane_right": "ด้านขวา", + "pane_below": "ใต้รายการ", + "pane_off": "ปิด", + "zoom_in": "ขยาย", + "zoom_out": "ย่อ", + "zoom_reset": "ขนาดจริง", + "full_screen": "เต็มหน้าจอ", + "open_in_window": "เปิดในหน้าต่างใหม่", + "next_message": "ข้อความถัดไป", + "previous_message": "ข้อความก่อนหน้า", + "next_unread": "ข้อความที่ยังไม่ได้อ่านถัดไป", + "mail": "อีเมล", + "files": "ไฟล์", + "back": "ย้อนกลับ", + "forward": "ไปข้างหน้า", + "minimize": "ย่อหน้าต่าง", + "next_window": "หน้าต่างถัดไป", + "shortcuts": "แป้นพิมพ์ลัด", + "docs": "เอกสารประกอบ", + "settings": "การตั้งค่า…", + "about": "เกี่ยวกับ Bulwark", + "from_plugins": "เพิ่มเติมจากปลั๊กอิน…", + "pick_gone": "เมนูนี้เก่าแล้ว โปรดเปิดใหม่แล้วลองอีกครั้ง" + }, + "context": { + "add_to_dictionary": "เพิ่ม “{word}” ในพจนานุกรม", + "no_suggestions": "ไม่มีคำแนะนำ", + "open_link": "เปิดลิงก์", + "copy_link": "คัดลอกลิงก์", + "copy_address": "คัดลอกที่อยู่", + "save_image": "บันทึกรูปภาพ…", + "copy_image": "คัดลอกรูปภาพ" + }, + "closing": { + "unsaved_title": "บันทึกฉบับร่างไม่ได้", + "unsaved_detail": "หากปิดตอนนี้ สิ่งที่คุณเขียนหลังจากบันทึกครั้งล่าสุดจะหายไป", + "close_anyway": "ปิดต่อไป", + "unsent_title": "มีอีเมลที่ยังไม่ได้ส่ง", + "unsent_one": "มี 1 ข้อความรอส่ง จะส่งออกเมื่อคุณเปิด Bulwark ครั้งถัดไป", + "unsent_many": "มี {count} ข้อความรอส่ง จะส่งออกเมื่อคุณเปิด Bulwark ครั้งถัดไป", + "quit": "ออก", + "leave_title": "ออกโดยไม่บันทึกการเปลี่ยนแปลงหรือไม่", + "leave_detail": "การเปลี่ยนแปลงของคุณในหน้านี้ยังไม่ได้บันทึก", + "leave": "ออก", + "stay": "อยู่ต่อ" + }, + "spellcheck": { + "offer": "การตรวจตัวสะกดสำหรับ{language}ต้องมีพจนานุกรม Bulwark ดาวน์โหลดจาก Google ได้ ข้อความของคุณจะไม่ถูกส่งไป", + "download": "ดาวน์โหลด" + }, + "restore": { + "unavailable_title": "ตอนนี้ยังเปิดฉบับร่างนี้ไม่ได้", + "unavailable_body": "ฉบับร่างปลอดภัยอยู่บนเซิร์ฟเวอร์ ในโฟลเดอร์ฉบับร่าง ลองอีกครั้งเมื่อเชื่อมต่อได้", + "try_again": "ลองอีกครั้ง", + "close": "ปิด" } }, "desktop_sign_in": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 5e5365633..7e08bdca3 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6558,6 +6558,85 @@ "save_as": "Farklı kaydet…", "copy": "Kopyala", "copy_failed": "Dosya kopyalanamadı." + }, + "menu": { + "file": "Dosya", + "edit": "Düzen", + "view": "Görünüm", + "message": "İleti", + "go": "Git", + "window": "Pencere", + "help": "Yardım", + "new_window": "Yeni Pencere", + "print": "Yazdır…", + "save_pdf": "PDF Olarak Kaydet…", + "switch_connection": "Bağlantıyı Değiştir…", + "close": "Kapat", + "quit": "Çık", + "undo": "Geri Al", + "redo": "Yinele", + "cut": "Kes", + "copy": "Kopyala", + "paste": "Yapıştır", + "select_all": "Tümünü Seç", + "search": "E-postalarda Ara", + "refresh": "Yeni E-postaları Denetle", + "reading_pane": "Okuma Bölmesi", + "pane_right": "Sağda", + "pane_below": "Listenin Altında", + "pane_off": "Kapalı", + "zoom_in": "Yakınlaştır", + "zoom_out": "Uzaklaştır", + "zoom_reset": "Gerçek Boyut", + "full_screen": "Tam Ekran", + "open_in_window": "Yeni Pencerede Aç", + "next_message": "Sonraki İleti", + "previous_message": "Önceki İleti", + "next_unread": "Sonraki Okunmamış İleti", + "mail": "E-posta", + "files": "Dosyalar", + "back": "Geri", + "forward": "İleri", + "minimize": "Küçült", + "next_window": "Sonraki Pencere", + "shortcuts": "Klavye Kısayolları", + "docs": "Belgeler", + "settings": "Ayarlar…", + "about": "Bulwark Hakkında", + "from_plugins": "Eklentilerden Daha Fazlası…", + "pick_gone": "Bu menü eskimişti. Yeniden açın ve bir kez daha deneyin." + }, + "context": { + "add_to_dictionary": "“{word}” sözcüğünü sözlüğe ekle", + "no_suggestions": "Öneri yok", + "open_link": "Bağlantıyı Aç", + "copy_link": "Bağlantıyı Kopyala", + "copy_address": "Adresi Kopyala", + "save_image": "Resmi Kaydet…", + "copy_image": "Resmi Kopyala" + }, + "closing": { + "unsaved_title": "Bir taslak kaydedilemedi", + "unsaved_detail": "Şimdi kapatırsanız, son kayıttan beri yazdıklarınız kaybolur.", + "close_anyway": "Yine de kapat", + "unsent_title": "Bazı e-postalar henüz gönderilmedi", + "unsent_one": "Bir ileti gönderilmeyi bekliyor. Bulwark’ı bir sonraki açışınızda gönderilir.", + "unsent_many": "{count} ileti gönderilmeyi bekliyor. Bulwark’ı bir sonraki açışınızda gönderilir.", + "quit": "Çık", + "leave_title": "Kaydedilmemiş değişikliklerle ayrılınsın mı?", + "leave_detail": "Bu sayfada yaptığınız değişiklikler henüz kaydedilmedi.", + "leave": "Ayrıl", + "stay": "Kal" + }, + "spellcheck": { + "offer": "{language} yazım denetimi için bir sözlük gerekir. Bulwark bunu Google’dan indirebilir. Metniniz gönderilmez.", + "download": "İndir" + }, + "restore": { + "unavailable_title": "Bu taslak şu anda açılamıyor", + "unavailable_body": "Sunucuda, Taslaklar klasöründe güvende. Bağlantı geri geldiğinde yeniden deneyin.", + "try_again": "Yeniden dene", + "close": "Kapat" } }, "desktop_sign_in": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 9d6aaed35..e86001031 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6558,6 +6558,85 @@ "save_as": "Зберегти як…", "copy": "Копіювати", "copy_failed": "Не вдалося скопіювати файл." + }, + "menu": { + "file": "Файл", + "edit": "Редагування", + "view": "Вигляд", + "message": "Повідомлення", + "go": "Перехід", + "window": "Вікно", + "help": "Довідка", + "new_window": "Нове вікно", + "print": "Друк…", + "save_pdf": "Зберегти як PDF…", + "switch_connection": "Змінити підключення…", + "close": "Закрити", + "quit": "Вийти", + "undo": "Скасувати", + "redo": "Повторити", + "cut": "Вирізати", + "copy": "Копіювати", + "paste": "Вставити", + "select_all": "Вибрати все", + "search": "Пошук у пошті", + "refresh": "Перевірити нову пошту", + "reading_pane": "Область читання", + "pane_right": "Праворуч", + "pane_below": "Під списком", + "pane_off": "Вимкнено", + "zoom_in": "Збільшити", + "zoom_out": "Зменшити", + "zoom_reset": "Реальний розмір", + "full_screen": "На весь екран", + "open_in_window": "Відкрити в новому вікні", + "next_message": "Наступне повідомлення", + "previous_message": "Попереднє повідомлення", + "next_unread": "Наступне непрочитане повідомлення", + "mail": "Пошта", + "files": "Файли", + "back": "Назад", + "forward": "Вперед", + "minimize": "Згорнути", + "next_window": "Наступне вікно", + "shortcuts": "Комбінації клавіш", + "docs": "Документація", + "settings": "Налаштування…", + "about": "Про Bulwark", + "from_plugins": "Більше від плагінів…", + "pick_gone": "Це меню застаріло. Відкрийте його знову й спробуйте ще раз." + }, + "context": { + "add_to_dictionary": "Додати «{word}» до словника", + "no_suggestions": "Немає пропозицій", + "open_link": "Відкрити посилання", + "copy_link": "Копіювати посилання", + "copy_address": "Копіювати адресу", + "save_image": "Зберегти зображення…", + "copy_image": "Копіювати зображення" + }, + "closing": { + "unsaved_title": "Не вдалося зберегти чернетку", + "unsaved_detail": "Якщо закрити зараз, усе написане після останнього збереження буде втрачено.", + "close_anyway": "Закрити все одно", + "unsent_title": "Частину листів ще не надіслано", + "unsent_one": "Одне повідомлення чекає на надсилання. Воно піде під час наступного запуску Bulwark.", + "unsent_many": "Повідомлень, що чекають на надсилання: {count}. Вони підуть під час наступного запуску Bulwark.", + "quit": "Вийти", + "leave_title": "Піти з незбереженими змінами?", + "leave_detail": "Зміни на цій сторінці ще не збережено.", + "leave": "Піти", + "stay": "Залишитися" + }, + "spellcheck": { + "offer": "Для перевірки правопису мовою «{language}» потрібен словник. Bulwark може завантажити його від Google. Ваш текст не надсилається.", + "download": "Завантажити" + }, + "restore": { + "unavailable_title": "Цю чернетку зараз не можна відкрити", + "unavailable_body": "Вона в безпеці на сервері, у теці «Чернетки». Спробуйте ще раз, коли з’явиться з’єднання.", + "try_again": "Спробувати ще раз", + "close": "Закрити" } }, "desktop_sign_in": { diff --git a/locales/vi/common.json b/locales/vi/common.json index 2076cdb65..a53cd69bb 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6570,6 +6570,85 @@ "save_as": "Lưu thành…", "copy": "Sao chép", "copy_failed": "Không thể sao chép tệp." + }, + "menu": { + "file": "Tệp", + "edit": "Chỉnh sửa", + "view": "Xem", + "message": "Thư", + "go": "Đi tới", + "window": "Cửa sổ", + "help": "Trợ giúp", + "new_window": "Cửa sổ mới", + "print": "In…", + "save_pdf": "Lưu dưới dạng PDF…", + "switch_connection": "Đổi kết nối…", + "close": "Đóng", + "quit": "Thoát", + "undo": "Hoàn tác", + "redo": "Làm lại", + "cut": "Cắt", + "copy": "Sao chép", + "paste": "Dán", + "select_all": "Chọn tất cả", + "search": "Tìm trong thư", + "refresh": "Kiểm tra thư mới", + "reading_pane": "Ô đọc thư", + "pane_right": "Bên phải", + "pane_below": "Bên dưới danh sách", + "pane_off": "Tắt", + "zoom_in": "Phóng to", + "zoom_out": "Thu nhỏ", + "zoom_reset": "Kích thước thực", + "full_screen": "Toàn màn hình", + "open_in_window": "Mở trong cửa sổ mới", + "next_message": "Thư tiếp theo", + "previous_message": "Thư trước", + "next_unread": "Thư chưa đọc tiếp theo", + "mail": "Thư", + "files": "Tệp", + "back": "Quay lại", + "forward": "Tiến tới", + "minimize": "Thu nhỏ cửa sổ", + "next_window": "Cửa sổ tiếp theo", + "shortcuts": "Phím tắt", + "docs": "Tài liệu", + "settings": "Cài đặt…", + "about": "Giới thiệu về Bulwark", + "from_plugins": "Thêm từ các plugin…", + "pick_gone": "Menu này đã cũ. Hãy mở lại và thử một lần nữa." + }, + "context": { + "add_to_dictionary": "Thêm “{word}” vào từ điển", + "no_suggestions": "Không có gợi ý", + "open_link": "Mở liên kết", + "copy_link": "Sao chép liên kết", + "copy_address": "Sao chép địa chỉ", + "save_image": "Lưu hình ảnh…", + "copy_image": "Sao chép hình ảnh" + }, + "closing": { + "unsaved_title": "Không thể lưu một bản nháp", + "unsaved_detail": "Nếu bạn đóng bây giờ, nội dung bạn đã viết kể từ lần lưu gần nhất sẽ bị mất.", + "close_anyway": "Vẫn đóng", + "unsent_title": "Một số thư chưa được gửi", + "unsent_one": "Có 1 thư đang chờ gửi. Thư sẽ được gửi vào lần tới bạn mở Bulwark.", + "unsent_many": "Có {count} thư đang chờ gửi. Các thư sẽ được gửi vào lần tới bạn mở Bulwark.", + "quit": "Thoát", + "leave_title": "Rời đi khi còn thay đổi chưa lưu?", + "leave_detail": "Các thay đổi của bạn trên trang này chưa được lưu.", + "leave": "Rời đi", + "stay": "Ở lại" + }, + "spellcheck": { + "offer": "Kiểm tra chính tả cho {language} cần một từ điển. Bulwark có thể tải từ Google. Văn bản của bạn không bị gửi đi.", + "download": "Tải xuống" + }, + "restore": { + "unavailable_title": "Hiện không thể mở bản nháp này", + "unavailable_body": "Bản nháp vẫn an toàn trên máy chủ, trong mục Thư nháp. Hãy thử lại khi có kết nối.", + "try_again": "Thử lại", + "close": "Đóng" } }, "desktop_sign_in": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 53eefab4f..019716c37 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6558,6 +6558,85 @@ "save_as": "另存新檔…", "copy": "複製", "copy_failed": "無法複製檔案。" + }, + "menu": { + "file": "檔案", + "edit": "編輯", + "view": "檢視", + "message": "郵件", + "go": "前往", + "window": "視窗", + "help": "說明", + "new_window": "新增視窗", + "print": "列印…", + "save_pdf": "儲存為 PDF…", + "switch_connection": "變更連線…", + "close": "關閉", + "quit": "結束", + "undo": "還原", + "redo": "重做", + "cut": "剪下", + "copy": "拷貝", + "paste": "貼上", + "select_all": "全選", + "search": "搜尋郵件", + "refresh": "檢查新郵件", + "reading_pane": "閱讀窗格", + "pane_right": "在右側", + "pane_below": "在清單下方", + "pane_off": "關閉", + "zoom_in": "放大", + "zoom_out": "縮小", + "zoom_reset": "實際大小", + "full_screen": "全螢幕", + "open_in_window": "在新視窗中開啟", + "next_message": "下一封郵件", + "previous_message": "上一封郵件", + "next_unread": "下一封未讀郵件", + "mail": "郵件", + "files": "檔案", + "back": "返回", + "forward": "下一頁", + "minimize": "最小化", + "next_window": "下一個視窗", + "shortcuts": "鍵盤快速鍵", + "docs": "說明文件", + "settings": "設定…", + "about": "關於 Bulwark", + "from_plugins": "更多外掛程式選項…", + "pick_gone": "此選單已過期。請重新開啟後再試一次。" + }, + "context": { + "add_to_dictionary": "將「{word}」加入字典", + "no_suggestions": "沒有建議", + "open_link": "開啟連結", + "copy_link": "拷貝連結", + "copy_address": "拷貝位址", + "save_image": "儲存圖片…", + "copy_image": "拷貝圖片" + }, + "closing": { + "unsaved_title": "草稿無法儲存", + "unsaved_detail": "如果現在關閉,自上次儲存以來所寫的內容將會遺失。", + "close_anyway": "仍要關閉", + "unsent_title": "有郵件尚未寄出", + "unsent_one": "有 1 封郵件正在等待寄出,將在您下次開啟 Bulwark 時寄出。", + "unsent_many": "有 {count} 封郵件正在等待寄出,將在您下次開啟 Bulwark 時寄出。", + "quit": "結束", + "leave_title": "有未儲存的變更,確定要離開嗎?", + "leave_detail": "您在此頁面所做的變更尚未儲存。", + "leave": "離開", + "stay": "留下" + }, + "spellcheck": { + "offer": "{language}拼字檢查需要字典。Bulwark 可以從 Google 下載。您的文字不會被傳送。", + "download": "下載" + }, + "restore": { + "unavailable_title": "目前無法開啟此草稿", + "unavailable_body": "草稿安全地保存在伺服器的「草稿」中。連線恢復後請再試一次。", + "try_again": "重試", + "close": "關閉" } }, "desktop_sign_in": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 770f679eb..9ad6edd12 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6558,6 +6558,85 @@ "save_as": "另存为…", "copy": "复制", "copy_failed": "无法复制文件。" + }, + "menu": { + "file": "文件", + "edit": "编辑", + "view": "视图", + "message": "邮件", + "go": "前往", + "window": "窗口", + "help": "帮助", + "new_window": "新建窗口", + "print": "打印…", + "save_pdf": "另存为 PDF…", + "switch_connection": "更改连接…", + "close": "关闭", + "quit": "退出", + "undo": "撤销", + "redo": "重做", + "cut": "剪切", + "copy": "复制", + "paste": "粘贴", + "select_all": "全选", + "search": "搜索邮件", + "refresh": "检查新邮件", + "reading_pane": "阅读窗格", + "pane_right": "在右侧", + "pane_below": "在列表下方", + "pane_off": "关闭", + "zoom_in": "放大", + "zoom_out": "缩小", + "zoom_reset": "实际大小", + "full_screen": "全屏", + "open_in_window": "在新窗口中打开", + "next_message": "下一封邮件", + "previous_message": "上一封邮件", + "next_unread": "下一封未读邮件", + "mail": "邮件", + "files": "文件", + "back": "后退", + "forward": "前进", + "minimize": "最小化", + "next_window": "下一个窗口", + "shortcuts": "键盘快捷键", + "docs": "文档", + "settings": "设置…", + "about": "关于 Bulwark", + "from_plugins": "更多插件选项…", + "pick_gone": "该菜单已过期。请重新打开后再试一次。" + }, + "context": { + "add_to_dictionary": "将“{word}”添加到词典", + "no_suggestions": "无建议", + "open_link": "打开链接", + "copy_link": "复制链接", + "copy_address": "复制地址", + "save_image": "保存图片…", + "copy_image": "复制图片" + }, + "closing": { + "unsaved_title": "草稿无法保存", + "unsaved_detail": "如果现在关闭,自上次保存以来所写的内容将会丢失。", + "close_anyway": "仍然关闭", + "unsent_title": "有邮件尚未发送", + "unsent_one": "有 1 封邮件正在等待发送,将在您下次打开 Bulwark 时发出。", + "unsent_many": "有 {count} 封邮件正在等待发送,将在您下次打开 Bulwark 时发出。", + "quit": "退出", + "leave_title": "有未保存的更改,确定离开吗?", + "leave_detail": "您在此页面所做的更改尚未保存。", + "leave": "离开", + "stay": "留下" + }, + "spellcheck": { + "offer": "{language}拼写检查需要词典。Bulwark 可以从 Google 下载。您的文字不会被发送。", + "download": "下载" + }, + "restore": { + "unavailable_title": "目前无法打开此草稿", + "unavailable_body": "草稿安全地保存在服务器的“草稿”中。连接恢复后请重试。", + "try_again": "重试", + "close": "关闭" } }, "desktop_sign_in": {