feat: show what each account keeps on the device and let it be changed and cleared

The offline section of Settings > Backup & reset now shows, per account, the
space each part takes, the last sync, a first sync's progress with the time
left, the window and attachment choices, and clearing the account. All
conflicts, calendar and contacts too, are listed there and counted in the
banner.

- the organisation's caps beat the user's choice and the desktop default, and
  every path that takes a scope goes through `effectiveScope`
- the desktop app keeps mail without asking, says so once, and stays off for
  an account where the user turned it off
- `navigator.storage.persist()` is asked on turning it on, and a refusal
  warns with a way to ask again; Safari gets the 7-day hint when not installed
- a store the browser deleted is noticed, says what was lost and syncs again
- a full disk says so, with what to do
- those who had offline mode keep it, with the 30 days they had
- Keep offline in the Files app's menu
This commit is contained in:
Linus Rath
2026-11-03 08:57:41 +01:00
parent 22a2d9e3a6
commit b001cbf109
55 changed files with 3701 additions and 92 deletions
@@ -25,7 +25,7 @@ const CAP_FIELDS: CapField[] = [
{
key: 'offlineMaxAttachmentMb',
label: 'Largest attachment or file kept',
description: 'Anything bigger is removed from the device and opened from the server when there is a connection.',
description: 'Anything bigger leaves the device and opens from the server when there is a connection. Files someone chose to keep offline count only towards the total below.',
unit: 'MB',
max: 1024 * 1024,
},
+11 -1
View File
@@ -3,12 +3,13 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff,
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useKeepFileOffline } from "@/hooks/use-keep-file-offline";
import { useRegisterCommands } from "@/lib/command-registry";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { Button } from "@/components/ui/button";
@@ -319,6 +320,8 @@ export function FileBrowser({
const dragDepth = useRef(0);
const anchorName = useRef<string | null>(null);
const menu = useContextMenu<MenuData>();
const tOffline = useTranslations("offlineStorage");
const { keptOffline, canKeepOffline, toggleKeptOffline } = useKeepFileOffline(client);
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -939,6 +942,13 @@ export function FileBrowser({
)}
</>
)}
{canKeepOffline(resource) && (
<ContextMenuItem
icon={keptOffline.has(resource.id) ? PinOff : Pin}
label={keptOffline.has(resource.id) ? tOffline("unpin") : tOffline("keep_offline")}
onClick={() => { closeMenu(); void toggleKeptOffline(resource); }}
/>
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
{canShare(resource) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
@@ -0,0 +1,251 @@
import 'fake-indexeddb/auto';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const offline = vi.hoisted(() => ({
clearAccount: vi.fn(async () => {}),
chooseScope: vi.fn(),
requestPersistence: vi.fn(async () => 'refused'),
dismissAreaConflict: vi.fn(async () => {}),
}));
vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/offline/offline-mode')>()),
clearAccount: offline.clearAccount,
chooseScope: offline.chooseScope,
requestPersistence: offline.requestPersistence,
dismissAreaConflict: offline.dismissAreaConflict,
readPersistence: vi.fn(async () => 'refused'),
refreshUsage: vi.fn(async () => {}),
}));
import { OfflineSettings } from '../offline-settings';
import { OfflineBanner } from '../offline-banner';
import { useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
import { fileRefFor } from '@/hooks/use-keep-file-offline';
const area = vi.fn(async (_account: string, name: string) => (name === 'files.kept' ? {
files: [
{ jmap: 'j', id: 'f1', name: 'Pinned.pdf', size: 2048, pinned: true, kept: true, error: null },
{ jmap: 'j', id: 'f2', name: 'Big.iso', size: 1048576, pinned: true, kept: false, error: 'overLimit' },
{ jmap: 'j', id: 'f3', name: 'Opened.txt', size: 5, pinned: false, kept: true, error: null },
],
} : null));
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>) {
usePolicyStore.setState({
loaded: true, source: 'server', subject: null,
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
} as never);
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
setStoreBackend({ supported: () => true, area, setScope: vi.fn(async () => {}) } as unknown as StoreBackend);
useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map()) as never });
useOfflineStore.setState({
enabled: ['acc'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {},
storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false,
usage: { acc: { bytes: 5000, parts: [{ part: 'mail', bytes: 4000, items: 12 }, { part: 'attachments', bytes: 1000, items: 2 }] } },
});
setDevices({});
});
afterEach(() => { setStoreBackend(null); });
const windowSelect = () => screen.getByText('window_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
describe('what an account keeps', () => {
it('offers every window and hands the choice on', () => {
render(<OfflineSettings />);
expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '90', '365', 'all']);
expect(windowSelect().value).toBe('90');
fireEvent.change(windowSelect(), { target: { value: 'all' } });
expect(offline.chooseScope).toHaveBeenCalledWith('acc', { days: null });
});
it('under a cap offers only what fits, and says who set it', () => {
setDevices({ offlineMaxDays: 60 });
render(<OfflineSettings />);
expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '60']);
expect(windowSelect().value).toBe('60');
expect(within(screen.getByText('window_label').closest('[data-search-label]') as HTMLElement).getByText('org_locked')).toBeInTheDocument();
});
it('locks the window when the cap leaves no choice', () => {
setDevices({ offlineMaxDays: 14 });
render(<OfflineSettings />);
const row = screen.getByText('window_label').closest('[data-search-label]') as HTMLElement;
expect(windowSelect().parentElement?.closest('[inert]')).not.toBeNull();
expect(within(row).getByText('org_locked')).toBeInTheDocument();
});
it('locks attachments off when the organisation forbids them, and hides the size', () => {
useOfflineStore.setState({ choices: { acc: { on: true, attachments: true } } });
setDevices({ noOfflineAttachments: true });
render(<OfflineSettings />);
const toggle = screen.getByTestId('offline-attachments');
expect(toggle.getAttribute('aria-checked')).toBe('false');
expect(toggle.closest('[inert]')).not.toBeNull();
expect(screen.queryByText('attachment_cap_label')).toBeNull();
fireEvent.click(toggle);
});
it('caps the size choice', () => {
setDevices({ offlineMaxAttachmentMb: 10 });
render(<OfflineSettings />);
const select = screen.getByText('attachment_cap_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
expect([...select.options].map((o) => o.value)).toEqual(['5', '10']);
fireEvent.change(select, { target: { value: '5' } });
expect(offline.chooseScope).toHaveBeenCalledWith('acc', { maxAttachmentMb: 5 });
});
it('says when the total is set by the organisation', () => {
setDevices({ offlineMaxTotalMb: 100 });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-total-locked').textContent).toContain('total_locked');
});
it('shows nothing of this with offline off', () => {
useOfflineStore.setState({ enabled: [] });
render(<OfflineSettings />);
expect(screen.queryByText('window_label')).toBeNull();
expect(screen.queryByTestId('offline-clear')).toBeNull();
});
});
describe('what it takes', () => {
it('lists each part', () => {
render(<OfflineSettings />);
const usage = screen.getByTestId('offline-usage');
expect(usage.textContent).toContain('usage_mail');
expect(usage.textContent).toContain('usage_attachments');
expect(screen.getByTestId('offline-status').textContent).toContain('usage');
});
it('shows the time of the last sync from before a reload', () => {
useOfflineStore.setState({ health: { acc: { opened: true, lastSync: Date.now() - 60_000 } } });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-status').textContent).toContain('usage_updated');
});
it('shows a large sync going on, with the time left once it can tell', () => {
const now = Date.now();
useOfflineStore.setState({
syncing: ['acc'],
progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now - 60_000, startedDone: 0, at: now } },
});
render(<OfflineSettings />);
const line = screen.getByTestId('offline-progress');
expect(line.textContent).toContain('progress_bodies');
expect(line.textContent).toContain('progress_count');
expect(line.textContent).toContain('progress_left');
expect(line.querySelector('progress')?.getAttribute('max')).toBe('5000');
});
it('keeps a small catch-up quiet', () => {
const now = Date.now();
useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 1, total: 3, startedAt: now, startedDone: 0, at: now } } });
render(<OfflineSettings />);
expect(screen.queryByTestId('offline-progress')).toBeNull();
});
});
describe('clearing an account', () => {
it('asks first, naming unsent mail', () => {
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'Hi', to: [], status: 'waiting' }] });
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-clear'));
expect(offline.clearAccount).not.toHaveBeenCalled();
expect(screen.getByText(/clear_confirm_unsent/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'clear_confirm' }));
expect(offline.clearAccount).toHaveBeenCalledWith('acc');
});
});
describe('what the browser may do', () => {
it('warns when it refused to keep the data, and can ask again', async () => {
useOfflineStore.setState({ persist: 'refused' });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-persist-refused').textContent).toContain('persist_refused');
await act(async () => { fireEvent.click(screen.getByTestId('offline-persist-retry')); });
expect(offline.requestPersistence).toHaveBeenCalled();
});
it('says nothing when it agreed', () => {
render(<OfflineSettings />);
expect(screen.queryByTestId('offline-persist-refused')).toBeNull();
});
it('warns about a store it deleted, with the count, until read', () => {
useOfflineStore.setState({ health: { acc: { opened: true, evicted: { at: 1, lost: 2 } } } });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-evicted').textContent).toContain('evicted_lost');
fireEvent.click(within(screen.getByTestId('offline-evicted')).getByRole('button', { name: 'evicted_dismiss' }));
expect(useOfflineStore.getState().health.acc.evicted).toBeUndefined();
});
it('says the device is full, with what to do', () => {
useOfflineStore.setState({ storageFull: { acc: 1 } });
render(<OfflineSettings />);
const alert = screen.getByTestId('offline-storage-full');
expect(alert.textContent).toContain('storage_full_title');
fireEvent.click(within(alert).getByRole('button', { name: 'clear_button' }));
expect(screen.getByRole('button', { name: 'clear_confirm' })).toBeInTheDocument();
});
it('gives the Safari hint only in Safari', () => {
const real = window.navigator.userAgent;
render(<OfflineSettings />);
expect(screen.queryByTestId('offline-safari-hint')).toBeNull();
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-safari-hint')).toBeInTheDocument();
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: real });
});
});
describe('everything that did not go through', () => {
const conflicts = [
{ area: 'calendar', account: 'acc', id: 1, createdAt: 1, title: 'Team meeting', reason: 'changed', destroy: false, detail: null },
{ area: 'contacts', account: 'acc', id: 2, createdAt: 1, title: 'Ana Silva', reason: 'refused', destroy: false, detail: 'Nope' },
] as const;
it('lists calendar and contacts next to mail, and each can be dismissed', async () => {
useOfflineStore.setState({ areaConflicts: [...conflicts] });
render(<OfflineSettings />);
const rows = screen.getAllByTestId('offline-conflict-row');
expect(rows.map((row) => row.getAttribute('data-area'))).toEqual(['calendar', 'contacts']);
expect(rows[0].textContent).toContain('Team meeting');
fireEvent.click(within(rows[1]).getByRole('button', { name: 'dismiss_label' }));
await waitFor(() => expect(offline.dismissAreaConflict).toHaveBeenCalledWith(conflicts[1]));
});
it('counts them all in the banner', () => {
useOfflineStore.setState({ areaConflicts: [...conflicts] });
render(<OfflineBanner />);
expect(screen.getByTestId('offline-banner-conflicts')).toBeInTheDocument();
});
});
describe('files kept offline', () => {
it('lists the pinned ones with their state, and can let go of one', async () => {
render(<OfflineSettings />);
const rows = await screen.findAllByTestId('offline-kept-file');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('kept_state_kept');
expect(rows[1].textContent).toContain('kept_error_overLimit');
expect(screen.queryByText('Opened.txt')).toBeNull();
fireEvent.click(within(rows[0]).getByRole('button', { name: 'unpin_label' }));
await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.objectContaining({ jmap: 'j', id: 'f1' })));
});
it('a node of another account goes by that account', () => {
expect(fileRefFor('shared1:n7', 'own')).toEqual({ jmap: 'shared1', id: 'n7' });
expect(fileRefFor('n7', 'own')).toEqual({ jmap: 'own', id: 'n7' });
});
});
+205
View File
@@ -0,0 +1,205 @@
'use client';
import { useEffect, useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { Lock } from '@/components/icons';
import { SettingItem, Select, ToggleSwitch } from '@/components/settings/settings-section';
import { OrgLockedNote } from '@/components/settings/org-locked-note';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { formatFileSize } from '@/lib/utils';
import { chooseScope, clearAccount, disableOffline, dismissEvicted, enableOffline, scopeForAccount } from '@/lib/offline/offline-mode';
import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope';
import { useOfflineStore } from '@/lib/offline/state';
import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress';
import { devicesForAccount } from '@/lib/device-policy';
import { KeptFiles } from './kept-files';
const MB = 1024 * 1024;
const ALL = 'all';
function ProgressLine({ progress }: { progress: SyncProgress }) {
const t = useTranslations('offlineStorage');
const format = useFormatter();
const phase = progress.phase === 'list' ? t('progress_list')
: progress.phase === 'bodies' ? t('progress_bodies')
: progress.phase === 'threads' ? t('progress_threads')
: progress.area === 'files' ? t('progress_files')
: t('progress_other');
const left = minutesLeft(progress);
const count = progress.total === null
? t('progress_count_open', { done: format.number(progress.done) })
: t('progress_count', { done: format.number(progress.done), total: format.number(progress.total) });
return (
<div className="space-y-1" data-testid="offline-progress">
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{phase}: {count}{left !== null && <> · {t('progress_left', { minutes: left })}</>}
</p>
{progress.total !== null && progress.total > 0 && (
<progress className="h-1 w-full overflow-hidden rounded-full [&::-webkit-progress-bar]:bg-muted [&::-webkit-progress-value]:bg-primary [&::-moz-progress-bar]:bg-primary" value={Math.min(progress.done, progress.total)} max={progress.total} aria-label={phase} />
)}
</div>
);
}
/** One account in Settings > Backup & reset > Offline. */
export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) {
const t = useTranslations('offline.settings');
const tStorage = useTranslations('offlineStorage');
const format = useFormatter();
const enabled = useOfflineStore((s) => s.enabled.includes(account));
const usage = useOfflineStore((s) => s.usage[account]);
const syncing = useOfflineStore((s) => s.syncing.includes(account));
const lastSynced = useOfflineStore((s) => s.lastSyncedAt[account] ?? s.health[account]?.lastSync);
const evicted = useOfflineStore((s) => s.health[account]?.evicted);
const storageFull = useOfflineStore((s) => !!s.storageFull[account]);
const progress = useOfflineStore((s) => s.progress[account]);
const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length);
// Re-renders on a choice; the value itself is worked out below, with the policy.
useOfflineStore((s) => s.choices[account]);
const [confirmOff, setConfirmOff] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const devices = devicesForAccount(account);
const { scope, locked } = scopeForAccount(account);
const [now, setNow] = useState(() => Date.now());
useEffect(() => { setNow(Date.now()); }, [lastSynced, syncing]);
const testId = (id: string) => (primary ? id : `${id}-other`);
if (locked.offline) {
return (
<div className="py-3" data-testid={testId('offline-forbidden')}>
{heading && <p className="mb-1 text-sm font-semibold text-foreground [overflow-wrap:anywhere]">{heading}</p>}
<p className="text-sm font-medium text-foreground">{t('keep_label')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('forbidden')}</p>
<OrgLockedNote className="mt-1" />
</div>
);
}
const windows = windowOptions(devices.offlineMaxDays);
const windowLabel = (days: number | null) => {
if (days === null) return tStorage('window_everything');
if (days === 365) return tStorage('window_year');
return tStorage('window_days', { days });
};
const caps = attachmentCapOptions(devices.offlineMaxAttachmentMb);
const currentCap = maxAttachmentMb(scope);
return (
<div className={heading ? 'rounded-md border border-border px-3' : undefined} data-testid={testId('offline-account')}>
{heading && <p className="pt-3 text-sm font-semibold text-foreground [overflow-wrap:anywhere]">{heading}</p>}
<SettingItem label={t('keep_label')} description={tStorage('keep_description')}>
<ToggleSwitch
checked={enabled}
testId={testId('offline-switch')}
onChange={(on) => {
if (on) void enableOffline(account);
else if (unsent > 0) setConfirmOff(true);
else void disableOffline(account);
}}
/>
</SettingItem>
{evicted && (
<div className="my-2 flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="alert" data-testid="offline-evicted">
<p className="min-w-0">
{tStorage('evicted')}
{evicted.lost > 0 && <> {tStorage('evicted_lost', { count: evicted.lost })}</>}
</p>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => dismissEvicted(account)}>{tStorage('evicted_dismiss')}</Button>
</div>
)}
{enabled && (
<>
{storageFull && (
<div className="my-2 flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-foreground sm:flex-row sm:items-center sm:justify-between" role="alert" data-testid="offline-storage-full">
<p className="min-w-0"><span className="font-medium">{tStorage('storage_full_title')}</span> {tStorage('storage_full_text')}</p>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setConfirmClear(true)}>{tStorage('clear_button')}</Button>
</div>
)}
<div className="space-y-2 py-3">
<p className="text-xs text-muted-foreground" role="status" aria-live="polite" data-testid={testId('offline-status')}>
{syncing
? t('syncing')
: lastSynced
? t('usage_updated', { size: formatFileSize(usage?.bytes ?? 0), time: format.relativeTime(lastSynced, now) })
: t('usage', { size: formatFileSize(usage?.bytes ?? 0) })}
</p>
{syncing && progressWorthShowing(progress) && <ProgressLine progress={progress} />}
{usage && usage.parts.length > 0 && (
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs" data-testid={testId('offline-usage')}>
{usage.parts.map((part) => (
<div key={part.part} className="contents" data-part={part.part}>
<dt className="min-w-0 text-muted-foreground">{tStorage(`usage_${part.part}`)}</dt>
<dd className="text-end tabular-nums text-foreground">{tStorage('usage_row', { size: formatFileSize(part.bytes), count: part.items })}</dd>
</div>
))}
</dl>
)}
</div>
<SettingItem label={tStorage('window_label')} description={tStorage('window_description')} locked={windows.length <= 1}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Select
value={scope.days === null ? ALL : String(scope.days)}
onChange={(value) => chooseScope(account, { days: value === ALL ? null : Number(value) })}
options={windows.map((days) => ({ value: days === null ? ALL : String(days), label: windowLabel(days) }))}
/>
{locked.days && windows.length > 1 && <OrgLockedNote />}
</div>
</SettingItem>
<SettingItem
label={tStorage('attachments_label')}
description={tStorage('attachments_description', { total: formatFileSize(scope.maxTotalBytes) })}
locked={locked.attachments}
>
<ToggleSwitch
checked={scope.attachments}
testId={testId('offline-attachments')}
onChange={(on) => chooseScope(account, { attachments: on })}
/>
</SettingItem>
{locked.maxTotal && !locked.attachments && (
<p className="flex items-center gap-1 pb-2 text-xs text-muted-foreground" data-testid={testId('offline-total-locked')}>
<Lock className="h-3 w-3 shrink-0" aria-hidden="true" />
{tStorage('total_locked', { size: formatFileSize(scope.maxTotalBytes) })}
</p>
)}
{scope.attachments && (
<SettingItem label={tStorage('attachment_cap_label')} nested locked={caps.length <= 1}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Select
value={String(currentCap)}
onChange={(value) => chooseScope(account, { maxAttachmentMb: Number(value) })}
options={(caps.includes(currentCap) ? caps : [...caps, currentCap].sort((a, b) => a - b)).map((mb) => ({ value: String(mb), label: formatFileSize(mb * MB) }))}
/>
{locked.maxAttachment && caps.length > 1 && <OrgLockedNote />}
</div>
</SettingItem>
)}
<KeptFiles account={account} />
<SettingItem label={tStorage('clear_label')} description={tStorage('clear_description')}>
<Button variant="outline" size="sm" onClick={() => setConfirmClear(true)} data-testid={testId('offline-clear')}>{tStorage('clear_button')}</Button>
</SettingItem>
</>
)}
<ConfirmDialog
isOpen={confirmOff}
onClose={() => setConfirmOff(false)}
onConfirm={() => { setConfirmOff(false); void disableOffline(account); }}
title={t('off_confirm_title')}
message={t('off_confirm_message', { count: unsent })}
confirmText={t('off_confirm')}
variant="destructive"
/>
<ConfirmDialog
isOpen={confirmClear}
onClose={() => setConfirmClear(false)}
onConfirm={() => { setConfirmClear(false); void clearAccount(account); }}
title={tStorage('clear_confirm_title')}
message={unsent > 0 ? `${tStorage('clear_confirm_message')} ${tStorage('clear_confirm_unsent', { count: unsent })}` : tStorage('clear_confirm_message')}
confirmText={tStorage('clear_confirm')}
variant="destructive"
/>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/lib/utils';
import { useKeptFiles } from '@/hooks/use-kept-files';
/** The files this account keeps until told otherwise, in Settings > Backup & reset > Offline. */
export function KeptFiles({ account }: { account: string }) {
const t = useTranslations('offlineStorage');
const { pinned, unpin } = useKeptFiles(account);
if (pinned.length === 0) return null;
return (
<div className="border-b border-border py-3 last:border-0" data-testid="offline-kept-files">
<h4 className="text-sm font-medium text-foreground">{t('kept_files_title')}</h4>
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={t('kept_files_title')}>
{pinned.map((file) => (
<li key={`${file.jmap}:${file.id}`} className="flex flex-col gap-2 px-3 py-2 sm:flex-row sm:items-center sm:justify-between" data-testid="offline-kept-file" data-kept={file.kept}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{file.name}</p>
<p className="text-xs text-muted-foreground">
{file.size !== null && <>{formatFileSize(file.size)} · </>}
{file.kept ? t('kept_state_kept') : file.error ? t(`kept_error_${file.error}` as 'kept_error_download') : t('kept_state_waiting')}
</p>
</div>
<Button variant="outline" size="sm" className="shrink-0" aria-label={t('unpin_label', { name: file.name })} onClick={() => void unpin(file)}>{t('unpin')}</Button>
</li>
))}
</ul>
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function OfflineBanner() {
const browserOffline = useOfflineStore((s) => s.browserOffline || s.startedOffline);
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
const waiting = useOfflineStore((s) => s.outbox.length);
const conflicts = useOfflineStore((s) => s.conflicts.length);
const conflicts = useOfflineStore((s) => s.conflicts.length + s.areaConflicts.length);
const tConflicts = useTranslations('offlineConflicts');
const [open, setOpen] = useState(false);
const offline = browserOffline || serverDown;
+20 -1
View File
@@ -2,10 +2,12 @@
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { addSignOutGate } from '@/lib/sign-out-cleanup';
import { reloadToNewBuild } from '@/lib/app-update';
import { buildSettingsPath } from '@/lib/deep-links';
import { confirmLoss, setOfflineNotifier, startOfflineMode, type OfflineNotice } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
import { toast } from '@/stores/toast-store';
@@ -23,6 +25,12 @@ export function OfflineHost() {
const tConflicts = useTranslations('offlineConflicts');
const tConflictsRef = useRef(tConflicts);
tConflictsRef.current = tConflicts;
const tStorage = useTranslations('offlineStorage');
const tStorageRef = useRef(tStorage);
tStorageRef.current = tStorage;
const router = useRouter();
const routerRef = useRef(router);
routerRef.current = router;
const { dialogProps, confirm } = useConfirmDialog();
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
@@ -30,6 +38,7 @@ export function OfflineHost() {
useEffect(() => {
const show = (notice: OfflineNotice) => {
const tr = tRef.current;
const openStorage = { label: tStorageRef.current('open_settings'), onClick: () => routerRef.current.push(buildSettingsPath('backup', 'offline')) };
switch (notice.kind) {
case 'queued':
toast.info(tr('toast.queued'));
@@ -52,7 +61,17 @@ export function OfflineHost() {
toast.warning(tConflictsRef.current('toast', { count: notice.count }));
break;
case 'storage-failed':
toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 });
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
break;
case 'evicted':
toast.warning(tStorageRef.current('evicted'), {
message: notice.lost > 0 ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined,
duration: notice.lost > 0 ? 0 : undefined,
action: openStorage,
});
break;
case 'default-on':
toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
break;
case 'refused-delete':
toast.warning(tr('toast.refused_delete'));
+48 -58
View File
@@ -1,94 +1,84 @@
'use client';
import { useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { AlertTriangle } from '@/components/icons';
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
import { OrgLockedNote } from '@/components/settings/org-locked-note';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { IS_LITE } from '@/lib/lite';
import { formatFileSize } from '@/lib/utils';
import { disableOffline, enableOffline, offlineAllowed, offlineSupported } from '@/lib/offline/offline-mode';
import { signedInAccountIds } from '@/lib/device-policy';
import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
import { useDevicePolicy } from '@/hooks/use-device-policy';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { AccountStorage } from './account-storage';
import { OutboxList } from './outbox-list';
/**
* Settings > Backup & reset > Offline (#523): "Keep mail on this device"
* for the active account, the space it uses, and the outbox. Not in Lite.
* When the organisation forbids it, the switch is gone and a note says so.
* Settings > Backup & reset > Offline (#523): for each account signed in here,
* whether it keeps mail on this device, how much, what that takes, and the way
* to clear it. Above that what the browser may do to it, below it the outbox
* and whatever didn't go through. Not in Lite.
*/
export function OfflineSettings() {
const t = useTranslations('offline.settings');
const format = useFormatter();
const account = useAuthStore((s) => s.activeAccountId);
const tStorage = useTranslations('offlineStorage');
const active = useAuthStore((s) => s.activeAccountId);
useAuthStore((s) => s.connectedAccountsRevision);
// Re-renders when the organisation's device settings change.
const devices = useDevicePolicy();
const enabled = useOfflineStore((s) => !!account && s.enabled.includes(account));
const usage = useOfflineStore((s) => s.usageBytes);
const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account));
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length);
const [confirmOff, setConfirmOff] = useState(false);
useDevicePolicy();
const labels = useAccountStore((s) => s.accounts);
const enabledCount = useOfflineStore((s) => s.enabled.length);
const persist = useOfflineStore((s) => s.persist);
const [asking, setAsking] = useState(false);
useEffect(() => {
if (IS_LITE || enabledCount === 0) return;
void readPersistence();
void refreshUsage();
}, [enabledCount]);
if (IS_LITE) return null;
const allowed = !devices.noOffline && offlineAllowed(account);
const turnOff = () => {
if (!account) return;
void disableOffline(account);
const accounts = [...new Set([...(active ? [active] : []), ...signedInAccountIds()])];
const several = accounts.length > 1;
const askAgain = async () => {
setAsking(true);
await requestPersistence();
setAsking(false);
};
return (
<SettingsSection id="offline" title={t('title')} description={t('description')}>
{!offlineSupported() ? (
<p className="text-sm text-muted-foreground" data-testid="offline-unsupported">{t('unsupported')}</p>
) : !allowed ? (
<div className="py-3" data-testid="offline-forbidden">
<p className="text-sm font-medium text-foreground">{t('keep_label')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('forbidden')}</p>
<OrgLockedNote className="mt-1" />
</div>
) : (
<>
<SettingItem label={t('keep_label')} description={t('keep_description')}>
<ToggleSwitch
checked={enabled}
testId="offline-switch"
onChange={(on) => {
if (!account) return;
if (on) void enableOffline(account);
else if (unsent > 0) setConfirmOff(true);
else turnOff();
}}
/>
</SettingItem>
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p>{t('warning')}</p>
</div>
{enabled && (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite" data-testid="offline-status">
{syncing
? t('syncing')
: lastSynced
? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) })
: t('usage', { size: formatFileSize(usage) })}
</p>
{enabledCount > 0 && persist === 'refused' && (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
<p className="min-w-0">{tStorage('persist_refused')}</p>
<Button variant="outline" size="sm" className="shrink-0" disabled={asking} onClick={() => void askAgain()} data-testid="offline-persist-retry">{tStorage('persist_retry')}</Button>
</div>
)}
{enabledCount > 0 && safariMayDelete() && (
<p className="rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="offline-safari-hint">{tStorage('safari_hint')}</p>
)}
{accounts.map((account, index) => (
<AccountStorage
key={account}
account={account}
primary={index === 0}
heading={several ? labels.find((entry) => entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null}
/>
))}
</>
)}
<OutboxList />
<ConfirmDialog
isOpen={confirmOff}
onClose={() => setConfirmOff(false)}
onConfirm={() => { setConfirmOff(false); turnOff(); }}
title={t('off_confirm_title')}
message={t('off_confirm_message', { count: unsent })}
confirmText={t('off_confirm')}
variant="destructive"
/>
</SettingsSection>
);
}
+26 -3
View File
@@ -5,9 +5,9 @@ import { useFormatter, useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush';
import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
import { dismissAreaConflict, refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
import { dismissConflict, type ConflictEntry } from '@/lib/offline/queue';
import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state';
import { isOfflineNow, useOfflineStore, type AreaConflict, type OutboxRow } from '@/lib/offline/state';
import { useAccountStore } from '@/stores/account-store';
/**
@@ -22,10 +22,12 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
const t = useTranslations('offline.outbox');
const tErrors = useTranslations('offline.errors');
const tConflicts = useTranslations('offlineConflicts');
const tStorage = useTranslations('offlineStorage');
const format = useFormatter();
const rows = useOfflineStore((s) => s.outbox);
const changes = useOfflineStore((s) => s.pendingChanges);
const conflicts = useOfflineStore((s) => s.conflicts);
const areaConflicts = useOfflineStore((s) => s.areaConflicts);
const accounts = useAccountStore((s) => s.accounts);
const [deleting, setDeleting] = useState<OutboxRow | null>(null);
const several = new Set(rows.map((r) => r.account)).size > 1 || accounts.length > 1;
@@ -52,6 +54,12 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
return tConflicts(conflict.summary.op === 'destroy' ? 'changed_delete' : 'changed');
};
const areaConflictText = (conflict: AreaConflict) => {
if (conflict.reason === 'refused') return tConflicts('refused', { reason: conflict.detail ?? '' });
if (conflict.reason === 'gone') return tConflicts('gone');
return tConflicts(conflict.destroy ? 'changed_delete' : 'changed');
};
const dismiss = async (conflict: ConflictEntry) => {
await dismissConflict(conflict.account, conflict.id);
await refreshQueueState();
@@ -92,7 +100,7 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
{changes > 0 && (
<p className="mt-2 text-xs text-muted-foreground" data-testid="offline-pending-changes">{t('changes', { count: changes })}</p>
)}
{conflicts.length > 0 && (
{conflicts.length + areaConflicts.length > 0 && (
<div className="mt-3" data-testid="offline-conflicts">
<p className="text-xs text-muted-foreground">{tConflicts('description')}</p>
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={tConflicts('title')}>
@@ -109,6 +117,21 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
</div>
</li>
))}
{areaConflicts.map((conflict) => (
<li key={`${conflict.account}:${conflict.area}:${conflict.id}`} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-conflict-row" data-area={conflict.area} data-reason={conflict.reason}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{tStorage(conflict.area === 'calendar' ? 'conflict_calendar' : 'conflict_contacts')}: {conflict.title || t('no_subject')}
</p>
<p className="text-xs text-destructive [overflow-wrap:anywhere]">
{areaConflictText(conflict)} · {format.dateTime(conflict.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" aria-label={tConflicts('dismiss_label', { subject: conflict.title || t('no_subject') })} onClick={() => void dismissAreaConflict(conflict)}>{tConflicts('dismiss')}</Button>
</div>
</li>
))}
</ul>
</div>
)}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import { useCallback, useMemo } from 'react';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useAuthStore } from '@/stores/auth-store';
import { useKeptFiles } from './use-kept-files';
interface FileRef {
id: string;
isDirectory?: boolean;
isSymlink?: boolean;
}
/**
* Nodes of another account are listed as "accountId:nodeId" (stores/file-store.ts).
* The store keeps them under the account they live in.
*/
export function fileRefFor(id: string, ownAccount: string): { jmap: string; id: string } {
const at = id.indexOf(':');
return at > 0 ? { jmap: id.slice(0, at), id: id.slice(at + 1) } : { jmap: ownAccount, id };
}
/** "Keep offline" in the Files app. Nothing to offer when the account doesn't keep mail here. */
export function useKeepFileOffline(client: IJMAPClient | null | undefined) {
const account = useAuthStore((s) => s.activeAccountId);
const { active, pinned, pin, unpin } = useKeptFiles(account);
const own = client?.getAccountId() ?? '';
const keptOffline = useMemo(() => new Set(pinned.map((file) => (file.jmap === own ? file.id : `${file.jmap}:${file.id}`))), [pinned, own]);
const canKeepOffline = useCallback((resource: FileRef) => active && !!own && !resource.isDirectory && !resource.isSymlink, [active, own]);
const toggleKeptOffline = useCallback(async (resource: FileRef) => {
const ref = fileRefFor(resource.id, own);
await (keptOffline.has(resource.id) ? unpin(ref) : pin(ref)).catch(() => {});
}, [own, keptOffline, pin, unpin]);
return { keptOffline, canKeepOffline, toggleKeptOffline };
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getStore } from '@/lib/store/client/api';
import { offlineActive } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
/** A row of the Files area's `files.kept`. */
export interface KeptFile {
jmap: string;
id: string;
name: string;
size: number | null;
pinned: boolean;
kept: boolean;
error: string | null;
}
/**
* The files an account keeps offline, and pinning them. Nothing when the
* account doesn't keep mail here, or the build has no Files area.
*/
export function useKeptFiles(account: string | null | undefined) {
const [files, setFiles] = useState<KeptFile[]>([]);
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
const active = useOfflineStore((s) => !!account && s.enabled.includes(account)) && offlineActive(account);
const read = useCallback(async () => {
if (!account || !active) {
setFiles([]);
return;
}
try {
setFiles((await getStore().area<{ files: KeptFile[] }>(account, 'files.kept'))?.files ?? []);
} catch {
// The store is starting or being deleted, or this build has no Files area.
setFiles([]);
}
}, [account, active]);
useEffect(() => { void read(); }, [read, lastSynced]);
const pinned = useMemo(() => files.filter((file) => file.pinned), [files]);
const pin = useCallback(async (ref: { jmap: string; id: string }) => {
if (!account) return;
await getStore().area(account, 'files.pin', ref);
await read();
}, [account, read]);
const unpin = useCallback(async (ref: { jmap: string; id: string }) => {
if (!account) return;
await getStore().area(account, 'files.unpin', ref);
await read();
}, [account, read]);
return { active, files, pinned, pin, unpin };
}
+1
View File
@@ -26,6 +26,7 @@ describe('purgeSignedOutData', () => {
for (const key of [
'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage',
'files-favorites', 'files-recent-files', 'files-path-stack', 'bulwark-delegate-names',
'bulwark-offline-accounts', 'bulwark-offline-choices', 'bulwark-offline-health',
'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode',
]) localStorage.setItem(key, 'x');
@@ -0,0 +1,216 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { getOfflineHooks, setOfflineHooks } from '@/lib/jmap/offline-hooks';
import { setStoreBackend } from '@/lib/store/client/api';
import { FakeJmap } from '@/lib/store/__tests__/helpers/fake-jmap';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import {
chooseScope,
clearAccount,
disableOffline,
enableOffline,
enforcePolicy,
registerOfflineHooks,
scopeForAccount,
setOfflineNotifier,
wipeEverything,
type OfflineNotice,
} from '../offline-mode';
import { useOfflineStore, writeEnabled } from '../state';
const NOW = Date.now();
const iso = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString();
const notices: OfflineNotice[] = [];
let store: InProcessStore;
let server: FakeJmap;
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>): void {
usePolicyStore.setState({
loaded: true, source: 'server', subject: null, subjectPending: false,
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
} as never);
}
const ids = (account = 'acc') => store.dbs.get(account)!.all<{ id: string }>('SELECT id FROM email ORDER BY id').map((row) => row.id);
beforeEach(() => {
localStorage.clear();
server = new FakeJmap();
store = new InProcessStore(server);
setStoreBackend(store);
notices.length = 0;
setOfflineNotifier((notice) => notices.push(notice));
useOfflineStore.setState({ enabled: [], choices: {}, health: {}, outbox: [], conflicts: [], areaConflicts: [], usage: {}, storageFull: {}, progress: {}, persist: 'unknown', browserOffline: false, downServers: [], startedOffline: false });
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
useAuthStore.setState({ activeAccountId: 'acc', client: null, getClientForAccount: (() => null) as never, getAllConnectedClients: (() => new Map()) as never });
setDevices({});
});
afterEach(async () => {
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
setOfflineHooks(null);
setOfflineNotifier(() => {});
await wipeEverything();
setStoreBackend(null);
});
describe('a policy fetch', () => {
async function kept(): Promise<void> {
server.addEmail({ id: 'old', receivedAt: iso(100) });
server.addEmail({ id: 'new', receivedAt: iso(5) });
writeEnabled(['acc']);
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
await store.setScope('acc', scopeForAccount('acc').scope);
await store.sync('acc');
expect(ids()).toEqual(['new', 'old']);
}
it('with a lower cap prunes what is kept, without fetching what stays', async () => {
await kept();
server.calls.length = 0;
setDevices({ offlineMaxDays: 30 });
enforcePolicy();
await vi.waitFor(() => expect(ids()).toEqual(['new']));
expect(server.calls.filter(([name]) => name === 'Email/query')).toEqual([]);
});
it('with the cap raised again starts a backfill', async () => {
await kept();
setDevices({ offlineMaxDays: 30 });
enforcePolicy();
await vi.waitFor(() => expect(ids()).toEqual(['new']));
setDevices({});
enforcePolicy();
await vi.waitFor(() => expect(ids()).toEqual(['new', 'old']));
});
it('with defaults only (no policy heard yet) changes nothing', async () => {
await kept();
usePolicyStore.setState({ source: 'fallback' } as never);
const setScope = vi.spyOn(store, 'setScope');
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(setScope).not.toHaveBeenCalled();
});
});
describe('the user picking what to keep', () => {
it('goes to the store and prunes', async () => {
server.addEmail({ id: 'old', receivedAt: iso(100) });
server.addEmail({ id: 'new', receivedAt: iso(5) });
writeEnabled(['acc']);
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
await store.setScope('acc', scopeForAccount('acc').scope);
await store.sync('acc');
chooseScope('acc', { days: 30 });
await vi.waitFor(() => expect(ids()).toEqual(['new']));
expect(JSON.parse(localStorage.getItem('bulwark-offline-choices')!).acc.days).toBe(30);
});
it('can\'t get past the cap', () => {
setDevices({ offlineMaxDays: 30 });
writeEnabled(['acc']);
chooseScope('acc', { days: null });
expect(scopeForAccount('acc').scope.days).toBe(30);
});
});
describe('opened messages', () => {
it('past the organisation\'s cap aren\'t recorded; under it they are', async () => {
registerOfflineHooks();
writeEnabled(['acc']);
setDevices({ offlineMaxDays: 30 });
const area = vi.spyOn(store, 'area');
const client = { getServerUrl: () => 'https://mail.example.org', getUsername: () => 'alice@example.org', getAccountId: () => 'j' } as never;
const message = (id: string, receivedAt: string) => ({ id, receivedAt, mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'x', isTruncated: false } } });
getOfflineHooks()!.record!(client, [['Email/get', { accountId: 'j' }, 'c']], {
methodResponses: [['Email/get', { accountId: 'j', list: [message('old', iso(100)), message('new', iso(5))] }, 'c']],
} as never);
await vi.waitFor(() => expect(area).toHaveBeenCalled());
const recorded = area.mock.calls.filter(([, name]) => name === 'mail.recordOpened').flatMap(([, , params]) => (params as { emails: Array<{ id: string }> }).emails.map((email) => email.id));
expect(recorded).toEqual(['new']);
});
});
describe('clearing an account', () => {
it('removes its store and starts over, and it stays on', async () => {
server.addEmail({ id: 'new', receivedAt: iso(5) });
writeEnabled(['acc']);
await store.sync('acc');
expect(ids()).toEqual(['new']);
const before = store.dbs.get('acc');
await clearAccount('acc');
expect(store.removed).toContain('acc');
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
expect(store.dbs.get('acc')).not.toBe(before);
expect(useOfflineStore.getState().health.acc?.opened).toBe(false);
// It isn't read as a browser clearing the store at the next open.
expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]);
});
});
describe('the desktop app', () => {
const asDesktop = () => { (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; };
it('keeps mail for an account without asking, and says so once', async () => {
asDesktop();
enforcePolicy();
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc']));
expect(notices.filter((n) => n.kind === 'default-on')).toHaveLength(1);
enforcePolicy();
expect(notices.filter((n) => n.kind === 'default-on')).toHaveLength(1);
});
it('not when the organisation forbids it', async () => {
asDesktop();
setDevices({ noOffline: true });
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
expect(notices.filter((n) => n.kind === 'default-on')).toEqual([]);
});
it('not before the policy is known', async () => {
asDesktop();
usePolicyStore.setState({ source: 'none' } as never);
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
});
it('not after the user turned it off', async () => {
asDesktop();
enforcePolicy();
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc']));
await disableOffline('acc');
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
expect(useOfflineStore.getState().choices.acc?.on).toBe(false);
});
it('holds to a cap from the first open', async () => {
asDesktop();
setDevices({ offlineMaxDays: 14 });
enforcePolicy();
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc']));
expect(scopeForAccount('acc').scope.days).toBe(14);
});
});
describe('a browser', () => {
it('stays opt-in', async () => {
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
expect(notices).toEqual([]);
await enableOffline('acc');
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
});
});
+22 -2
View File
@@ -4,7 +4,9 @@ import { setStoreBackend } from '@/lib/store/client/api';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { forgetLegacyMove, LEGACY_DB_NAME, moveLegacyOutbox } from '../migrate-legacy';
import { readQueue } from '../queue';
import { useOfflineStore } from '../state';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { effectiveScope, platformDefault, userChoiceFor } from '../scope';
import { OFFLINE_CHOICES_KEY, useOfflineStore, writeChoice } from '../state';
/** The old database, exactly as lib/offline/db.ts laid it out. */
const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'queue', 'uploads'];
@@ -72,7 +74,8 @@ beforeEach(async () => {
store = new InProcessStore();
setStoreBackend(store);
forgetLegacyMove();
useOfflineStore.setState({ enabled: ['acc'] });
localStorage.clear();
useOfflineStore.setState({ enabled: ['acc'], choices: {} });
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(LEGACY_DB_NAME);
request.onsuccess = () => resolve();
@@ -156,6 +159,23 @@ describe('moving the old outbox into the store', () => {
expect(await store.area('acc', 'mail.session')).toMatchObject({ apiUrl: 'https://old/jmap' });
});
it('keeps the account on, with the 30 days it had, and writes that down as its choice', async () => {
await seedOld();
await moveLegacyOutbox();
const state = useOfflineStore.getState();
expect(state.enabled).toContain('acc');
expect(state.choices.acc).toMatchObject({ on: true, days: 30 });
expect(JSON.parse(localStorage.getItem(OFFLINE_CHOICES_KEY)!).acc.days).toBe(30);
expect(effectiveScope('acc', userChoiceFor('acc'), DEFAULT_ORGANISATION_POLICY.devices, platformDefault()).scope.days).toBe(30);
});
it('never takes a window the user already picked', async () => {
await seedOld();
writeChoice('acc', { on: true, days: 365 });
await moveLegacyOutbox();
expect(useOfflineStore.getState().choices.acc.days).toBe(365);
});
it('never creates the old database when it isn\'t there', async () => {
await moveLegacyOutbox();
expect(await oldExists()).toBe(false);
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_SCOPE } from '@/lib/store/area';
import {
attachmentCapOptions,
effectiveScope,
LEGACY_CHOICE,
offlineDefaultOn,
olderThanCap,
platformDefault,
userChoiceFor,
windowOptions,
} from '../scope';
import { useOfflineStore } from '../state';
const NO_LIMITS = DEFAULT_ORGANISATION_POLICY.devices;
const MB = 1024 * 1024;
const BROWSER = { on: false, scope: DEFAULT_SCOPE };
const DESKTOP = { on: true, scope: DEFAULT_SCOPE };
describe('effectiveScope', () => {
it('the default when nothing else says', () => {
expect(effectiveScope('a', null, NO_LIMITS, BROWSER)).toMatchObject({ account: 'a', enabled: false, scope: DEFAULT_SCOPE });
expect(effectiveScope('a', null, NO_LIMITS, DESKTOP).enabled).toBe(true);
});
it('the user over the default', () => {
const effective = effectiveScope('a', { on: true, days: 30, attachments: false, maxAttachmentMb: 5 }, NO_LIMITS, BROWSER);
expect(effective.enabled).toBe(true);
expect(effective.scope).toEqual({ ...DEFAULT_SCOPE, days: 30, attachments: false, maxAttachmentBytes: 5 * MB });
expect(effectiveScope('a', { on: false }, NO_LIMITS, DESKTOP).enabled).toBe(false);
expect(effectiveScope('a', { days: null }, NO_LIMITS, BROWSER).scope.days).toBeNull();
});
it('the organisation over the user and over the desktop default', () => {
const policy = { ...NO_LIMITS, noOffline: true };
expect(effectiveScope('a', { on: true }, policy, BROWSER).enabled).toBe(false);
expect(effectiveScope('a', null, policy, DESKTOP).enabled).toBe(false);
expect(effectiveScope('a', { on: true }, policy, DESKTOP).locked.offline).toBe(true);
});
it('a cap lowers the choice and the default, also "everything", and never raises', () => {
const policy = { ...NO_LIMITS, offlineMaxDays: 60, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 100 };
const capped = effectiveScope('a', { days: null, maxAttachmentMb: 500 }, policy, DESKTOP);
expect(capped.scope).toEqual({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB });
expect(capped.locked).toMatchObject({ days: true, maxAttachment: true, maxTotal: true, attachments: false });
expect(effectiveScope('a', null, { ...policy, offlineMaxDays: 30 }, DESKTOP).scope.days).toBe(30);
const under = effectiveScope('a', { days: 30, maxAttachmentMb: 5 }, policy, BROWSER);
expect(under.scope.days).toBe(30);
expect(under.scope.maxAttachmentBytes).toBe(5 * MB);
});
it('forbidden attachments stay off whatever the user chose', () => {
const effective = effectiveScope('a', { attachments: true }, { ...NO_LIMITS, noOfflineAttachments: true }, DESKTOP);
expect(effective.scope.attachments).toBe(false);
expect(effective.locked.attachments).toBe(true);
});
});
describe('the platform default', () => {
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it('is off in a browser and on with the desktop bridge', () => {
expect(offlineDefaultOn()).toBe(false);
expect(platformDefault().on).toBe(false);
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
expect(offlineDefaultOn()).toBe(true);
expect(platformDefault().on).toBe(true);
});
});
describe('what the user chose', () => {
beforeEach(() => useOfflineStore.setState({ enabled: [], choices: {} }));
it('nothing for an account that never had it on', () => {
expect(userChoiceFor('a')).toBeNull();
});
it('an account on from before the local store keeps the 30 days it had', () => {
useOfflineStore.setState({ enabled: ['a'] });
expect(userChoiceFor('a')).toEqual(LEGACY_CHOICE);
expect(LEGACY_CHOICE.days).toBe(30);
expect(effectiveScope('a', userChoiceFor('a'), NO_LIMITS, BROWSER).scope.days).toBe(30);
});
it('a stored choice wins', () => {
useOfflineStore.setState({ enabled: ['a'], choices: { a: { on: true, days: 365 } } });
expect(userChoiceFor('a')?.days).toBe(365);
});
});
describe('what the page offers', () => {
it('every window without a cap', () => {
expect(windowOptions(0)).toEqual([30, 90, 365, null]);
});
it('what fits under a cap, and the cap itself', () => {
expect(windowOptions(90)).toEqual([30, 90]);
expect(windowOptions(60)).toEqual([30, 60]);
expect(windowOptions(7)).toEqual([7]);
expect(attachmentCapOptions(0)).toEqual([5, 25, 100, 500]);
expect(attachmentCapOptions(10)).toEqual([5, 10]);
});
});
describe('Lite', () => {
it('reads the caps from the same policy.json, and has no store to apply them to', async () => {
const { applyLitePolicy } = await import('@/lib/lite-config');
const policy = applyLitePolicy({ organisation: { devices: { offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 'lots' } } } as never);
expect(policy.organisation?.devices).toMatchObject({ offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 0 });
});
});
describe('olderThanCap', () => {
const now = Date.UTC(2026, 10, 3);
it('only with a cap, and only past it', () => {
expect(olderThanCap('2026-01-01T00:00:00Z', 0, now)).toBe(false);
expect(olderThanCap('2026-01-01T00:00:00Z', 30, now)).toBe(true);
expect(olderThanCap('2026-10-30T00:00:00Z', 30, now)).toBe(false);
expect(olderThanCap(undefined, 30, now)).toBe(false);
});
});
@@ -0,0 +1,326 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { configureStore, getStore, setStoreBackend } from '@/lib/store/client/api';
import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store/client/keys';
import { FAKE_SESSION } from '@/lib/store/__tests__/helpers/fake-jmap';
import { usePolicyStore } from '@/stores/policy-store';
import {
enableOffline,
registerOfflineHooks,
setOfflineNotifier,
startOfflineMode,
wipeAccount,
wipeEverything,
type OfflineNotice,
} from '../offline-mode';
import { readPersistence, requestPersistence, safariMayDelete, wasEvicted } from '../storage-health';
import { OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY, useOfflineStore, writeChoice, writeEnabled, writeHealth } from '../state';
/** Everything on one page: a BroadcastChannel that also reaches other objects here, as in a browser. */
class FakeChannel {
static open = new Set<FakeChannel>();
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(readonly name: string) {
FakeChannel.open.add(this);
}
postMessage(data: unknown): void {
for (const channel of FakeChannel.open) {
if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent);
}
}
close(): void {
FakeChannel.open.delete(this);
}
}
class FakeWorker {
static all: FakeWorker[] = [];
/** What the worker found on OPFS. */
static fileExisted = true;
onmessage: ((event: MessageEvent) => void) | null = null;
posted: Array<Record<string, unknown>> = [];
constructor() {
FakeWorker.all.push(this);
}
postMessage(message: Record<string, unknown>): void {
this.posted.push(message);
if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: { fileExisted: FakeWorker.fileExisted } } } as MessageEvent));
if (message.kind === 'sweep-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: 0 } } as MessageEvent));
}
terminate(): void {}
}
const rpcs: Array<{ method: string; params: { name?: string; account?: string } }> = [];
/** The worker's side of the channel: an empty store. */
function ownerOnChannel(): void {
const owner = new FakeChannel('bulwark-store-rpc');
owner.onmessage = (event) => {
const message = event.data as { kind: string; id: string; from: string; method: string; params: { name?: string } };
if (message.kind === 'who-owns') owner.postMessage({ kind: 'owner-changed', owner: 'worker' });
if (message.kind !== 'rpc') return;
rpcs.push({ method: message.method, params: message.params });
const name = message.params.name ?? '';
const result = name === 'outbox.read' ? { patches: 0, entries: [], conflicts: [] }
: name.endsWith('.conflicts') ? []
: name.endsWith('.pending') ? 0
: name === 'store.usage' ? { bytes: 0, parts: [] }
: null;
owner.postMessage({ kind: 'reply', id: message.id, to: message.from, result });
};
owner.postMessage({ kind: 'owner-changed', owner: 'worker' });
}
const notices: OfflineNotice[] = [];
function setStorage(storage: Record<string, unknown>): void {
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: storage });
}
function setUserAgent(value: string): void {
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value });
}
const realUserAgent = window.navigator.userAgent;
beforeEach(() => {
localStorage.clear();
deleteKeyDatabase();
FakeChannel.open.clear();
FakeWorker.all = [];
FakeWorker.fileExisted = true;
rpcs.length = 0;
notices.length = 0;
vi.stubGlobal('BroadcastChannel', FakeChannel);
vi.stubGlobal('Worker', FakeWorker);
vi.stubGlobal('FileSystemFileHandle', class {});
setStorage({ getDirectory: async () => ({ removeEntry: async () => {} }), persist: async () => true, persisted: async () => true });
Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async (_name: string, fn: () => unknown) => fn() } });
useOfflineStore.setState({
enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usage: {}, usageBytes: 0, choices: {}, health: {},
storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false,
});
usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
setStoreBackend(null);
setOfflineNotifier((notice) => notices.push(notice));
registerOfflineHooks();
});
afterEach(() => {
getStore().stop();
configureStore(null);
setStoreBackend(null);
setOfflineNotifier(() => {});
vi.unstubAllGlobals();
delete (window.navigator as { storage?: unknown }).storage;
delete (window.navigator as { locks?: unknown }).locks;
setUserAgent(realUserAgent);
});
/** Opens an account in this tab, as the owner, the way the page does. */
async function openAsOwner(account: string): Promise<FakeWorker> {
useOfflineStore.setState((s) => ({ enabled: [...s.enabled, account] }));
await getStore().ensureOpen(account, FAKE_SESSION);
const worker = FakeWorker.all.at(-1)!;
worker.onmessage?.({ data: { kind: 'owner' } } as MessageEvent);
ownerOnChannel();
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open' && (m.params as { account: string }).account === account)).toBe(true));
return worker;
}
describe('a store the browser cleared', () => {
it('a key with no file: warns, with how many waiting items went', async () => {
await openAccountKey('acc');
writeHealth('acc', { opened: true, pending: 3 });
FakeWorker.fileExisted = false;
await openAsOwner('acc');
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 3 }));
expect(useOfflineStore.getState().health.acc.evicted?.lost).toBe(3);
expect(useOfflineStore.getState().health.acc.pending).toBe(0);
});
it('no key where one was made: warns too', async () => {
writeHealth('acc', { opened: true, pending: 0 });
FakeWorker.fileExisted = false;
await openAsOwner('acc');
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 0 }));
});
it('a first open is not an eviction', async () => {
FakeWorker.fileExisted = false;
await openAsOwner('acc');
await vi.waitFor(() => expect(useOfflineStore.getState().health.acc?.opened).toBe(true));
expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]);
});
it('a normal start is not one either', async () => {
await openAccountKey('acc');
writeHealth('acc', { opened: true, pending: 3 });
await openAsOwner('acc');
await vi.waitFor(() => expect(FakeWorker.all[0].posted.some((m) => m.kind === 'open')).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices).toEqual([]);
});
it('files with no key are swept once this tab owns the store', async () => {
const { record } = await openAccountKey('acc');
const worker = await openAsOwner('acc');
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'sweep-files')).toBe(true));
expect((worker.posted.find((m) => m.kind === 'sweep-files') as { known: string[] }).known).toEqual([record.file]);
});
it('wasEvicted', () => {
expect(wasEvicted({ opened: true, hadKey: true, fileExisted: false })).toBe(true);
expect(wasEvicted({ opened: false, hadKey: true, fileExisted: false })).toBe(true);
expect(wasEvicted({ opened: true, hadKey: false, fileExisted: false })).toBe(true);
expect(wasEvicted({ opened: false, hadKey: false, fileExisted: false })).toBe(false);
expect(wasEvicted({ opened: true, hadKey: true, fileExisted: true })).toBe(false);
});
it('keeps only numbers about it outside the store', () => {
writeHealth('acc', { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 });
expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 } });
});
});
describe('a failed write', () => {
it('says the device is full, at most once in a while, until a sync goes through', async () => {
const worker = await openAsOwner('acc');
const event = (e: unknown) => worker.onmessage?.({ data: { kind: 'event', event: e } } as MessageEvent);
event({ type: 'error', account: 'acc', area: 'storage', message: 'SQLITE_FULL' });
event({ type: 'error', account: 'acc', area: 'storage', message: 'SQLITE_FULL' });
expect(notices.filter((n) => n.kind === 'storage-failed').length).toBeLessThanOrEqual(1);
expect(useOfflineStore.getState().storageFull.acc).toBeGreaterThan(0);
event({ type: 'synced', account: 'acc', at: 123 });
expect(useOfflineStore.getState().storageFull.acc).toBeUndefined();
expect(useOfflineStore.getState().health.acc?.lastSync).toBe(123);
});
});
describe('asking the browser to keep it', () => {
it('asks when offline is turned on, and remembers a refusal', async () => {
const persist = vi.fn(async () => false);
setStorage({ persist, persisted: async () => false, getDirectory: async () => ({}) });
await enableOffline('acc').catch(() => {});
await vi.waitFor(() => expect(useOfflineStore.getState().persist).toBe('refused'));
expect(persist).toHaveBeenCalledTimes(1);
});
it('asks again after an install', async () => {
const persist = vi.fn(async () => true);
setStorage({ persist, persisted: async () => false, getDirectory: async () => ({}) });
writeEnabled(['acc']);
const stop = startOfflineMode();
window.dispatchEvent(new Event('appinstalled'));
await vi.waitFor(() => expect(persist).toHaveBeenCalled());
await vi.waitFor(() => expect(useOfflineStore.getState().persist).toBe('granted'));
stop();
registerOfflineHooks();
});
it('tells a browser without the API from a refusal', async () => {
setStorage({});
expect(await requestPersistence()).toBe('unsupported');
expect(await readPersistence()).toBe('unsupported');
});
});
describe('the Safari hint', () => {
const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
const IPAD_CHROME = 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1';
const MAC_SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15';
const MAC_CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
const WIN_EDGE = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 Edg/126.0';
const ANDROID_WEBVIEW = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0 Mobile Safari/537.36';
const FIREFOX = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:128.0) Gecko/20100101 Firefox/128.0';
const ELECTRON = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Bulwark/1.0 Chrome/126.0 Electron/31.0 Safari/537.36';
it('shows in Safari and on iOS browsers that are not installed', () => {
for (const ua of [IPHONE, IPAD_CHROME, MAC_SAFARI]) expect(safariMayDelete(ua, false)).toBe(true);
});
it('not once installed', () => {
for (const ua of [IPHONE, MAC_SAFARI]) expect(safariMayDelete(ua, true)).toBe(false);
});
it('nowhere else', () => {
for (const ua of [MAC_CHROME, WIN_EDGE, ANDROID_WEBVIEW, FIREFOX, ELECTRON]) expect(safariMayDelete(ua, false)).toBe(false);
});
it('reads the real browser by default', () => {
setUserAgent(MAC_SAFARI);
expect(safariMayDelete()).toBe(true);
setUserAgent(MAC_CHROME);
expect(safariMayDelete()).toBe(false);
});
});
describe('removing an account', () => {
it('takes its data, key, choice and counts, and leaves the other account alone', async () => {
const { record } = await openAccountKey('gone');
await openAccountKey('stays');
writeEnabled(['gone', 'stays']);
writeChoice('gone', { days: 30 });
writeChoice('stays', { days: 90 });
writeHealth('gone', { opened: true, pending: 1 });
await openAsOwner('stays');
await wipeAccount('gone');
expect(await getAccountRecord('gone')).toBeNull();
expect(await getAccountRecord('stays')).not.toBeNull();
expect(rpcs).toContainEqual({ method: 'removeAccount', params: { account: 'gone', file: record.file } });
expect(useOfflineStore.getState().choices).toEqual({ stays: { days: 90 } });
expect(useOfflineStore.getState().health.gone).toBeUndefined();
expect(JSON.parse(localStorage.getItem(OFFLINE_CHOICES_KEY)!)).toEqual({ stays: { days: 90 } });
});
it('with the last account, deletes the pool and every key', async () => {
const removeEntry = vi.fn(async () => {});
setStorage({ getDirectory: async () => ({ removeEntry }), persist: async () => true });
await openAccountKey('acc');
writeEnabled(['acc']);
writeChoice('acc', { days: 30 });
await wipeAccount('acc');
expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true });
await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull());
expect(localStorage.getItem(OFFLINE_CHOICES_KEY)).toBeNull();
expect(localStorage.getItem(OFFLINE_HEALTH_KEY)).toBeNull();
});
it('wipeEverything leaves nothing either', async () => {
const removeEntry = vi.fn(async () => {});
setStorage({ getDirectory: async () => ({ removeEntry }) });
await openAccountKey('a');
await openAccountKey('b');
writeEnabled(['a', 'b']);
await wipeEverything();
expect(removeEntry).toHaveBeenCalled();
await vi.waitFor(async () => expect(await getAccountRecord('a')).toBeNull());
expect(await getAccountRecord('b')).toBeNull();
expect(useOfflineStore.getState().enabled).toEqual([]);
});
});
describe('a full sign-out', () => {
it('deletes the files, every key and what was stored about them', async () => {
const removeEntry = vi.fn(async () => {});
setStorage({ getDirectory: async () => ({ removeEntry }) });
await openAccountKey('acc');
writeEnabled(['acc']);
writeChoice('acc', { days: 30 });
writeHealth('acc', { opened: true, pending: 2 });
purgeSignedOutData();
await vi.waitFor(() => expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true }));
await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull());
for (const key of ['bulwark-offline-accounts', OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull();
expect(useOfflineStore.getState().choices).toEqual({});
expect(useOfflineStore.getState().health).toEqual({});
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { minutesLeft, nextProgress, progressWorthShowing } from '../sync-progress';
const event = (phase: string, done: number, total: number | null) => ({ area: 'mail', phase, done, total });
describe('the time left', () => {
it('says nothing until it has seen enough', () => {
const start = nextProgress(undefined, event('bodies', 0, 1000), 0);
expect(minutesLeft(start)).toBeNull();
expect(minutesLeft(nextProgress(start, event('bodies', 100, 1000), 1_000))).toBeNull();
});
it('works from the rate of this phase', () => {
const start = nextProgress(undefined, event('bodies', 0, 6000), 0);
// 100 a second: 59 s left rounds up.
expect(minutesLeft(nextProgress(start, event('bodies', 100, 6000), 1_000))).toBeNull();
expect(minutesLeft(nextProgress(start, event('bodies', 600, 6000), 6_000))).toBe(1);
});
it('while listing, counts the bodies that come after', () => {
const start = nextProgress(undefined, event('list', 0, 50_000), 0);
const left = minutesLeft(nextProgress(start, event('list', 7_000, 50_000), 5_000));
// Bench rig: 35 s to list, about 7 minutes in all.
expect(left).toBeGreaterThanOrEqual(5);
expect(left).toBeLessThanOrEqual(10);
});
it('starts over with each phase', () => {
const list = nextProgress(undefined, event('list', 1000, 1000), 0);
const bodies = nextProgress(list, event('bodies', 10, 1000), 60_000);
expect(bodies.startedAt).toBe(60_000);
expect(bodies.startedDone).toBe(10);
});
it('has no time without a total, or when done', () => {
const open = nextProgress(undefined, event('list', 0, null), 0);
expect(minutesLeft(nextProgress(open, event('list', 500, null), 10_000))).toBeNull();
const done = nextProgress(undefined, event('bodies', 0, 100), 0);
expect(minutesLeft(nextProgress(done, event('bodies', 100, 100), 10_000))).toBeNull();
});
});
describe('what is worth a line', () => {
it('a big job or an unknown one, not a few new messages', () => {
expect(progressWorthShowing(undefined)).toBe(false);
expect(progressWorthShowing(nextProgress(undefined, event('bodies', 1, 3), 0))).toBe(false);
expect(progressWorthShowing(nextProgress(undefined, event('bodies', 1, 500), 0))).toBe(true);
expect(progressWorthShowing(nextProgress(undefined, event('list', 500, null), 0))).toBe(true);
});
});
+4 -1
View File
@@ -14,7 +14,8 @@ import { getStore } from '@/lib/store/client/api';
import type { StoreSession } from '@/lib/store/engine/transport';
import type { LegacyImport } from '@/lib/store/outbox/calls';
import type { OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem } from '@/lib/store/outbox/items';
import { useOfflineStore } from './state';
import { LEGACY_CHOICE } from './scope';
import { useOfflineStore, writeChoice } from './state';
export const LEGACY_DB_NAME = 'bulwark-offline';
@@ -172,6 +173,8 @@ async function moveNow(): Promise<void> {
const keyRecord = await result(db.transaction('keys', 'readonly').objectStore('keys').get('aes')) as { key: CryptoKey } | undefined;
const key = keyRecord?.key ?? null;
for (const account of useOfflineStore.getState().enabled) {
// Keeps the window it had: a choice now, so a later default can't change it.
if (!useOfflineStore.getState().choices[account]) writeChoice(account, LEGACY_CHOICE);
const part = await readAccount(db, key, account);
const { session, ...content } = part;
if (session) await getStore().ensureOpen(account, session);
+251 -23
View File
@@ -21,13 +21,13 @@
*/
import { IS_LITE } from '@/lib/lite';
import { devicesForAccount } from '@/lib/device-policy';
import { devicesForAccount, signedInAccountIds } from '@/lib/device-policy';
import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects';
import { labelsPolicyFor } from '@/lib/outgoing/classification';
import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards';
import { emitPushStateChange } from '@/lib/push-events';
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
import { STORAGE_ERROR_AREA } from '@/lib/store/area';
import { STORAGE_ERROR_AREA, type StoreScope, type StoreUsage } from '@/lib/store/area';
import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api';
import { purgeStoreFiles } from '@/lib/store/client/purge';
import type { StoreSession } from '@/lib/store/engine/transport';
@@ -52,7 +52,30 @@ import {
type PolicyFingerprint,
} from './queue';
import { clearHandoffs } from '@/lib/launch-handoff';
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
import {
forgetAccountState,
forgetAllAccountState,
isOfflineNow,
OFFLINE_ACCOUNTS_KEY,
OFFLINE_CHOICES_KEY,
OFFLINE_HEALTH_KEY,
readEnabledFrom,
readStoredChoices,
readStoredHealth,
serverKey,
setServerDown,
useOfflineStore,
writeChoice,
writeEnabled,
writeHealth,
type AreaConflict,
type OutboxRow,
type UserChoice,
} from './state';
import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope';
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
import { readPersistence, requestPersistence, safariMayDelete, wasEvicted } from './storage-health';
import { nextProgress } from './sync-progress';
import { flushQueue, settleStaleSends } from './flush';
import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
@@ -66,6 +89,8 @@ export type OfflineNotice =
| { kind: 'lost'; subjects: string[] }
| { kind: 'conflict'; count: number }
| { kind: 'storage-failed' }
| { kind: 'evicted'; lost: number }
| { kind: 'default-on' }
| { kind: 'refused-delete' }
| { kind: 'refused-other' }
| { kind: 'forbidden' }
@@ -107,6 +132,11 @@ export function offlineAllowed(account: string | null | undefined): boolean {
return offlineSupported() && !devicesForAccount(account ?? null).noOffline;
}
/** What this account keeps here: the organisation's policy over the user's choice over the platform's default. */
export function scopeForAccount(account: string): EffectiveScope {
return effectiveScope(account, userChoiceFor(account), devicesForAccount(account), platformDefault());
}
/** "Keep mail on this device" is on for this account here, and allowed. */
export function offlineActive(account: string | null | undefined): boolean {
return !!account && useOfflineStore.getState().enabled.includes(account) && offlineAllowed(account);
@@ -145,6 +175,9 @@ const storeHooks: StorePageHooks = {
return {
session: (client?.getSessionSnapshot() as StoreSession | undefined) ?? null,
auth: client?.getAuthHeader() ?? null,
// Until the policy is known the store keeps its last scope, so a slow
// start doesn't read as a wider window that has to be fetched.
scope: scopeDecided(account) ? scopeForAccount(account).scope : undefined,
invertedKeywordSort: client?.keywordSortInverted() ?? null,
};
},
@@ -155,12 +188,34 @@ const storeHooks: StorePageHooks = {
await client.requestNetworkOnly([['Core/echo', {}, '0']]);
return client.getAuthHeader();
},
onOpened(account, found) {
const health = useOfflineStore.getState().health[account];
if (wasEvicted({ opened: !!health?.opened, ...found })) {
const lost = health?.pending ?? 0;
writeHealth(account, { opened: true, pending: 0, evicted: { at: Date.now(), lost } });
notify({ kind: 'evicted', lost });
void refreshQueueState();
return;
}
writeHealth(account, { opened: true });
},
onEvent(event) {
if (event.type === 'synced') {
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
useOfflineStore.setState((s) => {
const { [event.account]: _full, ...storageFull } = s.storageFull;
const { [event.account]: _progress, ...progress } = s.progress;
return { lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at }, storageFull, progress };
});
writeHealth(event.account, { lastSync: event.at });
void refreshUsage();
// A pass also sends what the calendar and contacts have waiting.
void refreshQueueState();
}
if (event.type === 'progress') {
useOfflineStore.setState((s) => ({ progress: { ...s.progress, [event.account]: nextProgress(s.progress[event.account], event, Date.now()) } }));
}
if (event.type === 'error' && event.area === STORAGE_ERROR_AREA) {
useOfflineStore.setState((s) => ({ storageFull: { ...s.storageFull, [event.account]: s.storageFull[event.account] ?? Date.now() } }));
const now = Date.now();
if (now - lastStorageNotice < STORAGE_NOTICE_MS) return;
lastStorageNotice = now;
@@ -194,6 +249,9 @@ function noticeRefused(answered: OfflineResponse | null, methodCalls: OfflineMet
notify({ kind: emptied ? 'refused-delete' : 'refused-other' });
}
/** What the store may keep for later when it answers for the server. */
const QUEUEING_METHOD = /^(Email|CalendarEvent|Calendar|ContactCard|AddressBook)\/set$/;
const probes = new Map<string, Promise<boolean>>();
/** A black-holed connection never answers the probe either. */
const PROBE_TIMEOUT_MS = 5_000;
@@ -238,7 +296,7 @@ const hooks: OfflineHooks = {
// The store couldn't start or lost its owner: as if nothing were kept.
}
noticeRefused(answered, methodCalls);
if (methodCalls.some(([name]) => name === 'Email/set') && answered) {
if (answered && methodCalls.some(([name]) => QUEUEING_METHOD.test(name))) {
requestOutboxSync();
void refreshQueueState();
}
@@ -270,7 +328,11 @@ const hooks: OfflineHooks = {
if (!offlineActive(account) || !account) return;
for (const [name, result, callId] of response.methodResponses) {
if (name !== 'Email/get' || !Array.isArray(result?.list)) continue;
const opened = (result.list as Array<{ bodyValues?: Record<string, { isTruncated?: boolean }> }>).filter(hasWholeText);
// Past the organisation's cap a message isn't kept, however it got opened.
const cap = devicesForAccount(account).offlineMaxDays;
const now = Date.now();
const opened = (result.list as Array<{ bodyValues?: Record<string, { isTruncated?: boolean }>; receivedAt?: unknown }>)
.filter((email) => hasWholeText(email) && !olderThanCap(email.receivedAt, cap, now));
if (opened.length === 0) continue;
const args = methodCalls.find(([n, , id]) => id === callId && n === name)?.[1];
const jmap = (result.accountId as string) || (args?.accountId as string) || client.getAccountId();
@@ -471,13 +533,13 @@ export async function refreshQueueState(): Promise<void> {
// Nothing is kept for an account that hasn't it on; and opening the
// store would create it (on the sign-in page, after a sign-out).
if (useOfflineStore.getState().enabled.length === 0) {
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [] });
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [] });
return;
}
try {
// Sends of a tab closed on the way: uncertain, so they can be looked up.
await settleStaleSends();
const { patches, outbox: entries, conflicts } = await readQueue();
const { patches, patchesBy, outbox: entries, conflicts } = await readQueue();
const rows: OutboxRow[] = entries.map((entry) => ({
id: entry.id,
account: entry.account,
@@ -487,17 +549,72 @@ export async function refreshQueueState(): Promise<void> {
status: entry.status,
error: entry.error,
}));
useOfflineStore.setState({ outbox: rows, pendingChanges: patches, conflicts });
const { enabled } = useOfflineStore.getState();
const areas = await Promise.all(enabled.map(readAreaState));
useOfflineStore.setState({ outbox: rows, pendingChanges: patches, conflicts, areaConflicts: areas.flatMap((area) => area.conflicts) });
// What a browser clearing the store would lose. Counted here, and the
// count is all that's kept outside the store.
enabled.forEach((account, index) => {
const sends = rows.filter((row) => row.account === account).length;
writeHealth(account, { pending: sends + (patchesBy[account] ?? 0) + areas[index].pending });
});
} catch {
// The store is being deleted, or can't start.
}
}
interface StoredCalendarConflict { id: number; createdAt: number; title: string | null; reason: 'refused' | 'changedOnServer' | 'destroyedOnServer'; kind: string; description: string | null; errorType: string | null }
interface StoredContactConflict { id: number; createdAt: number; summary: string; kind: 'refused' | 'changed' | 'gone'; local: { op?: string }; error: string | null }
/** What the calendar and contacts have waiting or couldn't send. An area this build doesn't have counts as empty. */
async function readAreaState(account: string): Promise<{ conflicts: AreaConflict[]; pending: number }> {
const read = <T,>(name: string, none: T) => getStore().area<T | null | undefined>(account, name).then((value) => value ?? none, () => none);
const [calendar, contacts, calendarPending, contactsPending] = await Promise.all([
read<StoredCalendarConflict[]>('calendar.conflicts', []),
read<StoredContactConflict[]>('contacts.conflicts', []),
read<number>('calendar.pending', 0),
read<number>('contacts.pending', 0),
]);
const conflicts: AreaConflict[] = [
...calendar.map((row): AreaConflict => ({
area: 'calendar',
account,
id: row.id,
createdAt: row.createdAt,
title: row.title ?? '',
reason: row.reason === 'refused' ? 'refused' : row.reason === 'destroyedOnServer' ? 'gone' : 'changed',
destroy: row.kind === 'destroy',
detail: row.description ?? row.errorType,
})),
...contacts.map((row): AreaConflict => ({
area: 'contacts',
account,
id: row.id,
createdAt: row.createdAt,
title: row.summary,
reason: row.kind,
destroy: row.local?.op === 'destroy',
detail: row.error,
})),
];
return { conflicts, pending: calendarPending + contactsPending };
}
/** The user has seen it: the calendar's or the contacts' copy stays as the server has it. */
export async function dismissAreaConflict(conflict: AreaConflict): Promise<void> {
await getStore().area(conflict.account, `${conflict.area}.dismissConflict`, { id: conflict.id });
await refreshQueueState();
}
export async function refreshUsage(): Promise<void> {
if (!offlineSupported() || useOfflineStore.getState().enabled.length === 0) return;
const { enabled } = useOfflineStore.getState();
if (!offlineSupported() || enabled.length === 0) return;
try {
const usage = await Promise.all(useOfflineStore.getState().enabled.map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage')));
useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0) });
const usage = await Promise.all(enabled.map((account) => getStore().area<StoreUsage>(account, 'store.usage')));
useOfflineStore.setState({
usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0),
usage: Object.fromEntries(enabled.map((account, index) => [account, usage[index]])),
});
} catch {
/* deleted meanwhile */
}
@@ -586,6 +703,7 @@ async function flushAreas(account: string, client: NonNullable<ReturnType<typeof
const tally = await getStore().area<{ sent?: number; conflicts?: number | unknown[] } | null>(account, call).catch(() => null);
if (!tally) continue;
const conflicts = Array.isArray(tally.conflicts) ? tally.conflicts.length : tally.conflicts ?? 0;
if (conflicts > 0) notify({ kind: 'conflict', count: conflicts });
if (!tally.sent && !conflicts) continue;
const role = (useAuthStore.getState().client as unknown) === client ? 'active' : 'background';
const changed = { [client.getAccountId()]: Object.fromEntries(types.map((type) => [type, 'flushed'])) };
@@ -632,13 +750,12 @@ export async function flushAll(): Promise<void> {
export async function enableOffline(account: string): Promise<void> {
if (!offlineAllowed(account)) return;
const enabled = useOfflineStore.getState().enabled;
// Before the list: an account on the list without a choice is one from before the local store.
writeChoice(account, { on: true });
if (!enabled.includes(account)) writeEnabled([...enabled, account]);
// Ask the browser not to clear it when space runs low (it may say no).
try {
void navigator.storage?.persist?.().catch(() => false);
} catch {
/* no Storage API */
}
// Ask the browser not to clear it when space runs low. It may say no, which
// the settings page shows, with a way to ask again.
void requestPersistence();
await getStore().ensureOpen(account).catch(() => {});
await tellServiceWorker(true);
scheduleSync(account, 0);
@@ -648,10 +765,94 @@ export async function enableOffline(account: string): Promise<void> {
export async function disableOffline(account: string): Promise<void> {
confirmLoss(account);
clearHandoffs();
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
await wipeAccount(account);
disabling.add(account);
try {
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
await wipeAccount(account);
// Remembered where the app would turn it on by itself.
if (offlineDefaultOn()) writeChoice(account, { on: false });
} finally {
disabling.delete(account);
}
}
/** On their way off: the desktop default doesn't turn them back on meanwhile. */
const disabling = new Set<string>();
/**
* Removes everything kept for an account, unsent messages included (the user
* confirmed), and its key, and starts again from the server if it stays on.
*/
export async function clearAccount(account: string): Promise<void> {
confirmLoss(account);
const timer = syncTimers.get(account);
if (timer) clearTimeout(timer);
syncTimers.delete(account);
await nameLost(account, false);
named.delete(account);
try {
await getStore().remove(account);
} catch {
// No store: nothing to remove. The key is gone in any case.
}
writeHealth(account, { opened: false, pending: 0, evicted: undefined, lastSync: undefined });
useOfflineStore.setState((s) => {
const { [account]: _usage, ...usage } = s.usage;
const { [account]: _full, ...storageFull } = s.storageFull;
const { [account]: _progress, ...progress } = s.progress;
const { [account]: _synced, ...lastSyncedAt } = s.lastSyncedAt;
return { usage, storageFull, progress, lastSyncedAt };
});
await refreshQueueState();
if (useOfflineStore.getState().enabled.includes(account)) {
await getStore().ensureOpen(account).catch(() => {});
scheduleSync(account, 0);
}
}
/** The user changed what an account keeps. Written first, so a reload holds to it. */
export function chooseScope(account: string, patch: UserChoice): void {
writeChoice(account, patch);
pushScope(account);
}
/** The store's scope follows the page's answer. Nothing happens in the store if it's the same as before. */
function pushScope(account: string): void {
if (!useOfflineStore.getState().enabled.includes(account) || !scopeDecided(account) || !offlineActive(account)) return;
const scope: StoreScope = scopeForAccount(account).scope;
void getStore().setScope(account, scope).then(() => refreshUsage()).catch(() => {});
}
/** The notice about the desktop default was shown on this device. Not account data, so it outlives a sign-out. */
const DEFAULT_ON_NOTICE_KEY = 'bulwark-offline-default-notice';
/**
* The desktop app keeps mail for every account without asking, unless the
* organisation forbids it or the user turned it off. Says so once.
*/
function applyDefaultOn(): void {
if (!offlineDefaultOn() || !offlineSupported()) return;
for (const account of signedInAccountIds()) {
if (useOfflineStore.getState().enabled.includes(account) || disabling.has(account) || !scopeDecided(account)) continue;
if (!scopeForAccount(account).enabled) continue;
void enableOffline(account);
try {
if (localStorage.getItem(DEFAULT_ON_NOTICE_KEY)) continue;
localStorage.setItem(DEFAULT_ON_NOTICE_KEY, '1');
} catch {
continue;
}
notify({ kind: 'default-on' });
}
}
/** The evicted warning was read. */
export function dismissEvicted(account: string): void {
writeHealth(account, { evicted: undefined });
}
export { readPersistence, requestPersistence, safariMayDelete };
// ---- Unsent mail a wipe deletes ----
// The user agreed to lose unsent mail (the sign-out question, switching
@@ -706,6 +907,7 @@ export async function wipeAccount(account: string): Promise<void> {
if (useOfflineStore.getState().enabled.includes(account)) {
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
}
forgetAccountState(account);
// Nobody keeps mail here any more: everything goes (and isn't opened
// again, which would create it anew).
if (useOfflineStore.getState().enabled.length === 0) {
@@ -726,6 +928,7 @@ export async function wipeEverything(): Promise<void> {
for (const timer of syncTimers.values()) clearTimeout(timer);
syncTimers.clear();
writeEnabled([]);
forgetAllAccountState();
getStore().stop();
await purgeStoreFiles();
try {
@@ -738,7 +941,13 @@ export async function wipeEverything(): Promise<void> {
clearHandoffs();
confirmed.clear();
named.clear();
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
}
/** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */
function scopeDecided(account: string): boolean {
const { loaded, source } = usePolicyStore.getState();
return loaded && (source === 'server' || source === 'cache') && policyDecided(account);
}
/** Whether the policy of an account is known well enough to decide on it. */
@@ -761,8 +970,12 @@ export function enforcePolicy(): void {
clearHandoffs();
void wipeAccount(account);
notify({ kind: 'forbidden' });
continue;
}
// A lowered cap prunes what's kept, and a raised one starts fetching.
pushScope(account);
}
applyDefaultOn();
}
// ---- Started without the server ----
@@ -852,6 +1065,8 @@ export function startOfflineMode(): () => void {
// there): stop here too, and don't write anything for that account. One
// switched on there has to be opened here if this tab owns the store.
const onStorage = (event: StorageEvent) => {
if (event.key === OFFLINE_CHOICES_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() });
if (event.key === OFFLINE_HEALTH_KEY || event.key === null) useOfflineStore.setState({ health: readStoredHealth() });
if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return;
const next = readEnabledFrom(event.key === null ? null : event.newValue);
const before = useOfflineStore.getState().enabled;
@@ -885,6 +1100,7 @@ export function startOfflineMode(): () => void {
// Session, then a sync, for every account signed in with offline mode on.
const syncSignedIn = () => {
applyDefaultOn();
for (const account of useOfflineStore.getState().enabled) {
const client = onlineClientFor(account);
if (!client || !offlineActive(account)) continue;
@@ -906,7 +1122,16 @@ export function startOfflineMode(): () => void {
clearInterval(reconnect);
});
if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true);
if (useOfflineStore.getState().enabled.length > 0) {
void tellServiceWorker(true);
void readPersistence().then((state) => {
if (state === 'refused' && isStandaloneDisplayMode()) void requestPersistence();
});
}
// An installed app is usually told yes where the tab was told no.
const onInstalled = () => { if (useOfflineStore.getState().enabled.length > 0) void requestPersistence(); };
window.addEventListener('appinstalled', onInstalled);
cleanups.push(() => window.removeEventListener('appinstalled', onInstalled));
void (async () => {
await moveLegacyOutbox();
await refreshQueueState();
@@ -934,7 +1159,10 @@ onFullSignOut(() => {
getStore().stop();
confirmed.clear();
named.clear();
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false });
useOfflineStore.setState({
enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {}, choices: {}, health: {},
storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false,
});
});
// Every sign-out the user didn't agree to lose mail at (the session limits,
+2 -1
View File
@@ -39,11 +39,12 @@ interface StoredQueue {
}
/** Everything waiting for one account (or every account with it on), oldest first. */
export async function readQueue(account?: string): Promise<{ patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
export async function readQueue(account?: string): Promise<{ patches: number; patchesBy: Record<string, number>; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
const accounts = account ? [account] : useOfflineStore.getState().enabled;
const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') })));
return {
patches: read.reduce((sum, { queue }) => sum + queue.patches, 0),
patchesBy: Object.fromEntries(read.map(({ id, queue }) => [id, queue.patches])),
outbox: read.flatMap(({ id, queue }) => queue.entries.map((entry) => ({ ...entry, account: id }))),
conflicts: read.flatMap(({ id, queue }) => queue.conflicts.map((conflict) => ({
id: conflict.id,
+125
View File
@@ -0,0 +1,125 @@
/**
* What an account keeps on this device. Three sources, in this order: the
* organisation's policy, the user's choice, the platform's default. The policy
* only ever lowers: nothing below it can raise a cap or turn back on what it
* forbids, and that includes the desktop app's default of keeping mail.
*/
import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area';
import type { DevicePolicy } from '@/lib/device-policy';
import { useOfflineStore, type UserChoice } from './state';
const MB = 1024 * 1024;
/** The windows a user picks from. Null keeps everything. */
export const WINDOW_CHOICES: readonly (number | null)[] = [30, 90, 365, null];
/** The largest attachment a user may keep, in MB. */
export const ATTACHMENT_CAP_CHOICES: readonly number[] = [5, 25, 100, 500];
/** What the old offline mode kept, for those who had it on before the local store. */
export const LEGACY_CHOICE: UserChoice = { on: true, days: 30, attachments: true, maxAttachmentMb: 5 };
export interface PlatformDefault {
on: boolean;
scope: StoreScope;
}
export interface EffectiveScope {
account: string;
/** Whether the account keeps mail here at all. */
enabled: boolean;
scope: StoreScope;
/** What the policy decides, for the settings page: these show "Set by your organisation". */
locked: {
offline: boolean;
days: boolean;
attachments: boolean;
maxAttachment: boolean;
maxTotal: boolean;
};
}
/** The desktop app keeps mail without asking. Browsers stay opt-in. Feature-detected: the bridge is only there in the app. */
export function offlineDefaultOn(): boolean {
return typeof window !== 'undefined' && Boolean((window as { bulwarkDesktop?: unknown }).bulwarkDesktop);
}
export function platformDefault(): PlatformDefault {
return { on: offlineDefaultOn(), scope: DEFAULT_SCOPE };
}
/** Null means everything, so any cap is lower. */
function cappedDays(days: number | null, cap: number): number | null {
if (!(cap > 0)) return days;
return days === null ? cap : Math.min(days, cap);
}
function cappedBytes(bytes: number, capMb: number): number {
return capMb > 0 ? Math.min(bytes, capMb * MB) : bytes;
}
export function effectiveScope(
account: string,
userChoice: UserChoice | null | undefined,
policy: Pick<DevicePolicy, 'noOffline' | 'offlineMaxDays' | 'noOfflineAttachments' | 'offlineMaxAttachmentMb' | 'offlineMaxTotalMb'>,
defaults: PlatformDefault,
): EffectiveScope {
const choice = userChoice ?? {};
const days = choice.days !== undefined ? choice.days : defaults.scope.days;
const maxAttachment = choice.maxAttachmentMb !== undefined ? choice.maxAttachmentMb * MB : defaults.scope.maxAttachmentBytes;
return {
account,
enabled: !policy.noOffline && (choice.on ?? defaults.on),
scope: {
days: cappedDays(days, policy.offlineMaxDays),
attachments: !policy.noOfflineAttachments && (choice.attachments ?? defaults.scope.attachments),
maxAttachmentBytes: cappedBytes(maxAttachment, policy.offlineMaxAttachmentMb),
maxTotalBytes: cappedBytes(defaults.scope.maxTotalBytes, policy.offlineMaxTotalMb),
},
locked: {
offline: policy.noOffline,
days: policy.offlineMaxDays > 0,
attachments: policy.noOfflineAttachments,
maxAttachment: policy.offlineMaxAttachmentMb > 0,
maxTotal: policy.offlineMaxTotalMb > 0,
},
};
}
/**
* What the user chose for an account. The switch used to be the only thing
* stored, so an account that has it on and no choice yet was turned on under
* the old offline mode, and keeps the window it had.
*/
export function userChoiceFor(account: string): UserChoice | null {
const { choices, enabled } = useOfflineStore.getState();
const stored = choices[account];
const on = enabled.includes(account);
if (!stored) return on ? LEGACY_CHOICE : null;
return on && stored.on === undefined ? { ...stored, on } : stored;
}
/** The windows on offer under a cap: the ones that fit, and the cap itself when it falls between them. */
export function windowOptions(capDays: number): (number | null)[] {
if (!(capDays > 0)) return [...WINDOW_CHOICES];
const fit = WINDOW_CHOICES.filter((days): days is number => days !== null && days < capDays);
return [...fit, capDays];
}
/** The attachment sizes on offer under a cap, in MB. */
export function attachmentCapOptions(capMb: number): number[] {
if (!(capMb > 0)) return [...ATTACHMENT_CAP_CHOICES];
const fit = ATTACHMENT_CAP_CHOICES.filter((mb) => mb < capMb);
return [...fit, capMb];
}
export function maxAttachmentMb(scope: StoreScope): number {
return Math.round(scope.maxAttachmentBytes / MB);
}
/** A message older than the organisation's cap isn't kept, opened or not. */
export function olderThanCap(receivedAt: unknown, capDays: number, now: number): boolean {
if (!(capDays > 0) || typeof receivedAt !== 'string') return false;
const at = Date.parse(receivedAt);
return Number.isFinite(at) && at < now - capDays * 86_400_000;
}
+117
View File
@@ -7,10 +7,50 @@
*/
import { create } from 'zustand';
import type { StoreUsage } from '@/lib/store/area';
import type { ConflictEntry, OutboxEntry, OutboxError } from './queue';
import type { PersistState } from './storage-health';
import type { SyncProgress } from './sync-progress';
/** localStorage: the local account ids that keep mail on this device. */
export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts';
/** localStorage: what the user picked per account. Numbers and switches only. */
export const OFFLINE_CHOICES_KEY = 'bulwark-offline-choices';
/** localStorage: what's known about each account's store with the store gone. Numbers only, never content. */
export const OFFLINE_HEALTH_KEY = 'bulwark-offline-health';
export interface UserChoice {
/** Off here means the user turned it off: the desktop app's default doesn't turn it back on. */
on?: boolean;
/** Days of mail to keep. Null keeps everything. */
days?: number | null;
attachments?: boolean;
maxAttachmentMb?: number;
}
export interface AccountHealth {
/** The store was opened for this account once. */
opened?: boolean;
/** Unsent messages and changes last counted: what an eviction loses. */
pending?: number;
/** When the last sync finished. Survives a reload, unlike the running state. */
lastSync?: number;
/** The browser deleted what was kept. Stays until the user has read it. */
evicted?: { at: number; lost: number };
}
/** A calendar or contact edit that didn't go through. Lives in its own area's store. */
export interface AreaConflict {
area: 'calendar' | 'contacts';
account: string;
id: number;
createdAt: number;
title: string;
reason: 'refused' | 'changed' | 'gone';
/** A delete can't be taken back, so it reads differently. */
destroy: boolean;
detail: string | null;
}
export interface OutboxRow {
id: number;
@@ -35,7 +75,18 @@ interface OfflineState {
pendingChanges: number;
/** Changes the server refused, or that met a message changed on another device. */
conflicts: ConflictEntry[];
/** The same for calendar events and contacts. */
areaConflicts: AreaConflict[];
usageBytes: number;
usage: Record<string, StoreUsage>;
choices: Record<string, UserChoice>;
health: Record<string, AccountHealth>;
/** What the browser said about keeping this site's data. */
persist: PersistState;
/** Accounts that couldn't write, and since when. Gone after the next good sync. */
storageFull: Record<string, number>;
/** How far the sync running for an account got. */
progress: Record<string, SyncProgress>;
/** Accounts whose mail is being brought onto the device right now. */
syncing: string[];
lastSyncedAt: Record<string, number>;
@@ -51,6 +102,25 @@ function readEnabled(): string[] {
}
}
function readRecord<T>(key: string): Record<string, T> {
try {
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, T> : {};
} catch {
return {};
}
}
function writeRecord(key: string, value: Record<string, unknown>): void {
try {
if (Object.keys(value).length > 0) localStorage.setItem(key, JSON.stringify(value));
else localStorage.removeItem(key);
} catch {
/* storage unavailable */
}
}
export const useOfflineStore = create<OfflineState>()(() => ({
enabled: readEnabled(),
browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false,
@@ -59,11 +129,58 @@ export const useOfflineStore = create<OfflineState>()(() => ({
outbox: [],
pendingChanges: 0,
conflicts: [],
areaConflicts: [],
usageBytes: 0,
usage: {},
choices: readRecord<UserChoice>(OFFLINE_CHOICES_KEY),
health: readRecord<AccountHealth>(OFFLINE_HEALTH_KEY),
persist: 'unknown',
storageFull: {},
progress: {},
syncing: [],
lastSyncedAt: {},
}));
export function writeChoice(account: string, patch: UserChoice): void {
const choices = { ...useOfflineStore.getState().choices, [account]: { ...useOfflineStore.getState().choices[account], ...patch } };
useOfflineStore.setState({ choices });
writeRecord(OFFLINE_CHOICES_KEY, choices);
}
export function forgetAccountState(account: string): void {
const { choices, health, usage, storageFull, progress } = useOfflineStore.getState();
const without = <T,>(record: Record<string, T>) => Object.fromEntries(Object.entries(record).filter(([id]) => id !== account));
const next = { choices: without(choices), health: without(health), usage: without(usage), storageFull: without(storageFull), progress: without(progress) };
useOfflineStore.setState(next);
writeRecord(OFFLINE_CHOICES_KEY, next.choices);
writeRecord(OFFLINE_HEALTH_KEY, next.health);
}
export function writeHealth(account: string, patch: AccountHealth): void {
const { health } = useOfflineStore.getState();
const current = health[account];
if (current && Object.entries(patch).every(([key, value]) => current[key as keyof AccountHealth] === value)) return;
const next = { ...health, [account]: { ...current, ...patch } };
useOfflineStore.setState({ health: next });
writeRecord(OFFLINE_HEALTH_KEY, next);
}
/** The state of other tabs, after a storage event. */
export function readStoredChoices(): Record<string, UserChoice> {
return readRecord<UserChoice>(OFFLINE_CHOICES_KEY);
}
export function readStoredHealth(): Record<string, AccountHealth> {
return readRecord<AccountHealth>(OFFLINE_HEALTH_KEY);
}
/** Nothing is kept for anyone any more. */
export function forgetAllAccountState(): void {
useOfflineStore.setState({ choices: {}, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [] });
writeRecord(OFFLINE_CHOICES_KEY, {});
writeRecord(OFFLINE_HEALTH_KEY, {});
}
export function writeEnabled(enabled: string[]): void {
useOfflineStore.setState({ enabled });
try {
+60
View File
@@ -0,0 +1,60 @@
/**
* The browser's side of keeping mail: whether it promised not to clear it, and
* whether it did anyway. Nothing here holds account content.
*/
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
import { useOfflineStore } from './state';
export type PersistState = 'unknown' | 'granted' | 'refused' | 'unsupported';
/**
* Asks the browser not to clear this site's data when space runs low, and
* remembers the answer for the settings page. Chromium decides by itself and
* may say no until the app is installed; Firefox asks the user.
*/
export async function requestPersistence(): Promise<PersistState> {
let state: PersistState = 'unsupported';
try {
if (typeof navigator.storage?.persist === 'function') state = (await navigator.storage.persist()) ? 'granted' : 'refused';
} catch {
state = 'refused';
}
useOfflineStore.setState({ persist: state });
return state;
}
/** What the browser last said, without asking. */
export async function readPersistence(): Promise<PersistState> {
let state: PersistState = 'unsupported';
try {
if (typeof navigator.storage?.persisted === 'function') state = (await navigator.storage.persisted()) ? 'granted' : 'refused';
} catch {
state = 'unknown';
}
useOfflineStore.setState({ persist: state });
return state;
}
/**
* Safari deletes a site's data after 7 days without a visit, unless the site is
* on the home screen or in the Dock. On iPhone and iPad every browser runs on
* the same engine, so Chrome and Firefox there count too. Chromium on a Mac or
* Windows, and the desktop app, say "Chrome" in the user agent.
*/
export function safariMayDelete(userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent, installed: boolean = isStandaloneDisplayMode()): boolean {
if (installed) return false;
if (!/AppleWebKit/.test(userAgent) || !/Version\/|CriOS|FxiOS|EdgiOS|OPiOS/.test(userAgent)) return false;
if (/iPhone|iPad|iPod/.test(userAgent)) return true;
return /Macintosh/.test(userAgent) && /Safari\//.test(userAgent) && !/Chrome|Chromium|Edg\/|OPR\//.test(userAgent);
}
/**
* Whether the browser took away what was kept for an account: its key record
* with no file, or no key where one was made. A file with no key can't be read
* by anyone and is swept without a word; if the account had been opened, that
* is the second case. On a first open neither applies.
*/
export function wasEvicted(state: { opened: boolean; hadKey: boolean; fileExisted: boolean }): boolean {
return state.hadKey ? !state.fileExisted : state.opened;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* How far a first sync got, and how long it may still take. The store reports
* batches of a phase: messages listed, then threads, then bodies, then files.
*/
export interface SyncProgress {
area: string;
phase: string;
done: number;
total: number | null;
/** When this phase started here, and how far it was then. */
startedAt: number;
startedDone: number;
at: number;
}
/**
* Listing a message is about ten times as fast as fetching its body and
* indexing it (bench rig, 50,000 messages: 35 s to list, 400 s in all). So
* while listing, the time left includes the bodies that come after it.
*/
const BODY_COST_PER_LIST_ITEM = 10;
/** An estimate from less than this jumps about too much to show. */
const MIN_SAMPLE_MS = 5_000;
/** Too small to be worth a progress line: new mail arriving in a normal pass. */
const MIN_ITEMS_SHOWN = 50;
export function nextProgress(
previous: SyncProgress | undefined,
event: { area: string; phase: string; done: number; total: number | null },
now: number,
): SyncProgress {
const samePhase = previous?.area === event.area && previous.phase === event.phase && event.done >= previous.done;
return {
area: event.area,
phase: event.phase,
done: event.done,
total: event.total,
startedAt: samePhase ? previous.startedAt : now,
startedDone: samePhase ? previous.startedDone : event.done,
at: now,
};
}
export function progressWorthShowing(progress: SyncProgress | undefined): progress is SyncProgress {
return !!progress && (progress.total === null || progress.total >= MIN_ITEMS_SHOWN) && progress.done >= 0;
}
/** Minutes left, rounded up; null while there's nothing sensible to say. */
export function minutesLeft(progress: SyncProgress): number | null {
const { total, done, startedAt, startedDone, at } = progress;
if (total === null || total <= done) return null;
const elapsed = at - startedAt;
const moved = done - startedDone;
if (elapsed < MIN_SAMPLE_MS || moved <= 0) return null;
const perItem = elapsed / moved;
let left = (total - done) * perItem;
if (progress.phase === 'list') left += total * perItem * BODY_COST_PER_LIST_ITEM;
return Math.max(1, Math.ceil(left / 60_000));
}
+3
View File
@@ -36,6 +36,9 @@ const SIGNED_OUT_STORAGE_KEYS = [
// Offline mode's "Keep mail on this device" (lib/offline/state.ts): the
// next person signing in here starts with it off.
'bulwark-offline-accounts',
// What each account chose to keep, and what's known about its store.
'bulwark-offline-choices',
'bulwark-offline-health',
];
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها.",
"storage_failed": "لم تعد هناك مساحة على هذا الجهاز للبريد دون اتصال. أفرغ مساحة أو احتفظ بأقل من الإعدادات."
},
"offlineStorage": {
"keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة في هذا المتصفح. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.",
"window_label": "إلى أي مدة سابقة يُحتفظ بالبريد",
"window_description": "يُزال البريد وأحداث التقويم الأقدم من هذا الجهاز. ما زال بإمكانك فتحها عندما يكون لديك اتصال.",
"window_days": "{days, plural, zero {# يوم} one {يوم واحد} two {يومان} few {# أيام} many {# يومًا} other {# يوم}}",
"window_year": "سنة واحدة",
"window_everything": "الكل",
"attachments_label": "الاحتفاظ بالمرفقات",
"attachments_description": "مرفقات الرسائل المحفوظة والملفات التي تحتفظ بها دون اتصال، حتى {total} في المجموع. تُزال الأقدم أولًا.",
"total_locked": "تسمح مؤسستك بحد أقصى {size} من المرفقات والملفات.",
"attachment_cap_label": "أكبر مرفق يُحتفظ به",
"usage_mail": "الرسائل وفهرس البحث",
"usage_attachments": "المرفقات",
"usage_calendar": "التقويم",
"usage_contacts": "جهات الاتصال",
"usage_files": "الملفات المحفوظة دون اتصال",
"usage_row": "{size} ({count, plural, zero {# عنصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}})",
"progress_list": "جارٍ سرد الرسائل",
"progress_bodies": "جارٍ تنزيل الرسائل",
"progress_threads": "جارٍ ترتيب المحادثات",
"progress_files": "جارٍ تنزيل الملفات",
"progress_other": "جارٍ جلب البيانات إلى هذا الجهاز",
"progress_count": "{done} من {total}",
"progress_count_open": "{done} حتى الآن",
"progress_left": "متبقٍّ نحو {minutes, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتين} few {# دقائق} many {# دقيقة} other {# دقيقة}}",
"clear_label": "مسح هذا الحساب",
"clear_description": "يحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب، ثم ينزّله من جديد.",
"clear_button": "مسح…",
"clear_confirm_title": "هل تريد مسح هذا الحساب على هذا الجهاز؟",
"clear_confirm_message": "سيُحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب ثم يُنزَّل من جديد. يبقى بريدك على الخادم.",
"clear_confirm_unsent": "{count, plural, zero {لن تُفقد أي رسالة لم تُرسل.} one {ستُفقد رسالة واحدة لم تُرسل.} two {ستُفقد رسالتان لم تُرسلا.} few {ستُفقد # رسائل لم تُرسل.} many {ستُفقد # رسالة لم تُرسل.} other {ستُفقد # رسالة لم تُرسل.}}",
"clear_confirm": "مسح",
"persist_refused": "قد يحذف هذا المتصفح البريد المحفوظ عندما تقل المساحة. تثبيت التطبيق يمنع ذلك عادةً.",
"persist_retry": "اسأل مرة أخرى",
"safari_hint": "يحذف Safari بيانات الموقع بعد 7 أيام دون زيارة. أضف Bulwark إلى الشاشة الرئيسية أو إلى Dock لتبقى رسائلك على هذا الجهاز.",
"evicted": "حذف المتصفح البريد المحفوظ على هذا الجهاز. يجري تنزيله من جديد.",
"evicted_lost": "{count, plural, zero {لم تُفقد أي رسالة أو تغيير لم يُرسل.} one {فُقدت رسالة أو تغيير واحد لم يُرسل.} two {فُقدت رسالتان أو تغييران لم يُرسلا.} few {فُقدت # رسائل أو تغييرات لم تُرسل.} many {فُقدت # رسالة أو تغييرًا لم يُرسل.} other {فُقدت # رسالة أو تغيير لم يُرسل.}}",
"evicted_dismiss": "حسنًا",
"storage_full_title": "مساحة تخزين الجهاز ممتلئة.",
"storage_full_text": "توقف Bulwark عن حفظ البريد هنا. احتفظ ببريد أقل أو امسح هذا الحساب لتوفير مساحة.",
"open_settings": "فتح الإعدادات",
"default_on": "يحتفظ Bulwark ببريدك على هذا الجهاز ليعمل دون اتصال. يمكنك تغيير ذلك من الإعدادات.",
"conflict_calendar": "حدث في التقويم",
"conflict_contacts": "جهة اتصال",
"kept_files_title": "الملفات المحفوظة دون اتصال",
"kept_state_kept": "على هذا الجهاز",
"kept_state_waiting": "بانتظار التنزيل",
"kept_error_tooLarge": "كبير جدًا للاحتفاظ به",
"kept_error_overLimit": "يتجاوز حد هذا الجهاز",
"kept_error_notAllowed": "لا تسمح مؤسستك بالاحتفاظ بالملفات",
"kept_error_download": "تعذّر التنزيل",
"keep_offline": "الاحتفاظ دون اتصال",
"unpin": "إيقاف الاحتفاظ دون اتصال",
"unpin_label": "إيقاف الاحتفاظ بـ {name} دون اتصال"
},
"deep_link": {
"message_not_found": "لم تعد هذه الرسالة متاحة.",
"thread_not_found": "لم تعد هذه المحادثة متاحة.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Canvis fets sense connexió que no s'han aplicat: {count}. La safata de sortida indica quins.",
"storage_failed": "Aquest dispositiu no té més espai per al correu sense connexió. Allibereu espai o conserveu-ne menys a la configuració."
},
"offlineStorage": {
"keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest navegador. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.",
"window_label": "Fins a quan enrere desar",
"window_description": "El correu i els esdeveniments més antics surten d'aquest dispositiu. Encara els podeu obrir quan tingueu connexió.",
"window_days": "{days, plural, one {# dia} other {# dies}}",
"window_year": "1 any",
"window_everything": "Tot",
"attachments_label": "Desa els fitxers adjunts",
"attachments_description": "Fitxers adjunts dels missatges desats i fitxers que deseu sense connexió, fins a {total} en total. Els més antics se suprimeixen primer.",
"total_locked": "La vostra organització permet com a màxim {size} d'adjunts i fitxers.",
"attachment_cap_label": "Adjunt més gran que es desa",
"usage_mail": "Missatges i índex de cerca",
"usage_attachments": "Fitxers adjunts",
"usage_calendar": "Calendari",
"usage_contacts": "Contactes",
"usage_files": "Fitxers desats sense connexió",
"usage_row": "{size} ({count, plural, one {# element} other {# elements}})",
"progress_list": "Llistant els missatges",
"progress_bodies": "Baixant els missatges",
"progress_threads": "Ordenant les converses",
"progress_files": "Baixant els fitxers",
"progress_other": "Portant dades a aquest dispositiu",
"progress_count": "{done} de {total}",
"progress_count_open": "{done} fins ara",
"progress_left": "queden uns {minutes, plural, one {# minut} other {# minuts}}",
"clear_label": "Buida aquest compte",
"clear_description": "Suprimeix tot el que aquest dispositiu desa d'aquest compte i ho torna a baixar.",
"clear_button": "Buida…",
"clear_confirm_title": "Voleu buidar aquest compte en aquest dispositiu?",
"clear_confirm_message": "Tot el que aquest dispositiu desa d'aquest compte se suprimeix i es torna a baixar. El vostre correu al servidor no es toca.",
"clear_confirm_unsent": "{count, plural, one {Es perdrà # missatge sense enviar.} other {Es perdran # missatges sense enviar.}}",
"clear_confirm": "Buida",
"persist_refused": "Aquest navegador pot esborrar el correu desat quan falti espai. Instal·lar l'aplicació sol evitar-ho.",
"persist_retry": "Torna-ho a demanar",
"safari_hint": "El Safari esborra les dades d'un lloc després de 7 dies sense visitar-lo. Afegiu Bulwark a la pantalla d'inici o al Dock per mantenir el vostre correu en aquest dispositiu.",
"evicted": "El navegador ha esborrat el correu desat en aquest dispositiu. Es torna a baixar.",
"evicted_lost": "{count, plural, one {S'ha perdut # missatge o canvi sense enviar.} other {S'han perdut # missatges o canvis sense enviar.}}",
"evicted_dismiss": "D'acord",
"storage_full_title": "L'emmagatzematge del dispositiu és ple.",
"storage_full_text": "Bulwark ha deixat de desar correu aquí. Deseu menys correu o buideu aquest compte per fer espai.",
"open_settings": "Obre la configuració",
"default_on": "Bulwark desa el vostre correu en aquest dispositiu perquè funcioni sense connexió. Podeu canviar-ho a la configuració.",
"conflict_calendar": "Esdeveniment del calendari",
"conflict_contacts": "Contacte",
"kept_files_title": "Fitxers desats sense connexió",
"kept_state_kept": "En aquest dispositiu",
"kept_state_waiting": "Esperant la baixada",
"kept_error_tooLarge": "Massa gran per desar-lo",
"kept_error_overLimit": "Supera el límit d'aquest dispositiu",
"kept_error_notAllowed": "La vostra organització no permet desar fitxers",
"kept_error_download": "No s'ha pogut baixar",
"keep_offline": "Desa sense connexió",
"unpin": "Deixa de desar sense connexió",
"unpin_label": "Deixa de desar {name} sense connexió"
},
"deep_link": {
"message_not_found": "Aquest missatge ja no està disponible.",
"thread_not_found": "Aquesta conversa ja no està disponible.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Nepoužité změny provedené offline: {count}. Které to jsou, ukazuje pošta k odeslání.",
"storage_failed": "Na tomto zařízení už není místo pro poštu offline. Uvolněte místo nebo v nastavení ponechte méně."
},
"offlineStorage": {
"keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto prohlížeči. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.",
"window_label": "Jak dlouho do minulosti uchovávat",
"window_description": "Starší pošta a události z kalendáře toto zařízení opustí. Když budete online, můžete je stále otevřít.",
"window_days": "{days, plural, one {# den} few {# dny} many {# dne} other {# dní}}",
"window_year": "1 rok",
"window_everything": "Vše",
"attachments_label": "Uchovávat přílohy",
"attachments_description": "Přílohy uchovaných zpráv a soubory, které uchováváte offline, celkem až {total}. Nejstarší se odstraní jako první.",
"total_locked": "Vaše organizace povoluje nejvýše {size} příloh a souborů.",
"attachment_cap_label": "Největší uchovávaná příloha",
"usage_mail": "Zprávy a index vyhledávání",
"usage_attachments": "Přílohy",
"usage_calendar": "Kalendář",
"usage_contacts": "Kontakty",
"usage_files": "Soubory uchované offline",
"usage_row": "{size} ({count, plural, one {# položka} few {# položky} many {# položky} other {# položek}})",
"progress_list": "Vypisují se zprávy",
"progress_bodies": "Stahují se zprávy",
"progress_threads": "Řadí se konverzace",
"progress_files": "Stahují se soubory",
"progress_other": "Přenášejí se data do tohoto zařízení",
"progress_count": "{done} z {total}",
"progress_count_open": "zatím {done}",
"progress_left": "zbývá asi {minutes, plural, one {# minuta} few {# minuty} many {# minuty} other {# minut}}",
"clear_label": "Vymazat tento účet",
"clear_description": "Smaže vše, co toto zařízení pro tento účet uchovává, a stáhne to znovu.",
"clear_button": "Vymazat…",
"clear_confirm_title": "Vymazat tento účet na tomto zařízení?",
"clear_confirm_message": "Vše, co toto zařízení pro tento účet uchovává, se smaže a stáhne znovu. Vaše pošta na serveru zůstane.",
"clear_confirm_unsent": "{count, plural, one {# neodeslaná zpráva se ztratí.} few {# neodeslané zprávy se ztratí.} many {# neodeslané zprávy se ztratí.} other {# neodeslaných zpráv se ztratí.}}",
"clear_confirm": "Vymazat",
"persist_refused": "Tento prohlížeč může uchovanou poštu smazat, když dojde místo. Instalace aplikace tomu obvykle zabrání.",
"persist_retry": "Zeptat se znovu",
"safari_hint": "Safari smaže data webu po 7 dnech bez návštěvy. Přidejte Bulwark na plochu nebo do Docku, aby vaše pošta na tomto zařízení zůstala.",
"evicted": "Prohlížeč smazal poštu uchovanou na tomto zařízení. Stahuje se znovu.",
"evicted_lost": "{count, plural, one {# neodeslaná zpráva nebo změna se ztratila.} few {# neodeslané zprávy nebo změny se ztratily.} many {# neodeslané zprávy nebo změny se ztratily.} other {# neodeslaných zpráv nebo změn se ztratilo.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Úložiště zařízení je plné.",
"storage_full_text": "Bulwark sem přestal ukládat poštu. Uchovávejte méně pošty nebo vymažte tento účet, aby se uvolnilo místo.",
"open_settings": "Otevřít nastavení",
"default_on": "Bulwark uchovává vaši poštu na tomto zařízení, aby fungoval i bez připojení. Můžete to změnit v Nastavení.",
"conflict_calendar": "Událost v kalendáři",
"conflict_contacts": "Kontakt",
"kept_files_title": "Soubory uchované offline",
"kept_state_kept": "Na tomto zařízení",
"kept_state_waiting": "Čeká na stažení",
"kept_error_tooLarge": "Příliš velký k uchování",
"kept_error_overLimit": "Přes limit tohoto zařízení",
"kept_error_notAllowed": "Vaše organizace uchovávání souborů nepovoluje",
"kept_error_download": "Nepodařilo se stáhnout",
"keep_offline": "Uchovat offline",
"unpin": "Přestat uchovávat offline",
"unpin_label": "Přestat uchovávat {name} offline"
},
"deep_link": {
"message_not_found": "Tato zpráva už není dostupná.",
"thread_not_found": "Tato konverzace už není dostupná.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Ændringer lavet offline, der ikke blev gennemført: {count}. Udbakken viser hvilke.",
"storage_failed": "Denne enhed har ikke mere plads til offlinemail. Frigør plads, eller behold mindre i indstillingerne."
},
"offlineStorage": {
"keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret i denne browser. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.",
"window_label": "Hvor langt tilbage der beholdes",
"window_description": "Ældre mail og kalenderbegivenheder forlader denne enhed. Du kan stadig åbne dem, når du har forbindelse.",
"window_days": "{days, plural, one {# dag} other {# dage}}",
"window_year": "1 år",
"window_everything": "Alt",
"attachments_label": "Behold vedhæftede filer",
"attachments_description": "Vedhæftede filer fra beholdte beskeder og de filer, du beholder offline, op til {total} i alt. De ældste fjernes først.",
"total_locked": "Din organisation tillader højst {size} vedhæftede filer og filer.",
"attachment_cap_label": "Største vedhæftede fil, der beholdes",
"usage_mail": "Beskeder og søgeindeks",
"usage_attachments": "Vedhæftede filer",
"usage_calendar": "Kalender",
"usage_contacts": "Kontakter",
"usage_files": "Filer beholdt offline",
"usage_row": "{size} ({count, plural, one {# element} other {# elementer}})",
"progress_list": "Lister beskeder",
"progress_bodies": "Henter beskeder",
"progress_threads": "Sorterer samtaler",
"progress_files": "Henter filer",
"progress_other": "Henter data til denne enhed",
"progress_count": "{done} af {total}",
"progress_count_open": "{done} indtil nu",
"progress_left": "cirka {minutes, plural, one {# minut} other {# minutter}} tilbage",
"clear_label": "Ryd denne konto",
"clear_description": "Sletter alt, denne enhed beholder for denne konto, og henter det derefter igen.",
"clear_button": "Ryd…",
"clear_confirm_title": "Ryd denne konto på denne enhed?",
"clear_confirm_message": "Alt, denne enhed beholder for denne konto, slettes og hentes igen. Din mail på serveren bliver liggende.",
"clear_confirm_unsent": "{count, plural, one {# usendt besked går tabt.} other {# usendte beskeder går tabt.}}",
"clear_confirm": "Ryd",
"persist_refused": "Denne browser kan slette den beholdte mail, når pladsen bliver knap. Det hjælper normalt at installere appen.",
"persist_retry": "Spørg igen",
"safari_hint": "Safari sletter et websteds data efter 7 dage uden besøg. Føj Bulwark til din hjemmeskærm eller til Dock for at beholde din mail på denne enhed.",
"evicted": "Browseren slettede den mail, der var beholdt på denne enhed. Den hentes igen.",
"evicted_lost": "{count, plural, one {# usendt besked eller ændring gik tabt.} other {# usendte beskeder eller ændringer gik tabt.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Enhedens lager er fuldt.",
"storage_full_text": "Bulwark er holdt op med at gemme mail her. Behold mindre mail, eller ryd denne konto for at frigøre plads.",
"open_settings": "Åbn indstillinger",
"default_on": "Bulwark beholder din mail på denne enhed, så den virker uden forbindelse. Du kan ændre det i Indstillinger.",
"conflict_calendar": "Kalenderbegivenhed",
"conflict_contacts": "Kontakt",
"kept_files_title": "Filer beholdt offline",
"kept_state_kept": "På denne enhed",
"kept_state_waiting": "Venter på download",
"kept_error_tooLarge": "For stor til at beholde",
"kept_error_overLimit": "Over grænsen for denne enhed",
"kept_error_notAllowed": "Din organisation tillader ikke, at filer beholdes",
"kept_error_download": "Kunne ikke hentes",
"keep_offline": "Behold offline",
"unpin": "Behold ikke længere offline",
"unpin_label": "Behold ikke {name} offline længere"
},
"deep_link": {
"message_not_found": "Denne besked er ikke længere tilgængelig.",
"thread_not_found": "Denne samtale er ikke længere tilgængelig.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Offline gemachte Änderungen wurden nicht übernommen: {count}. Welche, steht im Postausgang.",
"storage_failed": "Auf diesem Gerät ist kein Platz mehr für Offline-E-Mails. Geben Sie Speicher frei oder behalten Sie in den Einstellungen weniger."
},
"offlineStorage": {
"keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt in diesem Browser. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.",
"window_label": "Wie weit zurück behalten",
"window_description": "Ältere E-Mails und Kalendertermine werden von diesem Gerät entfernt. Sie können sie weiterhin öffnen, solange Sie online sind.",
"window_days": "{days, plural, one {# Tag} other {# Tage}}",
"window_year": "1 Jahr",
"window_everything": "Alles",
"attachments_label": "Anhänge behalten",
"attachments_description": "Anhänge behaltener Nachrichten und die Dateien, die Sie offline behalten, insgesamt bis zu {total}. Die ältesten werden zuerst entfernt.",
"total_locked": "Ihre Organisation erlaubt höchstens {size} für Anhänge und Dateien.",
"attachment_cap_label": "Größter Anhang, der behalten wird",
"usage_mail": "Nachrichten und Suchindex",
"usage_attachments": "Anhänge",
"usage_calendar": "Kalender",
"usage_contacts": "Kontakte",
"usage_files": "Offline behaltene Dateien",
"usage_row": "{size} ({count, plural, one {# Element} other {# Elemente}})",
"progress_list": "Nachrichten werden aufgelistet",
"progress_bodies": "Nachrichten werden geladen",
"progress_threads": "Unterhaltungen werden sortiert",
"progress_files": "Dateien werden geladen",
"progress_other": "Daten werden auf dieses Gerät geholt",
"progress_count": "{done} von {total}",
"progress_count_open": "bisher {done}",
"progress_left": "noch etwa {minutes, plural, one {# Minute} other {# Minuten}}",
"clear_label": "Dieses Konto leeren",
"clear_description": "Löscht alles, was dieses Gerät für dieses Konto behält, und lädt es danach neu.",
"clear_button": "Leeren…",
"clear_confirm_title": "Dieses Konto auf diesem Gerät leeren?",
"clear_confirm_message": "Alles, was dieses Gerät für dieses Konto behält, wird gelöscht und neu geladen. Ihre E-Mails auf dem Server bleiben erhalten.",
"clear_confirm_unsent": "{count, plural, one {# ungesendete Nachricht geht verloren.} other {# ungesendete Nachrichten gehen verloren.}}",
"clear_confirm": "Leeren",
"persist_refused": "Dieser Browser kann die behaltenen E-Mails löschen, wenn der Platz knapp wird. Die App zu installieren verhindert das meistens.",
"persist_retry": "Erneut fragen",
"safari_hint": "Safari löscht die Daten einer Website nach 7 Tagen ohne Besuch. Fügen Sie Bulwark zum Home-Bildschirm oder zum Dock hinzu, damit Ihre E-Mails auf diesem Gerät bleiben.",
"evicted": "Der Browser hat die auf diesem Gerät behaltenen E-Mails gelöscht. Sie werden neu geladen.",
"evicted_lost": "{count, plural, one {# ungesendete Nachricht oder Änderung ging verloren.} other {# ungesendete Nachrichten oder Änderungen gingen verloren.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Der Speicher des Geräts ist voll.",
"storage_full_text": "Bulwark speichert hier keine E-Mails mehr. Behalten Sie weniger E-Mails oder leeren Sie dieses Konto, um Platz zu schaffen.",
"open_settings": "Einstellungen öffnen",
"default_on": "Bulwark behält Ihre E-Mails auf diesem Gerät, damit es auch ohne Verbindung funktioniert. Sie können das in den Einstellungen ändern.",
"conflict_calendar": "Kalendertermin",
"conflict_contacts": "Kontakt",
"kept_files_title": "Offline behaltene Dateien",
"kept_state_kept": "Auf diesem Gerät",
"kept_state_waiting": "Wartet auf den Download",
"kept_error_tooLarge": "Zu groß zum Behalten",
"kept_error_overLimit": "Über dem Limit für dieses Gerät",
"kept_error_notAllowed": "Ihre Organisation erlaubt das Behalten von Dateien nicht",
"kept_error_download": "Konnte nicht geladen werden",
"keep_offline": "Offline behalten",
"unpin": "Nicht mehr offline behalten",
"unpin_label": "{name} nicht mehr offline behalten"
},
"deep_link": {
"message_not_found": "Diese Nachricht ist nicht mehr verfügbar.",
"thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Changes you made offline weren't applied: {count}. The outbox shows which.",
"storage_failed": "This device has no room left for offline mail. Free up space, or keep less in Settings."
},
"offlineStorage": {
"keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted in this browser. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.",
"window_label": "How far back to keep",
"window_description": "Older mail and calendar events leave this device. You can still open them when you have a connection.",
"window_days": "{days, plural, one {# day} other {# days}}",
"window_year": "1 year",
"window_everything": "Everything",
"attachments_label": "Keep attachments",
"attachments_description": "Attachments of kept messages and the files you keep offline, up to {total} in all. The oldest go first.",
"total_locked": "Your organisation allows {size} of attachments and files at most.",
"attachment_cap_label": "Largest attachment to keep",
"usage_mail": "Messages and search index",
"usage_attachments": "Attachments",
"usage_calendar": "Calendar",
"usage_contacts": "Contacts",
"usage_files": "Files kept offline",
"usage_row": "{size} ({count, plural, one {# item} other {# items}})",
"progress_list": "Listing messages",
"progress_bodies": "Downloading messages",
"progress_threads": "Sorting conversations",
"progress_files": "Downloading files",
"progress_other": "Bringing data to this device",
"progress_count": "{done} of {total}",
"progress_count_open": "{done} so far",
"progress_left": "about {minutes, plural, one {# minute} other {# minutes}} left",
"clear_label": "Clear this account",
"clear_description": "Deletes everything this device keeps for this account, then downloads it again.",
"clear_button": "Clear…",
"clear_confirm_title": "Clear this account on this device?",
"clear_confirm_message": "Everything this device keeps for this account is deleted and downloaded again. Your mail on the server stays.",
"clear_confirm_unsent": "{count, plural, one {# unsent message is lost.} other {# unsent messages are lost.}}",
"clear_confirm": "Clear",
"persist_refused": "This browser may delete the kept mail when space runs low. Installing the app usually stops that.",
"persist_retry": "Ask again",
"safari_hint": "Safari deletes a site's data after 7 days without a visit. Add Bulwark to your Home Screen or Dock to keep your mail on this device.",
"evicted": "The browser deleted the mail kept on this device. It's downloading again.",
"evicted_lost": "{count, plural, one {# unsent message or change was lost.} other {# unsent messages or changes were lost.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Device storage is full.",
"storage_full_text": "Bulwark has stopped saving mail here. Keep less mail or clear this account to make room.",
"open_settings": "Open settings",
"default_on": "Bulwark keeps your mail on this device, so it works without a connection. You can change this in Settings.",
"conflict_calendar": "Calendar event",
"conflict_contacts": "Contact",
"kept_files_title": "Files kept offline",
"kept_state_kept": "On this device",
"kept_state_waiting": "Waiting to download",
"kept_error_tooLarge": "Too large to keep",
"kept_error_overLimit": "Over the limit for this device",
"kept_error_notAllowed": "Your organisation doesn't allow keeping files",
"kept_error_download": "Couldn't be downloaded",
"keep_offline": "Keep offline",
"unpin": "Stop keeping offline",
"unpin_label": "Stop keeping {name} offline"
},
"deep_link": {
"message_not_found": "This message is no longer available.",
"thread_not_found": "This conversation is no longer available.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Cambios hechos sin conexión que no se aplicaron: {count}. La bandeja de salida indica cuáles.",
"storage_failed": "Este dispositivo no tiene espacio para el correo sin conexión. Libera espacio o guarda menos en Ajustes."
},
"offlineStorage": {
"keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este navegador. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.",
"window_label": "Cuánto tiempo atrás guardar",
"window_description": "El correo y los eventos más antiguos salen de este dispositivo. Aún puedes abrirlos cuando tengas conexión.",
"window_days": "{days, plural, one {# día} other {# días}}",
"window_year": "1 año",
"window_everything": "Todo",
"attachments_label": "Guardar adjuntos",
"attachments_description": "Adjuntos de los mensajes guardados y archivos que guardas sin conexión, hasta {total} en total. Los más antiguos se eliminan primero.",
"total_locked": "Tu organización permite como máximo {size} de adjuntos y archivos.",
"attachment_cap_label": "Adjunto más grande que se guarda",
"usage_mail": "Mensajes e índice de búsqueda",
"usage_attachments": "Adjuntos",
"usage_calendar": "Calendario",
"usage_contacts": "Contactos",
"usage_files": "Archivos guardados sin conexión",
"usage_row": "{size} ({count, plural, one {# elemento} other {# elementos}})",
"progress_list": "Listando mensajes",
"progress_bodies": "Descargando mensajes",
"progress_threads": "Ordenando conversaciones",
"progress_files": "Descargando archivos",
"progress_other": "Trayendo datos a este dispositivo",
"progress_count": "{done} de {total}",
"progress_count_open": "{done} hasta ahora",
"progress_left": "faltan unos {minutes, plural, one {# minuto} other {# minutos}}",
"clear_label": "Vaciar esta cuenta",
"clear_description": "Elimina todo lo que este dispositivo guarda de esta cuenta y lo vuelve a descargar.",
"clear_button": "Vaciar…",
"clear_confirm_title": "¿Vaciar esta cuenta en este dispositivo?",
"clear_confirm_message": "Todo lo que este dispositivo guarda de esta cuenta se elimina y se vuelve a descargar. Tu correo en el servidor se mantiene.",
"clear_confirm_unsent": "{count, plural, one {Se perderá # mensaje sin enviar.} other {Se perderán # mensajes sin enviar.}}",
"clear_confirm": "Vaciar",
"persist_refused": "Este navegador puede borrar el correo guardado cuando falte espacio. Instalar la aplicación suele evitarlo.",
"persist_retry": "Preguntar de nuevo",
"safari_hint": "Safari borra los datos de un sitio tras 7 días sin visitarlo. Añade Bulwark a tu pantalla de inicio o al Dock para conservar tu correo en este dispositivo.",
"evicted": "El navegador borró el correo guardado en este dispositivo. Se está descargando de nuevo.",
"evicted_lost": "{count, plural, one {Se perdió # mensaje o cambio sin enviar.} other {Se perdieron # mensajes o cambios sin enviar.}}",
"evicted_dismiss": "OK",
"storage_full_title": "El almacenamiento del dispositivo está lleno.",
"storage_full_text": "Bulwark ha dejado de guardar correo aquí. Guarda menos correo o vacía esta cuenta para liberar espacio.",
"open_settings": "Abrir ajustes",
"default_on": "Bulwark guarda tu correo en este dispositivo para que funcione sin conexión. Puedes cambiarlo en Ajustes.",
"conflict_calendar": "Evento del calendario",
"conflict_contacts": "Contacto",
"kept_files_title": "Archivos guardados sin conexión",
"kept_state_kept": "En este dispositivo",
"kept_state_waiting": "Esperando la descarga",
"kept_error_tooLarge": "Demasiado grande para guardarlo",
"kept_error_overLimit": "Supera el límite de este dispositivo",
"kept_error_notAllowed": "Tu organización no permite guardar archivos",
"kept_error_download": "No se pudo descargar",
"keep_offline": "Guardar sin conexión",
"unpin": "Dejar de guardar sin conexión",
"unpin_label": "Dejar de guardar {name} sin conexión"
},
"deep_link": {
"message_not_found": "Este mensaje ya no está disponible.",
"thread_not_found": "Esta conversación ya no está disponible.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Võrguühenduseta tehtud rakendamata muudatused: {count}. Väljundkaust näitab, millised.",
"storage_failed": "Selles seadmes pole enam ruumi võrguühenduseta kirjade jaoks. Vabastage ruumi või hoidke seadetes vähem."
},
"offlineStorage": {
"keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles brauseris. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.",
"window_label": "Kui kaugele minevikku hoida",
"window_description": "Vanemad kirjad ja kalendrisündmused kaovad sellest seadmest. Saad neid endiselt avada, kui oled võrgus.",
"window_days": "{days, plural, one {# päev} other {# päeva}}",
"window_year": "1 aasta",
"window_everything": "Kõik",
"attachments_label": "Hoia manuseid",
"attachments_description": "Hoitud kirjade manused ja failid, mida hoiad võrguta, kokku kuni {total}. Vanimad eemaldatakse esimesena.",
"total_locked": "Sinu organisatsioon lubab manuseid ja faile kokku kuni {size}.",
"attachment_cap_label": "Suurim hoitav manus",
"usage_mail": "Kirjad ja otsinguindeks",
"usage_attachments": "Manused",
"usage_calendar": "Kalender",
"usage_contacts": "Kontaktid",
"usage_files": "Võrguta hoitud failid",
"usage_row": "{size} ({count, plural, one {# kirje} other {# kirjet}})",
"progress_list": "Kirjade loendamine",
"progress_bodies": "Kirjade allalaadimine",
"progress_threads": "Vestluste sortimine",
"progress_files": "Failide allalaadimine",
"progress_other": "Andmete toomine sellesse seadmesse",
"progress_count": "{done} / {total}",
"progress_count_open": "seni {done}",
"progress_left": "jäänud umbes {minutes, plural, one {# minut} other {# minutit}}",
"clear_label": "Tühjenda see konto",
"clear_description": "Kustutab kõik, mida see seade selle konto jaoks hoiab, ja laadib selle uuesti alla.",
"clear_button": "Tühjenda…",
"clear_confirm_title": "Kas tühjendada see konto selles seadmes?",
"clear_confirm_message": "Kõik, mida see seade selle konto jaoks hoiab, kustutatakse ja laaditakse uuesti alla. Sinu kirjad serveris jäävad alles.",
"clear_confirm_unsent": "{count, plural, one {# saatmata kiri läheb kaotsi.} other {# saatmata kirja lähevad kaotsi.}}",
"clear_confirm": "Tühjenda",
"persist_refused": "See brauser võib hoitud kirjad kustutada, kui ruumi napib. Rakenduse paigaldamine hoiab selle tavaliselt ära.",
"persist_retry": "Küsi uuesti",
"safari_hint": "Safari kustutab saidi andmed, kui seda pole 7 päeva külastatud. Lisa Bulwark avaekraanile või Dockile, et su kirjad sellesse seadmesse alles jääksid.",
"evicted": "Brauser kustutas selles seadmes hoitud kirjad. Need laaditakse uuesti alla.",
"evicted_lost": "{count, plural, one {# saatmata kiri või muudatus läks kaotsi.} other {# saatmata kirja või muudatust läks kaotsi.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Seadme salvestusruum on täis.",
"storage_full_text": "Bulwark ei salvesta siin enam kirju. Hoia vähem kirju või tühjenda see konto, et ruumi vabastada.",
"open_settings": "Ava seaded",
"default_on": "Bulwark hoiab su kirju selles seadmes, et see töötaks ka ilma ühenduseta. Saad seda seadetes muuta.",
"conflict_calendar": "Kalendrisündmus",
"conflict_contacts": "Kontakt",
"kept_files_title": "Võrguta hoitud failid",
"kept_state_kept": "Selles seadmes",
"kept_state_waiting": "Ootab allalaadimist",
"kept_error_tooLarge": "Liiga suur hoidmiseks",
"kept_error_overLimit": "Ületab selle seadme piiri",
"kept_error_notAllowed": "Sinu organisatsioon ei luba faile hoida",
"kept_error_download": "Allalaadimine ebaõnnestus",
"keep_offline": "Hoia võrguta",
"unpin": "Ära hoia enam võrguta",
"unpin_label": "Ära hoia faili {name} enam võrguta"
},
"deep_link": {
"message_not_found": "See sõnum ei ole enam saadaval.",
"thread_not_found": "See vestlus ei ole enam saadaval.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها.",
"storage_failed": "در این دستگاه جایی برای ایمیل آفلاین نمانده است. فضا را آزاد کنید یا در تنظیمات مقدار کمتری نگه دارید."
},
"offlineStorage": {
"keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این مرورگر نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.",
"window_label": "تا چه مدت قبل نگه داشته شود",
"window_description": "ایمیل‌ها و رویدادهای تقویم قدیمی‌تر از این دستگاه حذف می‌شوند. وقتی اتصال دارید همچنان می‌توانید آن‌ها را باز کنید.",
"window_days": "{days, plural, one {# روز} other {# روز}}",
"window_year": "۱ سال",
"window_everything": "همه چیز",
"attachments_label": "نگه داشتن پیوست‌ها",
"attachments_description": "پیوست‌های پیام‌های نگه‌داشته‌شده و فایل‌هایی که آفلاین نگه می‌دارید، در مجموع تا {total}. قدیمی‌ترین‌ها اول حذف می‌شوند.",
"total_locked": "سازمان شما حداکثر {size} پیوست و فایل را مجاز می‌داند.",
"attachment_cap_label": "بزرگ‌ترین پیوستی که نگه داشته می‌شود",
"usage_mail": "پیام‌ها و نمایه جست‌وجو",
"usage_attachments": "پیوست‌ها",
"usage_calendar": "تقویم",
"usage_contacts": "مخاطبان",
"usage_files": "فایل‌های نگه‌داشته‌شده آفلاین",
"usage_row": "{size} ({count, plural, one {# مورد} other {# مورد}})",
"progress_list": "در حال فهرست‌کردن پیام‌ها",
"progress_bodies": "در حال دانلود پیام‌ها",
"progress_threads": "در حال مرتب‌سازی گفتگوها",
"progress_files": "در حال دانلود فایل‌ها",
"progress_other": "در حال آوردن داده‌ها به این دستگاه",
"progress_count": "{done} از {total}",
"progress_count_open": "تا اینجا {done}",
"progress_left": "حدود {minutes, plural, one {# دقیقه} other {# دقیقه}} مانده",
"clear_label": "پاک‌کردن این حساب",
"clear_description": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف می‌کند و دوباره دانلود می‌کند.",
"clear_button": "پاک‌کردن…",
"clear_confirm_title": "این حساب روی این دستگاه پاک شود؟",
"clear_confirm_message": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف و دوباره دانلود می‌شود. ایمیل‌های شما روی سرور می‌مانند.",
"clear_confirm_unsent": "{count, plural, one {# پیام ارسال‌نشده از بین می‌رود.} other {# پیام ارسال‌نشده از بین می‌روند.}}",
"clear_confirm": "پاک‌کردن",
"persist_refused": "وقتی فضا کم شود، این مرورگر ممکن است ایمیل‌های نگه‌داشته‌شده را حذف کند. نصب برنامه معمولاً جلوی این را می‌گیرد.",
"persist_retry": "دوباره بپرس",
"safari_hint": "Safari داده‌های یک سایت را پس از ۷ روز بدون بازدید حذف می‌کند. Bulwark را به صفحه اصلی یا Dock اضافه کنید تا ایمیل‌هایتان روی این دستگاه بماند.",
"evicted": "مرورگر ایمیل‌های نگه‌داشته‌شده روی این دستگاه را حذف کرد. دوباره دانلود می‌شوند.",
"evicted_lost": "{count, plural, one {# پیام یا تغییر ارسال‌نشده از بین رفت.} other {# پیام یا تغییر ارسال‌نشده از بین رفتند.}}",
"evicted_dismiss": "تأیید",
"storage_full_title": "حافظه دستگاه پر است.",
"storage_full_text": "Bulwark دیگر ایمیلی را اینجا ذخیره نمی‌کند. ایمیل کمتری نگه دارید یا این حساب را پاک کنید تا جا باز شود.",
"open_settings": "باز کردن تنظیمات",
"default_on": "Bulwark ایمیل‌هایتان را روی این دستگاه نگه می‌دارد تا بدون اتصال هم کار کند. می‌توانید این را در تنظیمات تغییر دهید.",
"conflict_calendar": "رویداد تقویم",
"conflict_contacts": "مخاطب",
"kept_files_title": "فایل‌های نگه‌داشته‌شده آفلاین",
"kept_state_kept": "روی این دستگاه",
"kept_state_waiting": "در انتظار دانلود",
"kept_error_tooLarge": "برای نگه‌داشتن بیش از حد بزرگ است",
"kept_error_overLimit": "از حد این دستگاه بیشتر است",
"kept_error_notAllowed": "سازمان شما نگه‌داشتن فایل‌ها را مجاز نمی‌داند",
"kept_error_download": "دانلود نشد",
"keep_offline": "نگه داشتن آفلاین",
"unpin": "دیگر آفلاین نگه داشته نشود",
"unpin_label": "{name} دیگر آفلاین نگه داشته نشود"
},
"deep_link": {
"message_not_found": "این پیام دیگر در دسترس نیست.",
"thread_not_found": "این گفتگو دیگر در دسترس نیست.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Des modifications faites hors ligne n'ont pas été appliquées : {count}. La boîte d'envoi indique lesquelles.",
"storage_failed": "Cet appareil n'a plus de place pour le courrier hors ligne. Libérez de l'espace ou conservez moins dans les paramètres."
},
"offlineStorage": {
"keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés dans ce navigateur. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.",
"window_label": "Durée de conservation",
"window_description": "Les e-mails et événements plus anciens quittent cet appareil. Vous pouvez toujours les ouvrir quand vous êtes connecté.",
"window_days": "{days, plural, one {# jour} other {# jours}}",
"window_year": "1 an",
"window_everything": "Tout",
"attachments_label": "Garder les pièces jointes",
"attachments_description": "Pièces jointes des messages gardés et fichiers que vous gardez hors ligne, jusqu'à {total} au total. Les plus anciens partent en premier.",
"total_locked": "Votre organisation autorise au maximum {size} de pièces jointes et de fichiers.",
"attachment_cap_label": "Plus grande pièce jointe à garder",
"usage_mail": "Messages et index de recherche",
"usage_attachments": "Pièces jointes",
"usage_calendar": "Agenda",
"usage_contacts": "Contacts",
"usage_files": "Fichiers gardés hors ligne",
"usage_row": "{size} ({count, plural, one {# élément} other {# éléments}})",
"progress_list": "Liste des messages",
"progress_bodies": "Téléchargement des messages",
"progress_threads": "Tri des conversations",
"progress_files": "Téléchargement des fichiers",
"progress_other": "Transfert des données vers cet appareil",
"progress_count": "{done} sur {total}",
"progress_count_open": "{done} jusqu'ici",
"progress_left": "environ {minutes, plural, one {# minute} other {# minutes}} restantes",
"clear_label": "Vider ce compte",
"clear_description": "Supprime tout ce que cet appareil garde pour ce compte, puis le télécharge de nouveau.",
"clear_button": "Vider…",
"clear_confirm_title": "Vider ce compte sur cet appareil ?",
"clear_confirm_message": "Tout ce que cet appareil garde pour ce compte est supprimé puis téléchargé de nouveau. Vos e-mails sur le serveur restent en place.",
"clear_confirm_unsent": "{count, plural, one {# message non envoyé sera perdu.} other {# messages non envoyés seront perdus.}}",
"clear_confirm": "Vider",
"persist_refused": "Ce navigateur peut supprimer les e-mails gardés quand la place manque. Installer l'application l'évite en général.",
"persist_retry": "Redemander",
"safari_hint": "Safari supprime les données d'un site après 7 jours sans visite. Ajoutez Bulwark à votre écran d'accueil ou au Dock pour garder vos e-mails sur cet appareil.",
"evicted": "Le navigateur a supprimé les e-mails gardés sur cet appareil. Ils sont de nouveau en cours de téléchargement.",
"evicted_lost": "{count, plural, one {# message ou modification non envoyé a été perdu.} other {# messages ou modifications non envoyés ont été perdus.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Le stockage de l'appareil est plein.",
"storage_full_text": "Bulwark n'enregistre plus d'e-mails ici. Gardez moins d'e-mails ou videz ce compte pour libérer de la place.",
"open_settings": "Ouvrir les réglages",
"default_on": "Bulwark garde vos e-mails sur cet appareil pour fonctionner sans connexion. Vous pouvez le modifier dans les réglages.",
"conflict_calendar": "Événement de l'agenda",
"conflict_contacts": "Contact",
"kept_files_title": "Fichiers gardés hors ligne",
"kept_state_kept": "Sur cet appareil",
"kept_state_waiting": "En attente de téléchargement",
"kept_error_tooLarge": "Trop volumineux pour être gardé",
"kept_error_overLimit": "Limite de cet appareil dépassée",
"kept_error_notAllowed": "Votre organisation n'autorise pas de garder des fichiers",
"kept_error_download": "Téléchargement impossible",
"keep_offline": "Garder hors ligne",
"unpin": "Ne plus garder hors ligne",
"unpin_label": "Ne plus garder {name} hors ligne"
},
"deep_link": {
"message_not_found": "Ce message n'est plus disponible.",
"thread_not_found": "Cette conversation n'est plus disponible.",
+55
View File
@@ -5757,6 +5757,61 @@
"toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו.",
"storage_failed": "אין יותר מקום במכשיר הזה לדואר לא מקוון. יש לפנות מקום או לשמור פחות בהגדרות."
},
"offlineStorage": {
"keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים בדפדפן הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.",
"window_label": "כמה זמן אחורה לשמור",
"window_description": "דואר ואירועי יומן ישנים יותר יוסרו מהמכשיר הזה. אפשר עדיין לפתוח אותם כשיש חיבור.",
"window_days": "{days, plural, one {יום #} two {יומיים} other {# ימים}}",
"window_year": "שנה אחת",
"window_everything": "הכול",
"attachments_label": "שמירת קבצים מצורפים",
"attachments_description": "קבצים מצורפים של הודעות שמורות וקבצים ששומרים במצב לא מקוון, עד {total} בסך הכול. הישנים ביותר מוסרים ראשונים.",
"total_locked": "הארגון שלך מאפשר לכל היותר {size} של קבצים מצורפים וקבצים.",
"attachment_cap_label": "הקובץ המצורף הגדול ביותר לשמירה",
"usage_mail": "הודעות ואינדקס חיפוש",
"usage_attachments": "קבצים מצורפים",
"usage_calendar": "יומן",
"usage_contacts": "אנשי קשר",
"usage_files": "קבצים שמורים במצב לא מקוון",
"usage_row": "{size} ({count, plural, one {פריט #} two {# פריטים} other {# פריטים}})",
"progress_list": "מציג רשימת הודעות",
"progress_bodies": "מוריד הודעות",
"progress_threads": "ממיין שיחות",
"progress_files": "מוריד קבצים",
"progress_other": "מביא נתונים למכשיר הזה",
"progress_count": "{done} מתוך {total}",
"progress_count_open": "עד כה {done}",
"progress_left": "נותרו בערך {minutes, plural, one {דקה #} two {# דקות} other {# דקות}}",
"clear_label": "ניקוי החשבון הזה",
"clear_description": "מוחק את כל מה שהמכשיר הזה שומר עבור החשבון הזה, ואז מוריד אותו מחדש.",
"clear_button": "ניקוי…",
"clear_confirm_title": "לנקות את החשבון הזה במכשיר הזה?",
"clear_confirm_message": "כל מה שהמכשיר הזה שומר עבור החשבון הזה יימחק ויורד מחדש. הדואר שלך בשרת נשאר.",
"clear_confirm_unsent": "{count, plural, one {הודעה אחת שלא נשלחה תאבד.} two {# הודעות שלא נשלחו יאבדו.} other {# הודעות שלא נשלחו יאבדו.}}",
"clear_confirm": "ניקוי",
"persist_refused": "הדפדפן הזה עלול למחוק את הדואר השמור כשנגמר המקום. התקנת האפליקציה בדרך כלל מונעת זאת.",
"persist_retry": "לשאול שוב",
"safari_hint": "Safari מוחק את נתוני האתר אחרי 7 ימים בלי ביקור. הוסיפו את Bulwark למסך הבית או ל-Dock כדי לשמור את הדואר במכשיר הזה.",
"evicted": "הדפדפן מחק את הדואר ששמור במכשיר הזה. הוא יורד מחדש.",
"evicted_lost": "{count, plural, one {הודעה אחת או שינוי אחד שלא נשלחו אבדו.} two {# הודעות או שינויים שלא נשלחו אבדו.} other {# הודעות או שינויים שלא נשלחו אבדו.}}",
"evicted_dismiss": "אישור",
"storage_full_title": "האחסון במכשיר מלא.",
"storage_full_text": "Bulwark הפסיק לשמור כאן דואר. שמרו פחות דואר או נקו את החשבון הזה כדי לפנות מקום.",
"open_settings": "פתיחת הגדרות",
"default_on": "Bulwark שומר את הדואר שלך במכשיר הזה כדי שיעבוד בלי חיבור. אפשר לשנות זאת בהגדרות.",
"conflict_calendar": "אירוע ביומן",
"conflict_contacts": "איש קשר",
"kept_files_title": "קבצים שמורים במצב לא מקוון",
"kept_state_kept": "במכשיר הזה",
"kept_state_waiting": "ממתין להורדה",
"kept_error_tooLarge": "גדול מדי לשמירה",
"kept_error_overLimit": "חורג מהמגבלה של המכשיר הזה",
"kept_error_notAllowed": "הארגון שלך לא מאפשר לשמור קבצים",
"kept_error_download": "לא ניתן היה להוריד",
"keep_offline": "שמירה במצב לא מקוון",
"unpin": "הפסקת שמירה במצב לא מקוון",
"unpin_label": "הפסקת שמירה של {name} במצב לא מקוון"
},
"deep_link": {
"message_not_found": "הודעה זו אינה זמינה עוד.",
"thread_not_found": "שיחה זו אינה זמינה עוד.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Offline végzett, nem alkalmazott módosítások: {count}. A postázandók mappa mutatja, melyek.",
"storage_failed": "Ezen az eszközön nincs több hely az offline leveleknek. Szabadítson fel helyet, vagy tartson meg kevesebbet a beállításokban."
},
"offlineStorage": {
"keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ebben a böngészőben. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.",
"window_label": "Mennyi időre visszamenőleg maradjon meg",
"window_description": "A régebbi levelek és naptáresemények lekerülnek erről az eszközről. Online állapotban továbbra is megnyithatja őket.",
"window_days": "{days, plural, one {# nap} other {# nap}}",
"window_year": "1 év",
"window_everything": "Minden",
"attachments_label": "Mellékletek megtartása",
"attachments_description": "A megtartott levelek mellékletei és az offline megtartott fájlok, összesen legfeljebb {total}. A legrégebbiek törlődnek először.",
"total_locked": "A szervezete legfeljebb {size} mellékletet és fájlt engedélyez.",
"attachment_cap_label": "A megtartott legnagyobb melléklet",
"usage_mail": "Levelek és keresési index",
"usage_attachments": "Mellékletek",
"usage_calendar": "Naptár",
"usage_contacts": "Névjegyek",
"usage_files": "Offline megtartott fájlok",
"usage_row": "{size} ({count, plural, one {# elem} other {# elem}})",
"progress_list": "Levelek listázása",
"progress_bodies": "Levelek letöltése",
"progress_threads": "Beszélgetések rendezése",
"progress_files": "Fájlok letöltése",
"progress_other": "Adatok áthozása erre az eszközre",
"progress_count": "{done} / {total}",
"progress_count_open": "eddig {done}",
"progress_left": "körülbelül {minutes, plural, one {# perc} other {# perc}} van hátra",
"clear_label": "Fiók kiürítése",
"clear_description": "Törli mindazt, amit ez az eszköz ehhez a fiókhoz megtart, majd újra letölti.",
"clear_button": "Kiürítés…",
"clear_confirm_title": "Kiüríti a fiókot ezen az eszközön?",
"clear_confirm_message": "Minden, amit ez az eszköz ehhez a fiókhoz megtart, törlődik és újra letöltődik. A szerveren lévő levelei megmaradnak.",
"clear_confirm_unsent": "{count, plural, one {# el nem küldött levél elvész.} other {# el nem küldött levél elvész.}}",
"clear_confirm": "Kiürítés",
"persist_refused": "Ez a böngésző törölheti a megtartott leveleket, ha fogy a hely. Az alkalmazás telepítése ezt általában megakadályozza.",
"persist_retry": "Újra rákérdez",
"safari_hint": "A Safari 7 nap látogatás nélkül törli egy webhely adatait. Adja hozzá a Bulwarkot a Főképernyőhöz vagy a Dockhoz, hogy a levelei az eszközön maradjanak.",
"evicted": "A böngésző törölte az ezen az eszközön megtartott leveleket. Újra letöltődnek.",
"evicted_lost": "{count, plural, one {# el nem küldött levél vagy módosítás elveszett.} other {# el nem küldött levél vagy módosítás elveszett.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Az eszköz tárhelye megtelt.",
"storage_full_text": "A Bulwark nem ment több levelet ide. Tartson meg kevesebb levelet, vagy ürítse ki ezt a fiókot, hogy legyen hely.",
"open_settings": "Beállítások megnyitása",
"default_on": "A Bulwark ezen az eszközön tartja meg a leveleit, hogy kapcsolat nélkül is működjön. Ezt a Beállításokban módosíthatja.",
"conflict_calendar": "Naptáresemény",
"conflict_contacts": "Névjegy",
"kept_files_title": "Offline megtartott fájlok",
"kept_state_kept": "Ezen az eszközön",
"kept_state_waiting": "Letöltésre vár",
"kept_error_tooLarge": "Túl nagy a megtartáshoz",
"kept_error_overLimit": "Meghaladja az eszköz korlátját",
"kept_error_notAllowed": "A szervezete nem engedi a fájlok megtartását",
"kept_error_download": "Nem sikerült letölteni",
"keep_offline": "Megtartás offline",
"unpin": "Megtartás offline megszüntetése",
"unpin_label": "{name} offline megtartásának megszüntetése"
},
"deep_link": {
"message_not_found": "Ez az üzenet már nem érhető el.",
"thread_not_found": "Ez a beszélgetés már nem érhető el.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Modifiche fatte offline non applicate: {count}. La posta in uscita indica quali.",
"storage_failed": "Questo dispositivo non ha più spazio per la posta offline. Libera spazio o conservane meno nelle impostazioni."
},
"offlineStorage": {
"keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati in questo browser. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.",
"window_label": "Da quanto tempo conservare",
"window_description": "La posta e gli eventi più vecchi lasciano questo dispositivo. Puoi comunque aprirli quando sei online.",
"window_days": "{days, plural, one {# giorno} other {# giorni}}",
"window_year": "1 anno",
"window_everything": "Tutto",
"attachments_label": "Conserva gli allegati",
"attachments_description": "Allegati dei messaggi conservati e file che tieni offline, fino a {total} in totale. I più vecchi vengono rimossi per primi.",
"total_locked": "La tua organizzazione consente al massimo {size} di allegati e file.",
"attachment_cap_label": "Allegato più grande da conservare",
"usage_mail": "Messaggi e indice di ricerca",
"usage_attachments": "Allegati",
"usage_calendar": "Calendario",
"usage_contacts": "Contatti",
"usage_files": "File conservati offline",
"usage_row": "{size} ({count, plural, one {# elemento} other {# elementi}})",
"progress_list": "Elenco dei messaggi",
"progress_bodies": "Download dei messaggi",
"progress_threads": "Ordinamento delle conversazioni",
"progress_files": "Download dei file",
"progress_other": "Trasferimento dei dati su questo dispositivo",
"progress_count": "{done} di {total}",
"progress_count_open": "{done} finora",
"progress_left": "mancano circa {minutes, plural, one {# minuto} other {# minuti}}",
"clear_label": "Svuota questo account",
"clear_description": "Elimina tutto ciò che questo dispositivo conserva per questo account, poi lo scarica di nuovo.",
"clear_button": "Svuota…",
"clear_confirm_title": "Svuotare questo account su questo dispositivo?",
"clear_confirm_message": "Tutto ciò che questo dispositivo conserva per questo account viene eliminato e scaricato di nuovo. La tua posta sul server resta dov'è.",
"clear_confirm_unsent": "{count, plural, one {# messaggio non inviato andrà perso.} other {# messaggi non inviati andranno persi.}}",
"clear_confirm": "Svuota",
"persist_refused": "Questo browser può eliminare la posta conservata quando lo spazio scarseggia. Installare l'app di solito lo evita.",
"persist_retry": "Chiedi di nuovo",
"safari_hint": "Safari elimina i dati di un sito dopo 7 giorni senza visite. Aggiungi Bulwark alla schermata Home o al Dock per conservare la tua posta su questo dispositivo.",
"evicted": "Il browser ha eliminato la posta conservata su questo dispositivo. Viene scaricata di nuovo.",
"evicted_lost": "{count, plural, one {# messaggio o modifica non inviati sono andati persi.} other {# messaggi o modifiche non inviati sono andati persi.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Lo spazio del dispositivo è esaurito.",
"storage_full_text": "Bulwark ha smesso di salvare la posta qui. Conserva meno posta o svuota questo account per fare spazio.",
"open_settings": "Apri impostazioni",
"default_on": "Bulwark conserva la tua posta su questo dispositivo, così funziona anche senza connessione. Puoi cambiarlo nelle impostazioni.",
"conflict_calendar": "Evento del calendario",
"conflict_contacts": "Contatto",
"kept_files_title": "File conservati offline",
"kept_state_kept": "Su questo dispositivo",
"kept_state_waiting": "In attesa del download",
"kept_error_tooLarge": "Troppo grande per essere conservato",
"kept_error_overLimit": "Oltre il limite di questo dispositivo",
"kept_error_notAllowed": "La tua organizzazione non consente di conservare i file",
"kept_error_download": "Impossibile scaricare",
"keep_offline": "Conserva offline",
"unpin": "Non conservare più offline",
"unpin_label": "Non conservare più {name} offline"
},
"deep_link": {
"message_not_found": "Questo messaggio non è più disponibile.",
"thread_not_found": "Questa conversazione non è più disponibile.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。",
"storage_failed": "このデバイスにはオフラインメール用の空き容量がありません。空き容量を増やすか、設定で保存する量を減らしてください。"
},
"offlineStorage": {
"keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのブラウザーに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。",
"window_label": "保存する期間",
"window_description": "これより古いメールとカレンダーの予定はこのデバイスから削除されます。オンラインのときは引き続き開けます。",
"window_days": "{days, plural, other {#日}}",
"window_year": "1年",
"window_everything": "すべて",
"attachments_label": "添付ファイルを保存",
"attachments_description": "保存したメッセージの添付ファイルと、オフライン用に保存したファイルを合計 {total} まで保存します。古いものから削除されます。",
"total_locked": "組織の設定により、添付ファイルとファイルは最大 {size} までです。",
"attachment_cap_label": "保存する添付ファイルの最大サイズ",
"usage_mail": "メッセージと検索インデックス",
"usage_attachments": "添付ファイル",
"usage_calendar": "カレンダー",
"usage_contacts": "連絡先",
"usage_files": "オフライン用に保存したファイル",
"usage_row": "{size}({count, plural, other {#件}})",
"progress_list": "メッセージを一覧表示しています",
"progress_bodies": "メッセージをダウンロードしています",
"progress_threads": "スレッドを並べ替えています",
"progress_files": "ファイルをダウンロードしています",
"progress_other": "データをこのデバイスに取り込んでいます",
"progress_count": "{done} / {total}",
"progress_count_open": "ここまで {done}",
"progress_left": "残り約{minutes, plural, other {#分}}",
"clear_label": "このアカウントを消去",
"clear_description": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。",
"clear_button": "消去…",
"clear_confirm_title": "このデバイスでこのアカウントを消去しますか?",
"clear_confirm_message": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。サーバー上のメールはそのまま残ります。",
"clear_confirm_unsent": "{count, plural, other {未送信のメッセージ#件が失われます。}}",
"clear_confirm": "消去",
"persist_refused": "空き容量が少なくなると、このブラウザーは保存したメールを削除することがあります。アプリをインストールすると、たいていは防げます。",
"persist_retry": "もう一度確認",
"safari_hint": "Safari は、7日間アクセスがないサイトのデータを削除します。このデバイスにメールを残すには、Bulwark をホーム画面または Dock に追加してください。",
"evicted": "ブラウザーがこのデバイスに保存していたメールを削除しました。あらためてダウンロードしています。",
"evicted_lost": "{count, plural, other {未送信のメッセージまたは変更#件が失われました。}}",
"evicted_dismiss": "OK",
"storage_full_title": "デバイスの空き容量がありません。",
"storage_full_text": "Bulwark はここへのメールの保存を停止しました。保存するメールを減らすか、このアカウントを消去して空きを作ってください。",
"open_settings": "設定を開く",
"default_on": "Bulwark はメールをこのデバイスに保存するので、接続がなくても使えます。設定で変更できます。",
"conflict_calendar": "カレンダーの予定",
"conflict_contacts": "連絡先",
"kept_files_title": "オフライン用に保存したファイル",
"kept_state_kept": "このデバイスにあります",
"kept_state_waiting": "ダウンロード待ち",
"kept_error_tooLarge": "大きすぎて保存できません",
"kept_error_overLimit": "このデバイスの上限を超えています",
"kept_error_notAllowed": "組織の設定によりファイルを保存できません",
"kept_error_download": "ダウンロードできませんでした",
"keep_offline": "オフライン用に保存",
"unpin": "オフライン保存をやめる",
"unpin_label": "{name} のオフライン保存をやめる"
},
"deep_link": {
"message_not_found": "このメッセージは利用できなくなりました。",
"thread_not_found": "この会話は利用できなくなりました。",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다.",
"storage_failed": "이 기기에 오프라인 메일을 저장할 공간이 없습니다. 공간을 확보하거나 설정에서 보관량을 줄이세요."
},
"offlineStorage": {
"keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 브라우저에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.",
"window_label": "보관할 기간",
"window_description": "이보다 오래된 메일과 캘린더 일정은 이 기기에서 삭제됩니다. 온라인일 때는 계속 열어볼 수 있습니다.",
"window_days": "{days, plural, other {#일}}",
"window_year": "1년",
"window_everything": "전체",
"attachments_label": "첨부 파일 보관",
"attachments_description": "보관한 메시지의 첨부 파일과 오프라인용으로 보관한 파일을 합쳐 최대 {total}까지 보관합니다. 오래된 것부터 삭제됩니다.",
"total_locked": "조직 설정에 따라 첨부 파일과 파일은 최대 {size}까지만 허용됩니다.",
"attachment_cap_label": "보관할 첨부 파일의 최대 크기",
"usage_mail": "메시지 및 검색 색인",
"usage_attachments": "첨부 파일",
"usage_calendar": "캘린더",
"usage_contacts": "연락처",
"usage_files": "오프라인용으로 보관한 파일",
"usage_row": "{size} ({count, plural, other {#개}})",
"progress_list": "메시지 목록을 만드는 중",
"progress_bodies": "메시지를 다운로드하는 중",
"progress_threads": "대화를 정렬하는 중",
"progress_files": "파일을 다운로드하는 중",
"progress_other": "데이터를 이 기기로 가져오는 중",
"progress_count": "{total} 중 {done}",
"progress_count_open": "지금까지 {done}",
"progress_left": "약 {minutes, plural, other {#분}} 남음",
"clear_label": "이 계정 비우기",
"clear_description": "이 기기에 보관된 이 계정의 데이터를 모두 삭제한 뒤 다시 다운로드합니다.",
"clear_button": "비우기…",
"clear_confirm_title": "이 기기에서 이 계정을 비울까요?",
"clear_confirm_message": "이 기기에 보관된 이 계정의 데이터가 모두 삭제되고 다시 다운로드됩니다. 서버에 있는 메일은 그대로 남습니다.",
"clear_confirm_unsent": "{count, plural, other {보내지 않은 메시지 #개가 사라집니다.}}",
"clear_confirm": "비우기",
"persist_refused": "저장 공간이 부족해지면 이 브라우저가 보관한 메일을 삭제할 수 있습니다. 앱을 설치하면 대개 막을 수 있습니다.",
"persist_retry": "다시 요청",
"safari_hint": "Safari는 7일 동안 방문하지 않은 사이트의 데이터를 삭제합니다. 이 기기에 메일을 계속 보관하려면 Bulwark를 홈 화면이나 Dock에 추가하세요.",
"evicted": "브라우저가 이 기기에 보관한 메일을 삭제했습니다. 다시 다운로드하는 중입니다.",
"evicted_lost": "{count, plural, other {보내지 않은 메시지 또는 변경 사항 #개가 사라졌습니다.}}",
"evicted_dismiss": "확인",
"storage_full_title": "기기 저장 공간이 가득 찼습니다.",
"storage_full_text": "Bulwark가 이곳에 메일 저장을 중단했습니다. 보관하는 메일을 줄이거나 이 계정을 비워 공간을 확보하세요.",
"open_settings": "설정 열기",
"default_on": "Bulwark는 연결이 없어도 쓸 수 있도록 메일을 이 기기에 보관합니다. 설정에서 변경할 수 있습니다.",
"conflict_calendar": "캘린더 일정",
"conflict_contacts": "연락처",
"kept_files_title": "오프라인용으로 보관한 파일",
"kept_state_kept": "이 기기에 있음",
"kept_state_waiting": "다운로드 대기 중",
"kept_error_tooLarge": "너무 커서 보관할 수 없음",
"kept_error_overLimit": "이 기기의 한도를 초과함",
"kept_error_notAllowed": "조직에서 파일 보관을 허용하지 않음",
"kept_error_download": "다운로드하지 못함",
"keep_offline": "오프라인용으로 보관",
"unpin": "오프라인 보관 중지",
"unpin_label": "{name} 오프라인 보관 중지"
},
"deep_link": {
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
"thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Bezsaistē veiktās nepiemērotās izmaiņas: {count}. Izsūtne parāda, kuras.",
"storage_failed": "Šajā ierīcē vairs nav vietas bezsaistes pastam. Atbrīvojiet vietu vai iestatījumos glabājiet mazāk."
},
"offlineStorage": {
"keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā pārlūkā. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.",
"window_label": "Cik tālu atpakaļ glabāt",
"window_description": "Vecākās vēstules un kalendāra notikumi pazūd no šīs ierīces. Tās joprojām varat atvērt, kad ir savienojums.",
"window_days": "{days, plural, zero {# dienu} one {# diena} other {# dienas}}",
"window_year": "1 gads",
"window_everything": "Viss",
"attachments_label": "Glabāt pielikumus",
"attachments_description": "Glabāto vēstuļu pielikumi un faili, ko glabājat bezsaistē, kopā līdz {total}. Vecākie tiek noņemti pirmie.",
"total_locked": "Jūsu organizācija atļauj ne vairāk kā {size} pielikumu un failu.",
"attachment_cap_label": "Lielākais glabājamais pielikums",
"usage_mail": "Vēstules un meklēšanas indekss",
"usage_attachments": "Pielikumi",
"usage_calendar": "Kalendārs",
"usage_contacts": "Kontaktpersonas",
"usage_files": "Bezsaistē glabātie faili",
"usage_row": "{size} ({count, plural, zero {# vienumu} one {# vienums} other {# vienumi}})",
"progress_list": "Vēstuļu saraksta veidošana",
"progress_bodies": "Vēstuļu lejupielāde",
"progress_threads": "Sarunu kārtošana",
"progress_files": "Failu lejupielāde",
"progress_other": "Datu pārnešana uz šo ierīci",
"progress_count": "{done} no {total}",
"progress_count_open": "līdz šim {done}",
"progress_left": "atlikušas apmēram {minutes, plural, zero {# minūšu} one {# minūte} other {# minūtes}}",
"clear_label": "Notīrīt šo kontu",
"clear_description": "Izdzēš visu, ko šī ierīce glabā šim kontam, un lejupielādē to no jauna.",
"clear_button": "Notīrīt…",
"clear_confirm_title": "Vai notīrīt šo kontu šajā ierīcē?",
"clear_confirm_message": "Viss, ko šī ierīce glabā šim kontam, tiek izdzēsts un lejupielādēts no jauna. Jūsu e-pasts serverī paliek.",
"clear_confirm_unsent": "{count, plural, zero {# neizsūtītu vēstuļu tiks zaudēts.} one {Tiks zaudēta # neizsūtīta vēstule.} other {Tiks zaudētas # neizsūtītas vēstules.}}",
"clear_confirm": "Notīrīt",
"persist_refused": "Šis pārlūks var izdzēst glabāto e-pastu, kad pietrūkst vietas. Lietotnes instalēšana to parasti novērš.",
"persist_retry": "Pajautāt vēlreiz",
"safari_hint": "Safari izdzēš vietnes datus pēc 7 dienām bez apmeklējuma. Pievienojiet Bulwark sākuma ekrānam vai Dock, lai e-pasts paliktu šajā ierīcē.",
"evicted": "Pārlūks izdzēsa šajā ierīcē glabāto e-pastu. Tas tiek lejupielādēts no jauna.",
"evicted_lost": "{count, plural, zero {Tika zaudētas # neizsūtītu vēstuļu vai izmaiņu.} one {Tika zaudēta # neizsūtīta vēstule vai izmaiņa.} other {Tika zaudētas # neizsūtītas vēstules vai izmaiņas.}}",
"evicted_dismiss": "Labi",
"storage_full_title": "Ierīces krātuve ir pilna.",
"storage_full_text": "Bulwark vairs šeit nesaglabā e-pastu. Glabājiet mazāk e-pasta vai notīriet šo kontu, lai atbrīvotu vietu.",
"open_settings": "Atvērt iestatījumus",
"default_on": "Bulwark glabā jūsu e-pastu šajā ierīcē, lai tas darbotos bez savienojuma. To varat mainīt iestatījumos.",
"conflict_calendar": "Kalendāra notikums",
"conflict_contacts": "Kontaktpersona",
"kept_files_title": "Bezsaistē glabātie faili",
"kept_state_kept": "Šajā ierīcē",
"kept_state_waiting": "Gaida lejupielādi",
"kept_error_tooLarge": "Pārāk liels, lai glabātu",
"kept_error_overLimit": "Pārsniedz šīs ierīces ierobežojumu",
"kept_error_notAllowed": "Jūsu organizācija neatļauj glabāt failus",
"kept_error_download": "Nevarēja lejupielādēt",
"keep_offline": "Glabāt bezsaistē",
"unpin": "Pārtraukt glabāt bezsaistē",
"unpin_label": "Pārtraukt glabāt {name} bezsaistē"
},
"deep_link": {
"message_not_found": "Šī ziņa vairs nav pieejama.",
"thread_not_found": "Šī sarakste vairs nav pieejama.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу.",
"storage_failed": "Энэ төхөөрөмжид офлайн захидалд зай үлдсэнгүй. Зай чөлөөлөх эсвэл тохиргооноос цөөнийг хадгална уу."
},
"offlineStorage": {
"keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ хөтөч дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.",
"window_label": "Хэр удаан хугацаагаар хадгалах вэ",
"window_description": "Илүү хуучин имэйл болон хуанлийн үйл явдлууд энэ төхөөрөмжөөс устна. Холболттой үед та тэдгээрийг нээж болно.",
"window_days": "{days, plural, one {# өдөр} other {# өдөр}}",
"window_year": "1 жил",
"window_everything": "Бүгд",
"attachments_label": "Хавсралтуудыг хадгалах",
"attachments_description": "Хадгалсан мессежүүдийн хавсралт болон офлайн хадгалсан файлууд, нийт {total} хүртэл. Хамгийн хуучин нь эхэлж устна.",
"total_locked": "Таны байгууллага хавсралт болон файлыг нийтдээ {size} хүртэл зөвшөөрдөг.",
"attachment_cap_label": "Хадгалах хамгийн том хавсралт",
"usage_mail": "Мессеж ба хайлтын индекс",
"usage_attachments": "Хавсралтууд",
"usage_calendar": "Хуанли",
"usage_contacts": "Харилцагчид",
"usage_files": "Офлайн хадгалсан файлууд",
"usage_row": "{size} ({count, plural, one {# зүйл} other {# зүйл}})",
"progress_list": "Мессежүүдийг жагсааж байна",
"progress_bodies": "Мессежүүдийг татаж байна",
"progress_threads": "Харилцан яриаг эрэмбэлж байна",
"progress_files": "Файлуудыг татаж байна",
"progress_other": "Өгөгдлийг энэ төхөөрөмж рүү авчирч байна",
"progress_count": "{total}-аас {done}",
"progress_count_open": "одоогоор {done}",
"progress_left": "ойролцоогоор {minutes, plural, one {# минут} other {# минут}} үлдсэн",
"clear_label": "Энэ бүртгэлийг цэвэрлэх",
"clear_description": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхнийг устгаад дахин татна.",
"clear_button": "Цэвэрлэх…",
"clear_confirm_title": "Энэ бүртгэлийг энэ төхөөрөмж дээр цэвэрлэх үү?",
"clear_confirm_message": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхэн устаж, дахин татагдана. Серверийн имэйл тань хэвээр үлдэнэ.",
"clear_confirm_unsent": "{count, plural, one {# илгээгээгүй мессеж алдагдана.} other {# илгээгээгүй мессеж алдагдана.}}",
"clear_confirm": "Цэвэрлэх",
"persist_refused": "Зай дутвал энэ хөтөч хадгалсан имэйлийг устгаж болно. Аппыг суулгавал ихэвчлэн ийм зүйл болдоггүй.",
"persist_retry": "Дахин асуух",
"safari_hint": "Safari сайтад 7 хоног зочлоогүй бол түүний өгөгдлийг устгадаг. Имэйлээ энэ төхөөрөмж дээр хадгалахын тулд Bulwark-ыг Үндсэн дэлгэц эсвэл Dock-д нэмнэ үү.",
"evicted": "Хөтөч энэ төхөөрөмж дээр хадгалсан имэйлийг устгасан. Дахин татаж байна.",
"evicted_lost": "{count, plural, one {# илгээгээгүй мессеж эсвэл өөрчлөлт алдагдсан.} other {# илгээгээгүй мессеж эсвэл өөрчлөлт алдагдсан.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Төхөөрөмжийн сав дүүрсэн.",
"storage_full_text": "Bulwark энд имэйл хадгалахаа больсон. Бага имэйл хадгалах эсвэл зай гаргахын тулд энэ бүртгэлийг цэвэрлэнэ үү.",
"open_settings": "Тохиргоо нээх",
"default_on": "Bulwark таны имэйлийг энэ төхөөрөмж дээр хадгалдаг тул холболтгүйгээр ажиллана. Үүнийг Тохиргоонд өөрчилж болно.",
"conflict_calendar": "Хуанлийн үйл явдал",
"conflict_contacts": "Харилцагч",
"kept_files_title": "Офлайн хадгалсан файлууд",
"kept_state_kept": "Энэ төхөөрөмж дээр",
"kept_state_waiting": "Татагдахыг хүлээж байна",
"kept_error_tooLarge": "Хадгалахад хэт том",
"kept_error_overLimit": "Энэ төхөөрөмжийн хязгаараас хэтэрсэн",
"kept_error_notAllowed": "Таны байгууллага файл хадгалахыг зөвшөөрдөггүй",
"kept_error_download": "Татаж чадсангүй",
"keep_offline": "Офлайн хадгалах",
"unpin": "Офлайн хадгалахаа болих",
"unpin_label": "{name}-г офлайн хадгалахаа болих"
},
"deep_link": {
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
"thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Endringer gjort uten nett som ikke ble utført: {count}. Utboksen viser hvilke.",
"storage_failed": "Denne enheten har ikke mer plass til e-post uten nett. Frigjør plass, eller behold mindre i innstillingene."
},
"offlineStorage": {
"keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert i denne nettleseren. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.",
"window_label": "Hvor langt tilbake som beholdes",
"window_description": "Eldre e-post og kalenderhendelser forlater denne enheten. Du kan fortsatt åpne dem når du er tilkoblet.",
"window_days": "{days, plural, one {# dag} other {# dager}}",
"window_year": "1 år",
"window_everything": "Alt",
"attachments_label": "Behold vedlegg",
"attachments_description": "Vedlegg fra beholdte meldinger og filene du beholder frakoblet, opptil {total} til sammen. De eldste fjernes først.",
"total_locked": "Organisasjonen din tillater høyst {size} med vedlegg og filer.",
"attachment_cap_label": "Største vedlegg som beholdes",
"usage_mail": "Meldinger og søkeindeks",
"usage_attachments": "Vedlegg",
"usage_calendar": "Kalender",
"usage_contacts": "Kontakter",
"usage_files": "Filer beholdt frakoblet",
"usage_row": "{size} ({count, plural, one {# element} other {# elementer}})",
"progress_list": "Lister opp meldinger",
"progress_bodies": "Laster ned meldinger",
"progress_threads": "Sorterer samtaler",
"progress_files": "Laster ned filer",
"progress_other": "Henter data til denne enheten",
"progress_count": "{done} av {total}",
"progress_count_open": "{done} så langt",
"progress_left": "omtrent {minutes, plural, one {# minutt} other {# minutter}} igjen",
"clear_label": "Tøm denne kontoen",
"clear_description": "Sletter alt denne enheten beholder for denne kontoen, og laster det ned på nytt.",
"clear_button": "Tøm…",
"clear_confirm_title": "Tømme denne kontoen på denne enheten?",
"clear_confirm_message": "Alt denne enheten beholder for denne kontoen, slettes og lastes ned på nytt. E-posten din på serveren blir værende.",
"clear_confirm_unsent": "{count, plural, one {# usendt melding går tapt.} other {# usendte meldinger går tapt.}}",
"clear_confirm": "Tøm",
"persist_refused": "Denne nettleseren kan slette den beholdte e-posten når plassen blir knapp. Å installere appen hjelper vanligvis.",
"persist_retry": "Spør igjen",
"safari_hint": "Safari sletter data fra et nettsted etter 7 dager uten besøk. Legg Bulwark til på Hjem-skjermen eller i Dock for å beholde e-posten på denne enheten.",
"evicted": "Nettleseren slettet e-posten som var beholdt på denne enheten. Den lastes ned på nytt.",
"evicted_lost": "{count, plural, one {# usendt melding eller endring gikk tapt.} other {# usendte meldinger eller endringer gikk tapt.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Lagringsplassen på enheten er full.",
"storage_full_text": "Bulwark har sluttet å lagre e-post her. Behold mindre e-post, eller tøm denne kontoen for å få plass.",
"open_settings": "Åpne innstillinger",
"default_on": "Bulwark beholder e-posten din på denne enheten, så den fungerer uten tilkobling. Du kan endre dette i Innstillinger.",
"conflict_calendar": "Kalenderhendelse",
"conflict_contacts": "Kontakt",
"kept_files_title": "Filer beholdt frakoblet",
"kept_state_kept": "På denne enheten",
"kept_state_waiting": "Venter på nedlasting",
"kept_error_tooLarge": "For stor til å beholdes",
"kept_error_overLimit": "Over grensen for denne enheten",
"kept_error_notAllowed": "Organisasjonen din tillater ikke at filer beholdes",
"kept_error_download": "Kunne ikke lastes ned",
"keep_offline": "Behold frakoblet",
"unpin": "Slutt å beholde frakoblet",
"unpin_label": "Slutt å beholde {name} frakoblet"
},
"deep_link": {
"message_not_found": "Denne meldingen er ikke lenger tilgjengelig.",
"thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Offline gemaakte wijzigingen niet doorgevoerd: {count}. Het Postvak UIT laat zien welke.",
"storage_failed": "Dit apparaat heeft geen ruimte meer voor offline mail. Maak ruimte vrij of bewaar minder via Instellingen."
},
"offlineStorage": {
"keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld in deze browser. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.",
"window_label": "Hoe ver terug bewaren",
"window_description": "Oudere e-mail en agenda-items verdwijnen van dit apparaat. Je kunt ze nog openen als je online bent.",
"window_days": "{days, plural, one {# dag} other {# dagen}}",
"window_year": "1 jaar",
"window_everything": "Alles",
"attachments_label": "Bijlagen bewaren",
"attachments_description": "Bijlagen van bewaarde berichten en de bestanden die je offline bewaart, samen maximaal {total}. De oudste gaan eerst weg.",
"total_locked": "Je organisatie staat maximaal {size} aan bijlagen en bestanden toe.",
"attachment_cap_label": "Grootste bijlage om te bewaren",
"usage_mail": "Berichten en zoekindex",
"usage_attachments": "Bijlagen",
"usage_calendar": "Agenda",
"usage_contacts": "Contacten",
"usage_files": "Offline bewaarde bestanden",
"usage_row": "{size} ({count, plural, one {# item} other {# items}})",
"progress_list": "Berichten worden opgesomd",
"progress_bodies": "Berichten worden gedownload",
"progress_threads": "Gesprekken worden gesorteerd",
"progress_files": "Bestanden worden gedownload",
"progress_other": "Gegevens worden naar dit apparaat gehaald",
"progress_count": "{done} van {total}",
"progress_count_open": "{done} tot nu toe",
"progress_left": "nog ongeveer {minutes, plural, one {# minuut} other {# minuten}}",
"clear_label": "Dit account wissen",
"clear_description": "Verwijdert alles wat dit apparaat voor dit account bewaart en downloadt het daarna opnieuw.",
"clear_button": "Wissen…",
"clear_confirm_title": "Dit account op dit apparaat wissen?",
"clear_confirm_message": "Alles wat dit apparaat voor dit account bewaart, wordt verwijderd en opnieuw gedownload. Je e-mail op de server blijft staan.",
"clear_confirm_unsent": "{count, plural, one {# niet-verzonden bericht gaat verloren.} other {# niet-verzonden berichten gaan verloren.}}",
"clear_confirm": "Wissen",
"persist_refused": "Deze browser kan de bewaarde e-mail verwijderen als de ruimte opraakt. De app installeren voorkomt dat meestal.",
"persist_retry": "Opnieuw vragen",
"safari_hint": "Safari verwijdert de gegevens van een site na 7 dagen zonder bezoek. Zet Bulwark op je beginscherm of in het Dock om je e-mail op dit apparaat te houden.",
"evicted": "De browser heeft de e-mail verwijderd die op dit apparaat was bewaard. Ze wordt opnieuw gedownload.",
"evicted_lost": "{count, plural, one {# niet-verzonden bericht of wijziging is verloren gegaan.} other {# niet-verzonden berichten of wijzigingen zijn verloren gegaan.}}",
"evicted_dismiss": "OK",
"storage_full_title": "De opslag van het apparaat is vol.",
"storage_full_text": "Bulwark slaat hier geen e-mail meer op. Bewaar minder e-mail of wis dit account om ruimte te maken.",
"open_settings": "Instellingen openen",
"default_on": "Bulwark bewaart je e-mail op dit apparaat, zodat het ook zonder verbinding werkt. Je kunt dit wijzigen in de instellingen.",
"conflict_calendar": "Agenda-item",
"conflict_contacts": "Contact",
"kept_files_title": "Offline bewaarde bestanden",
"kept_state_kept": "Op dit apparaat",
"kept_state_waiting": "Wacht op download",
"kept_error_tooLarge": "Te groot om te bewaren",
"kept_error_overLimit": "Boven de limiet voor dit apparaat",
"kept_error_notAllowed": "Je organisatie staat het bewaren van bestanden niet toe",
"kept_error_download": "Kon niet worden gedownload",
"keep_offline": "Offline bewaren",
"unpin": "Niet meer offline bewaren",
"unpin_label": "{name} niet meer offline bewaren"
},
"deep_link": {
"message_not_found": "Dit bericht is niet meer beschikbaar.",
"thread_not_found": "Dit gesprek is niet meer beschikbaar.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Niezastosowane zmiany wprowadzone offline: {count}. Skrzynka nadawcza pokazuje które.",
"storage_failed": "Na tym urządzeniu nie ma już miejsca na pocztę offline. Zwolnij miejsce lub zachowuj mniej w ustawieniach."
},
"offlineStorage": {
"keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane w tej przeglądarce. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.",
"window_label": "Jak daleko wstecz przechowywać",
"window_description": "Starsza poczta i wydarzenia z kalendarza znikają z tego urządzenia. Nadal możesz je otworzyć, gdy masz połączenie.",
"window_days": "{days, plural, one {# dzień} few {# dni} many {# dni} other {# dnia}}",
"window_year": "1 rok",
"window_everything": "Wszystko",
"attachments_label": "Przechowuj załączniki",
"attachments_description": "Załączniki przechowywanych wiadomości i pliki przechowywane offline, razem do {total}. Najstarsze są usuwane najpierw.",
"total_locked": "Twoja organizacja zezwala na najwyżej {size} załączników i plików.",
"attachment_cap_label": "Największy przechowywany załącznik",
"usage_mail": "Wiadomości i indeks wyszukiwania",
"usage_attachments": "Załączniki",
"usage_calendar": "Kalendarz",
"usage_contacts": "Kontakty",
"usage_files": "Pliki przechowywane offline",
"usage_row": "{size} ({count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}})",
"progress_list": "Wyszukiwanie wiadomości",
"progress_bodies": "Pobieranie wiadomości",
"progress_threads": "Sortowanie konwersacji",
"progress_files": "Pobieranie plików",
"progress_other": "Przenoszenie danych na to urządzenie",
"progress_count": "{done} z {total}",
"progress_count_open": "dotąd {done}",
"progress_left": "zostało około {minutes, plural, one {# minuty} few {# minut} many {# minut} other {# minuty}}",
"clear_label": "Wyczyść to konto",
"clear_description": "Usuwa wszystko, co to urządzenie przechowuje dla tego konta, a potem pobiera to ponownie.",
"clear_button": "Wyczyść…",
"clear_confirm_title": "Wyczyścić to konto na tym urządzeniu?",
"clear_confirm_message": "Wszystko, co to urządzenie przechowuje dla tego konta, zostanie usunięte i pobrane ponownie. Twoja poczta na serwerze zostaje.",
"clear_confirm_unsent": "{count, plural, one {# niewysłana wiadomość zostanie utracona.} few {# niewysłane wiadomości zostaną utracone.} many {# niewysłanych wiadomości zostanie utraconych.} other {# niewysłanej wiadomości zostanie utraconej.}}",
"clear_confirm": "Wyczyść",
"persist_refused": "Ta przeglądarka może usunąć przechowywaną pocztę, gdy zabraknie miejsca. Zainstalowanie aplikacji zwykle temu zapobiega.",
"persist_retry": "Zapytaj ponownie",
"safari_hint": "Safari usuwa dane witryny po 7 dniach bez odwiedzin. Dodaj Bulwark do ekranu początkowego lub do Docka, aby zachować pocztę na tym urządzeniu.",
"evicted": "Przeglądarka usunęła pocztę przechowywaną na tym urządzeniu. Jest pobierana ponownie.",
"evicted_lost": "{count, plural, one {Utracono # niewysłaną wiadomość lub zmianę.} few {Utracono # niewysłane wiadomości lub zmiany.} many {Utracono # niewysłanych wiadomości lub zmian.} other {Utracono # niewysłanej wiadomości lub zmiany.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Pamięć urządzenia jest pełna.",
"storage_full_text": "Bulwark przestał tu zapisywać pocztę. Przechowuj mniej poczty albo wyczyść to konto, aby zwolnić miejsce.",
"open_settings": "Otwórz ustawienia",
"default_on": "Bulwark przechowuje Twoją pocztę na tym urządzeniu, żeby działała bez połączenia. Możesz to zmienić w Ustawieniach.",
"conflict_calendar": "Wydarzenie w kalendarzu",
"conflict_contacts": "Kontakt",
"kept_files_title": "Pliki przechowywane offline",
"kept_state_kept": "Na tym urządzeniu",
"kept_state_waiting": "Czeka na pobranie",
"kept_error_tooLarge": "Za duży, aby go przechowywać",
"kept_error_overLimit": "Przekracza limit tego urządzenia",
"kept_error_notAllowed": "Twoja organizacja nie zezwala na przechowywanie plików",
"kept_error_download": "Nie udało się pobrać",
"keep_offline": "Przechowuj offline",
"unpin": "Nie przechowuj już offline",
"unpin_label": "Nie przechowuj już {name} offline"
},
"deep_link": {
"message_not_found": "Ta wiadomość nie jest już dostępna.",
"thread_not_found": "Ta rozmowa nie jest już dostępna.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Alterações feitas offline não aplicadas: {count}. A caixa de saída mostra quais.",
"storage_failed": "Este dispositivo não tem espaço para o correio offline. Liberte espaço ou guarde menos nas definições."
},
"offlineStorage": {
"keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste navegador. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.",
"window_label": "Quanto tempo atrás manter",
"window_description": "Os e-mails e eventos mais antigos saem deste dispositivo. Pode continuar a abri-los quando tiver ligação.",
"window_days": "{days, plural, one {# dia} other {# dias}}",
"window_year": "1 ano",
"window_everything": "Tudo",
"attachments_label": "Manter anexos",
"attachments_description": "Anexos das mensagens mantidas e ficheiros que mantém offline, até {total} no total. Os mais antigos saem primeiro.",
"total_locked": "A sua organização permite no máximo {size} de anexos e ficheiros.",
"attachment_cap_label": "Maior anexo a manter",
"usage_mail": "Mensagens e índice de pesquisa",
"usage_attachments": "Anexos",
"usage_calendar": "Calendário",
"usage_contacts": "Contactos",
"usage_files": "Ficheiros mantidos offline",
"usage_row": "{size} ({count, plural, one {# item} other {# itens}})",
"progress_list": "A listar mensagens",
"progress_bodies": "A transferir mensagens",
"progress_threads": "A ordenar conversas",
"progress_files": "A transferir ficheiros",
"progress_other": "A trazer dados para este dispositivo",
"progress_count": "{done} de {total}",
"progress_count_open": "{done} até agora",
"progress_left": "faltam cerca de {minutes, plural, one {# minuto} other {# minutos}}",
"clear_label": "Limpar esta conta",
"clear_description": "Elimina tudo o que este dispositivo mantém para esta conta e volta a transferi-lo.",
"clear_button": "Limpar…",
"clear_confirm_title": "Limpar esta conta neste dispositivo?",
"clear_confirm_message": "Tudo o que este dispositivo mantém para esta conta é eliminado e transferido novamente. O seu correio no servidor mantém-se.",
"clear_confirm_unsent": "{count, plural, one {# mensagem por enviar será perdida.} other {# mensagens por enviar serão perdidas.}}",
"clear_confirm": "Limpar",
"persist_refused": "Este navegador pode eliminar o correio mantido quando o espaço escasseia. Instalar a aplicação costuma evitá-lo.",
"persist_retry": "Perguntar novamente",
"safari_hint": "O Safari elimina os dados de um site após 7 dias sem visitas. Adicione o Bulwark ao Ecrã Principal ou ao Dock para manter o seu correio neste dispositivo.",
"evicted": "O navegador eliminou o correio mantido neste dispositivo. Está a ser transferido novamente.",
"evicted_lost": "{count, plural, one {# mensagem ou alteração por enviar foi perdida.} other {# mensagens ou alterações por enviar foram perdidas.}}",
"evicted_dismiss": "OK",
"storage_full_title": "O armazenamento do dispositivo está cheio.",
"storage_full_text": "O Bulwark deixou de guardar correio aqui. Mantenha menos correio ou limpe esta conta para libertar espaço.",
"open_settings": "Abrir definições",
"default_on": "O Bulwark mantém o seu correio neste dispositivo, para funcionar sem ligação. Pode alterar isto nas Definições.",
"conflict_calendar": "Evento do calendário",
"conflict_contacts": "Contacto",
"kept_files_title": "Ficheiros mantidos offline",
"kept_state_kept": "Neste dispositivo",
"kept_state_waiting": "A aguardar transferência",
"kept_error_tooLarge": "Demasiado grande para manter",
"kept_error_overLimit": "Acima do limite deste dispositivo",
"kept_error_notAllowed": "A sua organização não permite manter ficheiros",
"kept_error_download": "Não foi possível transferir",
"keep_offline": "Manter offline",
"unpin": "Deixar de manter offline",
"unpin_label": "Deixar de manter {name} offline"
},
"deep_link": {
"message_not_found": "Esta mensagem não está mais disponível.",
"thread_not_found": "Esta conversa não está mais disponível.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Modificări făcute offline care nu au fost aplicate: {count}. Căsuța de trimitere arată care.",
"storage_failed": "Acest dispozitiv nu mai are loc pentru e-mailul offline. Eliberați spațiu sau păstrați mai puțin din setări."
},
"offlineStorage": {
"keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate în acest browser. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.",
"window_label": "Cât de departe în urmă se păstrează",
"window_description": "E-mailurile și evenimentele mai vechi dispar de pe acest dispozitiv. Le puteți deschide în continuare când sunteți online.",
"window_days": "{days, plural, one {# zi} few {# zile} other {# de zile}}",
"window_year": "1 an",
"window_everything": "Tot",
"attachments_label": "Păstrează atașamentele",
"attachments_description": "Atașamentele mesajelor păstrate și fișierele păstrate offline, în total până la {total}. Cele mai vechi sunt șterse primele.",
"total_locked": "Organizația dvs. permite cel mult {size} de atașamente și fișiere.",
"attachment_cap_label": "Cel mai mare atașament păstrat",
"usage_mail": "Mesaje și index de căutare",
"usage_attachments": "Atașamente",
"usage_calendar": "Calendar",
"usage_contacts": "Contacte",
"usage_files": "Fișiere păstrate offline",
"usage_row": "{size} ({count, plural, one {# element} few {# elemente} other {# de elemente}})",
"progress_list": "Se listează mesajele",
"progress_bodies": "Se descarcă mesajele",
"progress_threads": "Se sortează conversațiile",
"progress_files": "Se descarcă fișierele",
"progress_other": "Se aduc date pe acest dispozitiv",
"progress_count": "{done} din {total}",
"progress_count_open": "{done} până acum",
"progress_left": "mai durează aproximativ {minutes, plural, one {# minut} few {# minute} other {# de minute}}",
"clear_label": "Golește acest cont",
"clear_description": "Șterge tot ce păstrează acest dispozitiv pentru acest cont, apoi descarcă din nou.",
"clear_button": "Golește…",
"clear_confirm_title": "Goliți acest cont pe acest dispozitiv?",
"clear_confirm_message": "Tot ce păstrează acest dispozitiv pentru acest cont este șters și descărcat din nou. E-mailurile dvs. de pe server rămân.",
"clear_confirm_unsent": "{count, plural, one {# mesaj netrimis se pierde.} few {# mesaje netrimise se pierd.} other {# de mesaje netrimise se pierd.}}",
"clear_confirm": "Golește",
"persist_refused": "Acest browser poate șterge e-mailurile păstrate când se termină spațiul. Instalarea aplicației de obicei previne asta.",
"persist_retry": "Întreabă din nou",
"safari_hint": "Safari șterge datele unui site după 7 zile fără vizite. Adăugați Bulwark pe ecranul principal sau în Dock ca să păstrați e-mailurile pe acest dispozitiv.",
"evicted": "Browserul a șters e-mailurile păstrate pe acest dispozitiv. Se descarcă din nou.",
"evicted_lost": "{count, plural, one {# mesaj sau modificare netrimis s-a pierdut.} few {# mesaje sau modificări netrimise s-au pierdut.} other {# de mesaje sau modificări netrimise s-au pierdut.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Spațiul dispozitivului este plin.",
"storage_full_text": "Bulwark nu mai salvează e-mailuri aici. Păstrați mai puține e-mailuri sau goliți acest cont pentru a face loc.",
"open_settings": "Deschide setările",
"default_on": "Bulwark păstrează e-mailurile pe acest dispozitiv, ca să funcționeze fără conexiune. Puteți schimba asta în Setări.",
"conflict_calendar": "Eveniment din calendar",
"conflict_contacts": "Contact",
"kept_files_title": "Fișiere păstrate offline",
"kept_state_kept": "Pe acest dispozitiv",
"kept_state_waiting": "Așteaptă descărcarea",
"kept_error_tooLarge": "Prea mare pentru a fi păstrat",
"kept_error_overLimit": "Peste limita acestui dispozitiv",
"kept_error_notAllowed": "Organizația dvs. nu permite păstrarea fișierelor",
"kept_error_download": "Nu a putut fi descărcat",
"keep_offline": "Păstrează offline",
"unpin": "Nu mai păstra offline",
"unpin_label": "Nu mai păstra {name} offline"
},
"deep_link": {
"message_not_found": "Acest mesaj nu mai este disponibil.",
"thread_not_found": "Această conversație nu mai este disponibilă.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих.",
"storage_failed": "На этом устройстве не осталось места для офлайн-почты. Освободите место или храните меньше в настройках."
},
"offlineStorage": {
"keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде в этом браузере. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.",
"window_label": "За какой период хранить",
"window_description": "Более старые письма и события календаря удаляются с этого устройства. Их по-прежнему можно открыть, когда есть сеть.",
"window_days": "{days, plural, one {# день} few {# дня} many {# дней} other {# дня}}",
"window_year": "1 год",
"window_everything": "Всё",
"attachments_label": "Хранить вложения",
"attachments_description": "Вложения сохранённых писем и файлы, которые вы храните без сети, всего до {total}. Самые старые удаляются первыми.",
"total_locked": "Ваша организация разрешает не более {size} вложений и файлов.",
"attachment_cap_label": "Самое большое хранимое вложение",
"usage_mail": "Письма и поисковый индекс",
"usage_attachments": "Вложения",
"usage_calendar": "Календарь",
"usage_contacts": "Контакты",
"usage_files": "Файлы, хранимые без сети",
"usage_row": "{size} ({count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}})",
"progress_list": "Составляется список писем",
"progress_bodies": "Загружаются письма",
"progress_threads": "Сортируются цепочки писем",
"progress_files": "Загружаются файлы",
"progress_other": "Данные переносятся на это устройство",
"progress_count": "{done} из {total}",
"progress_count_open": "пока {done}",
"progress_left": "осталось примерно {minutes, plural, one {# минута} few {# минуты} many {# минут} other {# минуты}}",
"clear_label": "Очистить эту учётную запись",
"clear_description": "Удаляет всё, что это устройство хранит для этой учётной записи, и загружает заново.",
"clear_button": "Очистить…",
"clear_confirm_title": "Очистить эту учётную запись на этом устройстве?",
"clear_confirm_message": "Всё, что это устройство хранит для этой учётной записи, будет удалено и загружено заново. Ваша почта на сервере останется.",
"clear_confirm_unsent": "{count, plural, one {# неотправленное письмо будет потеряно.} few {# неотправленных письма будут потеряны.} many {# неотправленных писем будут потеряны.} other {# неотправленного письма будет потеряно.}}",
"clear_confirm": "Очистить",
"persist_refused": "Этот браузер может удалить сохранённую почту, когда не хватает места. Установка приложения обычно это предотвращает.",
"persist_retry": "Спросить снова",
"safari_hint": "Safari удаляет данные сайта, если его не открывали 7 дней. Добавьте Bulwark на экран «Домой» или в Dock, чтобы почта оставалась на этом устройстве.",
"evicted": "Браузер удалил почту, сохранённую на этом устройстве. Она загружается заново.",
"evicted_lost": "{count, plural, one {Потеряно # неотправленное письмо или изменение.} few {Потеряно # неотправленных письма или изменения.} many {Потеряно # неотправленных писем или изменений.} other {Потеряно # неотправленного письма или изменения.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Память устройства заполнена.",
"storage_full_text": "Bulwark больше не сохраняет здесь почту. Храните меньше писем или очистите эту учётную запись, чтобы освободить место.",
"open_settings": "Открыть настройки",
"default_on": "Bulwark хранит вашу почту на этом устройстве, чтобы она работала без сети. Это можно изменить в настройках.",
"conflict_calendar": "Событие календаря",
"conflict_contacts": "Контакт",
"kept_files_title": "Файлы, хранимые без сети",
"kept_state_kept": "На этом устройстве",
"kept_state_waiting": "Ожидает загрузки",
"kept_error_tooLarge": "Слишком большой для хранения",
"kept_error_overLimit": "Превышен лимит этого устройства",
"kept_error_notAllowed": "Ваша организация не разрешает хранить файлы",
"kept_error_download": "Не удалось загрузить",
"keep_offline": "Хранить без сети",
"unpin": "Больше не хранить без сети",
"unpin_label": "Больше не хранить {name} без сети"
},
"deep_link": {
"message_not_found": "Это письмо больше недоступно.",
"thread_not_found": "Эта переписка больше недоступна.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Nepoužité zmeny urobené offline: {count}. Ktoré to sú, ukazuje pošta na odoslanie.",
"storage_failed": "V tomto zariadení už nie je miesto na poštu offline. Uvoľnite miesto alebo v nastaveniach ponechajte menej."
},
"offlineStorage": {
"keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto prehliadači. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.",
"window_label": "Ako dlho do minulosti uchovávať",
"window_description": "Staršia pošta a udalosti z kalendára toto zariadenie opustia. Keď budete online, môžete ich stále otvoriť.",
"window_days": "{days, plural, one {# deň} few {# dni} many {# dňa} other {# dní}}",
"window_year": "1 rok",
"window_everything": "Všetko",
"attachments_label": "Uchovávať prílohy",
"attachments_description": "Prílohy uchovaných správ a súbory, ktoré uchovávate offline, spolu až {total}. Najstaršie sa odstránia ako prvé.",
"total_locked": "Vaša organizácia povoľuje najviac {size} príloh a súborov.",
"attachment_cap_label": "Najväčšia uchovávaná príloha",
"usage_mail": "Správy a index vyhľadávania",
"usage_attachments": "Prílohy",
"usage_calendar": "Kalendár",
"usage_contacts": "Kontakty",
"usage_files": "Súbory uchované offline",
"usage_row": "{size} ({count, plural, one {# položka} few {# položky} many {# položky} other {# položiek}})",
"progress_list": "Vypisujú sa správy",
"progress_bodies": "Sťahujú sa správy",
"progress_threads": "Radia sa konverzácie",
"progress_files": "Sťahujú sa súbory",
"progress_other": "Prenášajú sa dáta do tohto zariadenia",
"progress_count": "{done} z {total}",
"progress_count_open": "zatiaľ {done}",
"progress_left": "zostáva asi {minutes, plural, one {# minúta} few {# minúty} many {# minúty} other {# minút}}",
"clear_label": "Vymazať tento účet",
"clear_description": "Zmaže všetko, čo toto zariadenie pre tento účet uchováva, a stiahne to znova.",
"clear_button": "Vymazať…",
"clear_confirm_title": "Vymazať tento účet na tomto zariadení?",
"clear_confirm_message": "Všetko, čo toto zariadenie pre tento účet uchováva, sa zmaže a stiahne znova. Vaša pošta na serveri zostane.",
"clear_confirm_unsent": "{count, plural, one {# neodoslaná správa sa stratí.} few {# neodoslané správy sa stratia.} many {# neodoslanej správy sa stratí.} other {# neodoslaných správ sa stratí.}}",
"clear_confirm": "Vymazať",
"persist_refused": "Tento prehliadač môže uchovanú poštu zmazať, keď dôjde miesto. Inštalácia aplikácie tomu zvyčajne zabráni.",
"persist_retry": "Opýtať sa znova",
"safari_hint": "Safari zmaže dáta webu po 7 dňoch bez návštevy. Pridajte Bulwark na plochu alebo do Docku, aby vaša pošta na tomto zariadení zostala.",
"evicted": "Prehliadač zmazal poštu uchovanú na tomto zariadení. Sťahuje sa znova.",
"evicted_lost": "{count, plural, one {# neodoslaná správa alebo zmena sa stratila.} few {# neodoslané správy alebo zmeny sa stratili.} many {# neodoslanej správy alebo zmeny sa stratilo.} other {# neodoslaných správ alebo zmien sa stratilo.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Úložisko zariadenia je plné.",
"storage_full_text": "Bulwark sem prestal ukladať poštu. Uchovávajte menej pošty alebo vymažte tento účet, aby sa uvoľnilo miesto.",
"open_settings": "Otvoriť nastavenia",
"default_on": "Bulwark uchováva vašu poštu na tomto zariadení, aby fungoval aj bez pripojenia. Môžete to zmeniť v Nastaveniach.",
"conflict_calendar": "Udalosť v kalendári",
"conflict_contacts": "Kontakt",
"kept_files_title": "Súbory uchované offline",
"kept_state_kept": "Na tomto zariadení",
"kept_state_waiting": "Čaká na stiahnutie",
"kept_error_tooLarge": "Príliš veľký na uchovanie",
"kept_error_overLimit": "Nad limit tohto zariadenia",
"kept_error_notAllowed": "Vaša organizácia uchovávanie súborov nepovoľuje",
"kept_error_download": "Nepodarilo sa stiahnuť",
"keep_offline": "Uchovať offline",
"unpin": "Prestať uchovávať offline",
"unpin_label": "Prestať uchovávať {name} offline"
},
"deep_link": {
"message_not_found": "Táto správa už nie je dostupná.",
"thread_not_found": "Táto konverzácia už nie je dostupná.",
+55
View File
@@ -2101,6 +2101,61 @@
"toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก",
"storage_failed": "อุปกรณ์นี้ไม่มีที่ว่างสำหรับอีเมลออฟไลน์แล้ว โปรดเพิ่มพื้นที่ว่างหรือเก็บให้น้อยลงในการตั้งค่า"
},
"offlineStorage": {
"keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในเบราว์เซอร์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ",
"window_label": "เก็บย้อนหลังนานแค่ไหน",
"window_description": "อีเมลและกิจกรรมในปฏิทินที่เก่ากว่านี้จะถูกนำออกจากอุปกรณ์นี้ คุณยังเปิดดูได้เมื่อออนไลน์",
"window_days": "{days, plural, other {# วัน}}",
"window_year": "1 ปี",
"window_everything": "ทั้งหมด",
"attachments_label": "เก็บไฟล์แนบ",
"attachments_description": "ไฟล์แนบของข้อความที่เก็บไว้และไฟล์ที่เก็บไว้ใช้ออฟไลน์ รวมกันไม่เกิน {total} ของที่เก่าที่สุดจะถูกลบก่อน",
"total_locked": "องค์กรของคุณอนุญาตไฟล์แนบและไฟล์รวมกันไม่เกิน {size}",
"attachment_cap_label": "ไฟล์แนบที่ใหญ่ที่สุดที่จะเก็บ",
"usage_mail": "ข้อความและดัชนีการค้นหา",
"usage_attachments": "ไฟล์แนบ",
"usage_calendar": "ปฏิทิน",
"usage_contacts": "รายชื่อติดต่อ",
"usage_files": "ไฟล์ที่เก็บไว้ใช้ออฟไลน์",
"usage_row": "{size} ({count, plural, other {# รายการ}})",
"progress_list": "กำลังแสดงรายการข้อความ",
"progress_bodies": "กำลังดาวน์โหลดข้อความ",
"progress_threads": "กำลังเรียงลำดับการสนทนา",
"progress_files": "กำลังดาวน์โหลดไฟล์",
"progress_other": "กำลังนำข้อมูลมาไว้ที่อุปกรณ์นี้",
"progress_count": "{done} จาก {total}",
"progress_count_open": "ตอนนี้ {done}",
"progress_left": "เหลือประมาณ {minutes, plural, other {# นาที}}",
"clear_label": "ล้างบัญชีนี้",
"clear_description": "ลบทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้ แล้วดาวน์โหลดใหม่",
"clear_button": "ล้าง…",
"clear_confirm_title": "ล้างบัญชีนี้บนอุปกรณ์นี้ไหม",
"clear_confirm_message": "ทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้จะถูกลบและดาวน์โหลดใหม่ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่",
"clear_confirm_unsent": "{count, plural, other {ข้อความที่ยังไม่ได้ส่ง # ข้อความจะสูญหาย}}",
"clear_confirm": "ล้าง",
"persist_refused": "เบราว์เซอร์นี้อาจลบอีเมลที่เก็บไว้เมื่อพื้นที่เหลือน้อย การติดตั้งแอปมักช่วยป้องกันได้",
"persist_retry": "ขออีกครั้ง",
"safari_hint": "Safari จะลบข้อมูลของเว็บไซต์หลังจากไม่ได้เข้าชม 7 วัน เพิ่ม Bulwark ลงในหน้าจอโฮมหรือ Dock เพื่อเก็บอีเมลไว้บนอุปกรณ์นี้",
"evicted": "เบราว์เซอร์ลบอีเมลที่เก็บไว้บนอุปกรณ์นี้แล้ว กำลังดาวน์โหลดใหม่",
"evicted_lost": "{count, plural, other {ข้อความหรือการเปลี่ยนแปลงที่ยังไม่ได้ส่ง # รายการสูญหาย}}",
"evicted_dismiss": "ตกลง",
"storage_full_title": "พื้นที่เก็บข้อมูลของอุปกรณ์เต็ม",
"storage_full_text": "Bulwark หยุดบันทึกอีเมลที่นี่แล้ว เก็บอีเมลให้น้อยลงหรือล้างบัญชีนี้เพื่อเพิ่มพื้นที่",
"open_settings": "เปิดการตั้งค่า",
"default_on": "Bulwark เก็บอีเมลของคุณไว้บนอุปกรณ์นี้ เพื่อให้ใช้งานได้โดยไม่ต้องเชื่อมต่อ คุณเปลี่ยนได้ในการตั้งค่า",
"conflict_calendar": "กิจกรรมในปฏิทิน",
"conflict_contacts": "รายชื่อติดต่อ",
"kept_files_title": "ไฟล์ที่เก็บไว้ใช้ออฟไลน์",
"kept_state_kept": "บนอุปกรณ์นี้",
"kept_state_waiting": "รอดาวน์โหลด",
"kept_error_tooLarge": "ใหญ่เกินกว่าจะเก็บ",
"kept_error_overLimit": "เกินขีดจำกัดของอุปกรณ์นี้",
"kept_error_notAllowed": "องค์กรของคุณไม่อนุญาตให้เก็บไฟล์",
"kept_error_download": "ดาวน์โหลดไม่ได้",
"keep_offline": "เก็บไว้ใช้ออฟไลน์",
"unpin": "เลิกเก็บไว้ใช้ออฟไลน์",
"unpin_label": "เลิกเก็บ {name} ไว้ใช้ออฟไลน์"
},
"plugin_pages": {
"loading": "กำลังโหลดหน้าเว็บ...",
"not_available_title": "หน้านี้ไม่พร้อมใช้งาน",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Çevrimdışı yapılan ve uygulanmayan değişiklikler: {count}. Giden kutusu hangileri olduğunu gösterir.",
"storage_failed": "Bu cihazda çevrimdışı e-posta için yer kalmadı. Yer açın veya Ayarlar'dan daha azını saklayın."
},
"offlineStorage": {
"keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu tarayıcıda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.",
"window_label": "Ne kadar geriye tutulsun",
"window_description": "Daha eski e-postalar ve takvim etkinlikleri bu cihazdan kaldırılır. Bağlantınız olduğunda yine açabilirsiniz.",
"window_days": "{days, plural, one {# gün} other {# gün}}",
"window_year": "1 yıl",
"window_everything": "Hepsi",
"attachments_label": "Ekleri tut",
"attachments_description": "Tutulan iletilerin ekleri ve çevrimdışı tuttuğunuz dosyalar, toplam en fazla {total}. En eskiler önce silinir.",
"total_locked": "Kuruluşunuz en fazla {size} ek ve dosyaya izin veriyor.",
"attachment_cap_label": "Tutulacak en büyük ek",
"usage_mail": "İletiler ve arama dizini",
"usage_attachments": "Ekler",
"usage_calendar": "Takvim",
"usage_contacts": "Kişiler",
"usage_files": "Çevrimdışı tutulan dosyalar",
"usage_row": "{size} ({count, plural, one {# öğe} other {# öğe}})",
"progress_list": "İletiler listeleniyor",
"progress_bodies": "İletiler indiriliyor",
"progress_threads": "Konuşmalar sıralanıyor",
"progress_files": "Dosyalar indiriliyor",
"progress_other": "Veriler bu cihaza getiriliyor",
"progress_count": "{done} / {total}",
"progress_count_open": "şimdiye kadar {done}",
"progress_left": "yaklaşık {minutes, plural, one {# dakika} other {# dakika}} kaldı",
"clear_label": "Bu hesabı temizle",
"clear_description": "Bu cihazın bu hesap için tuttuğu her şeyi siler, sonra yeniden indirir.",
"clear_button": "Temizle…",
"clear_confirm_title": "Bu hesap bu cihazda temizlensin mi?",
"clear_confirm_message": "Bu cihazın bu hesap için tuttuğu her şey silinir ve yeniden indirilir. Sunucudaki e-postalarınız kalır.",
"clear_confirm_unsent": "{count, plural, one {# gönderilmemiş ileti kaybolacak.} other {# gönderilmemiş ileti kaybolacak.}}",
"clear_confirm": "Temizle",
"persist_refused": "Alan azaldığında bu tarayıcı tutulan e-postaları silebilir. Uygulamayı yüklemek genellikle bunu önler.",
"persist_retry": "Yeniden sor",
"safari_hint": "Safari, 7 gün ziyaret edilmeyen bir sitenin verilerini siler. E-postalarınızı bu cihazda tutmak için Bulwark'ı Ana Ekran'a veya Dock'a ekleyin.",
"evicted": "Tarayıcı, bu cihazda tutulan e-postaları sildi. Yeniden indiriliyor.",
"evicted_lost": "{count, plural, one {# gönderilmemiş ileti veya değişiklik kayboldu.} other {# gönderilmemiş ileti veya değişiklik kayboldu.}}",
"evicted_dismiss": "Tamam",
"storage_full_title": "Cihaz depolama alanı dolu.",
"storage_full_text": "Bulwark artık buraya e-posta kaydetmiyor. Daha az e-posta tutun veya yer açmak için bu hesabı temizleyin.",
"open_settings": "Ayarları aç",
"default_on": "Bulwark, bağlantı olmadan da çalışması için e-postalarınızı bu cihazda tutar. Bunu Ayarlar'dan değiştirebilirsiniz.",
"conflict_calendar": "Takvim etkinliği",
"conflict_contacts": "Kişi",
"kept_files_title": "Çevrimdışı tutulan dosyalar",
"kept_state_kept": "Bu cihazda",
"kept_state_waiting": "İndirilmeyi bekliyor",
"kept_error_tooLarge": "Tutulamayacak kadar büyük",
"kept_error_overLimit": "Bu cihazın sınırını aşıyor",
"kept_error_notAllowed": "Kuruluşunuz dosyaların tutulmasına izin vermiyor",
"kept_error_download": "İndirilemedi",
"keep_offline": "Çevrimdışı tut",
"unpin": "Çevrimdışı tutmayı bırak",
"unpin_label": "{name} dosyasını çevrimdışı tutmayı bırak"
},
"deep_link": {
"message_not_found": "Bu ileti artık kullanılamıyor.",
"thread_not_found": "Bu sohbet artık kullanılamıyor.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних.",
"storage_failed": "На цьому пристрої не залишилося місця для офлайн-пошти. Звільніть місце або зберігайте менше в налаштуваннях."
},
"offlineStorage": {
"keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді в цьому браузері. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.",
"window_label": "За який період зберігати",
"window_description": "Старіші листи й події календаря видаляються з цього пристрою. Їх і надалі можна відкрити, коли є мережа.",
"window_days": "{days, plural, one {# день} few {# дні} many {# днів} other {# дня}}",
"window_year": "1 рік",
"window_everything": "Усе",
"attachments_label": "Зберігати вкладення",
"attachments_description": "Вкладення збережених листів і файли, які ви зберігаєте без мережі, разом до {total}. Найстаріші видаляються першими.",
"total_locked": "Ваша організація дозволяє щонайбільше {size} вкладень і файлів.",
"attachment_cap_label": "Найбільше вкладення, яке зберігається",
"usage_mail": "Листи та пошуковий індекс",
"usage_attachments": "Вкладення",
"usage_calendar": "Календар",
"usage_contacts": "Контакти",
"usage_files": "Файли, збережені без мережі",
"usage_row": "{size} ({count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елемента}})",
"progress_list": "Складається список листів",
"progress_bodies": "Завантажуються листи",
"progress_threads": "Сортуються розмови",
"progress_files": "Завантажуються файли",
"progress_other": "Дані переносяться на цей пристрій",
"progress_count": "{done} із {total}",
"progress_count_open": "поки {done}",
"progress_left": "залишилося приблизно {minutes, plural, one {# хвилина} few {# хвилини} many {# хвилин} other {# хвилини}}",
"clear_label": "Очистити цей обліковий запис",
"clear_description": "Видаляє все, що цей пристрій зберігає для цього облікового запису, і завантажує знову.",
"clear_button": "Очистити…",
"clear_confirm_title": "Очистити цей обліковий запис на цьому пристрої?",
"clear_confirm_message": "Усе, що цей пристрій зберігає для цього облікового запису, буде видалено й завантажено знову. Ваша пошта на сервері залишиться.",
"clear_confirm_unsent": "{count, plural, one {# невідправлений лист буде втрачено.} few {# невідправлені листи буде втрачено.} many {# невідправлених листів буде втрачено.} other {# невідправленого листа буде втрачено.}}",
"clear_confirm": "Очистити",
"persist_refused": "Цей браузер може видалити збережену пошту, коли бракує місця. Встановлення застосунку зазвичай цьому запобігає.",
"persist_retry": "Запитати знову",
"safari_hint": "Safari видаляє дані сайту, якщо його не відвідували 7 днів. Додайте Bulwark на екран «Додому» або в Dock, щоб пошта залишалася на цьому пристрої.",
"evicted": "Браузер видалив пошту, збережену на цьому пристрої. Вона завантажується знову.",
"evicted_lost": "{count, plural, one {Втрачено # невідправлений лист або зміну.} few {Втрачено # невідправлені листи або зміни.} many {Втрачено # невідправлених листів або змін.} other {Втрачено # невідправленого листа або зміни.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Пам'ять пристрою заповнена.",
"storage_full_text": "Bulwark більше не зберігає тут пошту. Зберігайте менше листів або очистьте цей обліковий запис, щоб звільнити місце.",
"open_settings": "Відкрити налаштування",
"default_on": "Bulwark зберігає вашу пошту на цьому пристрої, щоб вона працювала без мережі. Це можна змінити в налаштуваннях.",
"conflict_calendar": "Подія календаря",
"conflict_contacts": "Контакт",
"kept_files_title": "Файли, збережені без мережі",
"kept_state_kept": "На цьому пристрої",
"kept_state_waiting": "Очікує завантаження",
"kept_error_tooLarge": "Завеликий для зберігання",
"kept_error_overLimit": "Перевищено ліміт цього пристрою",
"kept_error_notAllowed": "Ваша організація не дозволяє зберігати файли",
"kept_error_download": "Не вдалося завантажити",
"keep_offline": "Зберігати без мережі",
"unpin": "Більше не зберігати без мережі",
"unpin_label": "Більше не зберігати {name} без мережі"
},
"deep_link": {
"message_not_found": "Цей лист більше недоступний.",
"thread_not_found": "Ця розмова більше недоступна.",
+55
View File
@@ -5768,6 +5768,61 @@
"toast": "Thay đổi khi ngoại tuyến chưa được áp dụng: {count}. Hộp thư đi cho biết là những thay đổi nào.",
"storage_failed": "Thiết bị này không còn chỗ cho thư ngoại tuyến. Hãy giải phóng dung lượng hoặc giữ ít hơn trong Cài đặt."
},
"offlineStorage": {
"keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trong trình duyệt này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.",
"window_label": "Giữ lại bao lâu",
"window_description": "Thư và sự kiện lịch cũ hơn sẽ bị xóa khỏi thiết bị này. Bạn vẫn có thể mở chúng khi có kết nối.",
"window_days": "{days, plural, other {# ngày}}",
"window_year": "1 năm",
"window_everything": "Tất cả",
"attachments_label": "Giữ tệp đính kèm",
"attachments_description": "Tệp đính kèm của các thư được giữ và các tệp bạn giữ ngoại tuyến, tối đa {total} tổng cộng. Tệp cũ nhất sẽ bị xóa trước.",
"total_locked": "Tổ chức của bạn chỉ cho phép tối đa {size} tệp đính kèm và tệp.",
"attachment_cap_label": "Tệp đính kèm lớn nhất được giữ",
"usage_mail": "Thư và chỉ mục tìm kiếm",
"usage_attachments": "Tệp đính kèm",
"usage_calendar": "Lịch",
"usage_contacts": "Danh bạ",
"usage_files": "Tệp giữ ngoại tuyến",
"usage_row": "{size} ({count, plural, other {# mục}})",
"progress_list": "Đang liệt kê thư",
"progress_bodies": "Đang tải thư xuống",
"progress_threads": "Đang sắp xếp cuộc trò chuyện",
"progress_files": "Đang tải tệp xuống",
"progress_other": "Đang đưa dữ liệu về thiết bị này",
"progress_count": "{done} trên {total}",
"progress_count_open": "đến nay {done}",
"progress_left": "còn khoảng {minutes, plural, other {# phút}}",
"clear_label": "Xóa tài khoản này",
"clear_description": "Xóa mọi thứ thiết bị này giữ cho tài khoản này, rồi tải lại.",
"clear_button": "Xóa…",
"clear_confirm_title": "Xóa tài khoản này trên thiết bị này?",
"clear_confirm_message": "Mọi thứ thiết bị này giữ cho tài khoản này sẽ bị xóa và tải lại. Thư của bạn trên máy chủ vẫn còn.",
"clear_confirm_unsent": "{count, plural, other {# thư chưa gửi sẽ bị mất.}}",
"clear_confirm": "Xóa",
"persist_refused": "Trình duyệt này có thể xóa thư đã giữ khi sắp hết dung lượng. Cài đặt ứng dụng thường giúp tránh điều này.",
"persist_retry": "Hỏi lại",
"safari_hint": "Safari xóa dữ liệu của một trang web sau 7 ngày không truy cập. Hãy thêm Bulwark vào Màn hình chính hoặc Dock để giữ thư trên thiết bị này.",
"evicted": "Trình duyệt đã xóa thư được giữ trên thiết bị này. Đang tải lại.",
"evicted_lost": "{count, plural, other {# thư hoặc thay đổi chưa gửi đã bị mất.}}",
"evicted_dismiss": "OK",
"storage_full_title": "Bộ nhớ thiết bị đã đầy.",
"storage_full_text": "Bulwark đã ngừng lưu thư ở đây. Hãy giữ ít thư hơn hoặc xóa tài khoản này để có thêm chỗ.",
"open_settings": "Mở cài đặt",
"default_on": "Bulwark giữ thư của bạn trên thiết bị này để dùng được khi không có kết nối. Bạn có thể đổi điều này trong Cài đặt.",
"conflict_calendar": "Sự kiện lịch",
"conflict_contacts": "Liên hệ",
"kept_files_title": "Tệp giữ ngoại tuyến",
"kept_state_kept": "Trên thiết bị này",
"kept_state_waiting": "Đang chờ tải xuống",
"kept_error_tooLarge": "Quá lớn để giữ",
"kept_error_overLimit": "Vượt giới hạn của thiết bị này",
"kept_error_notAllowed": "Tổ chức của bạn không cho phép giữ tệp",
"kept_error_download": "Không tải xuống được",
"keep_offline": "Giữ ngoại tuyến",
"unpin": "Ngừng giữ ngoại tuyến",
"unpin_label": "Ngừng giữ {name} ngoại tuyến"
},
"deep_link": {
"message_not_found": "Thư này không còn nữa.",
"thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。",
"storage_failed": "此裝置已沒有空間存放離線郵件。請釋出空間,或在設定中減少保留的內容。"
},
"offlineStorage": {
"keep_description": "在此瀏覽器中加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。",
"window_label": "保留多久以內的內容",
"window_description": "更早的郵件和行事曆活動會從此裝置移除。連線時你仍可以開啟它們。",
"window_days": "{days, plural, other {# 天}}",
"window_year": "1 年",
"window_everything": "全部",
"attachments_label": "保留附件",
"attachments_description": "已保留郵件的附件和你離線保留的檔案,總共最多 {total}。最舊的先刪除。",
"total_locked": "你的組織最多允許 {size} 的附件和檔案。",
"attachment_cap_label": "保留的最大附件",
"usage_mail": "郵件和搜尋索引",
"usage_attachments": "附件",
"usage_calendar": "行事曆",
"usage_contacts": "聯絡人",
"usage_files": "離線保留的檔案",
"usage_row": "{size}({count, plural, other {# 項}})",
"progress_list": "正在列出郵件",
"progress_bodies": "正在下載郵件",
"progress_threads": "正在整理對話",
"progress_files": "正在下載檔案",
"progress_other": "正在把資料帶到此裝置",
"progress_count": "{done} / {total}",
"progress_count_open": "已完成 {done}",
"progress_left": "還剩約 {minutes, plural, other {# 分鐘}}",
"clear_label": "清除此帳號",
"clear_description": "刪除此裝置為該帳號保留的全部內容,然後重新下載。",
"clear_button": "清除…",
"clear_confirm_title": "在此裝置上清除該帳號?",
"clear_confirm_message": "此裝置為該帳號保留的全部內容會被刪除並重新下載。伺服器上的郵件不受影響。",
"clear_confirm_unsent": "{count, plural, other {# 封未寄出的郵件將會遺失。}}",
"clear_confirm": "清除",
"persist_refused": "儲存空間不足時,此瀏覽器可能會刪除保留的郵件。安裝應用程式通常可以避免這種情況。",
"persist_retry": "再次請求",
"safari_hint": "Safari 會在網站 7 天未造訪後刪除其資料。把 Bulwark 加入主畫面或 Dock,郵件就能保留在此裝置上。",
"evicted": "瀏覽器刪除了此裝置上保留的郵件。正在重新下載。",
"evicted_lost": "{count, plural, other {# 封未寄出的郵件或變更已遺失。}}",
"evicted_dismiss": "好",
"storage_full_title": "裝置儲存空間已滿。",
"storage_full_text": "Bulwark 已停止在此儲存郵件。請少保留一些郵件,或清除此帳號來騰出空間。",
"open_settings": "開啟設定",
"default_on": "Bulwark 會把你的郵件保留在此裝置上,這樣沒有網路也能使用。你可以在設定中變更。",
"conflict_calendar": "行事曆活動",
"conflict_contacts": "聯絡人",
"kept_files_title": "離線保留的檔案",
"kept_state_kept": "在此裝置上",
"kept_state_waiting": "等待下載",
"kept_error_tooLarge": "太大,無法保留",
"kept_error_overLimit": "超出此裝置的限制",
"kept_error_notAllowed": "你的組織不允許保留檔案",
"kept_error_download": "無法下載",
"keep_offline": "離線保留",
"unpin": "不再離線保留",
"unpin_label": "不再離線保留 {name}"
},
"deep_link": {
"message_not_found": "已無法使用此郵件。",
"thread_not_found": "已無法使用此對話串。",
+55
View File
@@ -5756,6 +5756,61 @@
"toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。",
"storage_failed": "此设备已没有空间存放离线邮件。请释放空间,或在设置中减少保留的内容。"
},
"offlineStorage": {
"keep_description": "在此浏览器中加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。",
"window_label": "保留多久以内的内容",
"window_description": "更早的邮件和日历事件会从此设备中移除。联网时你仍可以打开它们。",
"window_days": "{days, plural, other {# 天}}",
"window_year": "1 年",
"window_everything": "全部",
"attachments_label": "保留附件",
"attachments_description": "已保留邮件的附件和你离线保留的文件,总共最多 {total}。最旧的先删除。",
"total_locked": "你的组织最多允许 {size} 的附件和文件。",
"attachment_cap_label": "保留的最大附件",
"usage_mail": "邮件和搜索索引",
"usage_attachments": "附件",
"usage_calendar": "日历",
"usage_contacts": "联系人",
"usage_files": "离线保留的文件",
"usage_row": "{size}({count, plural, other {# 项}})",
"progress_list": "正在列出邮件",
"progress_bodies": "正在下载邮件",
"progress_threads": "正在整理会话",
"progress_files": "正在下载文件",
"progress_other": "正在把数据带到此设备",
"progress_count": "{done} / {total}",
"progress_count_open": "已完成 {done}",
"progress_left": "还剩约 {minutes, plural, other {# 分钟}}",
"clear_label": "清除此账户",
"clear_description": "删除此设备为该账户保留的全部内容,然后重新下载。",
"clear_button": "清除…",
"clear_confirm_title": "在此设备上清除该账户?",
"clear_confirm_message": "此设备为该账户保留的全部内容会被删除并重新下载。服务器上的邮件不受影响。",
"clear_confirm_unsent": "{count, plural, other {# 封未发送的邮件将丢失。}}",
"clear_confirm": "清除",
"persist_refused": "存储空间不足时,此浏览器可能会删除保留的邮件。安装应用通常可以避免这种情况。",
"persist_retry": "再次请求",
"safari_hint": "Safari 会在网站 7 天未访问后删除其数据。把 Bulwark 添加到主屏幕或程序坞,邮件就能保留在此设备上。",
"evicted": "浏览器删除了此设备上保留的邮件。正在重新下载。",
"evicted_lost": "{count, plural, other {# 封未发送的邮件或更改已丢失。}}",
"evicted_dismiss": "好",
"storage_full_title": "设备存储空间已满。",
"storage_full_text": "Bulwark 已停止在此保存邮件。请少保留一些邮件,或清除此账户来腾出空间。",
"open_settings": "打开设置",
"default_on": "Bulwark 会把你的邮件保留在此设备上,这样没有网络也能使用。你可以在设置中更改。",
"conflict_calendar": "日历事件",
"conflict_contacts": "联系人",
"kept_files_title": "离线保留的文件",
"kept_state_kept": "在此设备上",
"kept_state_waiting": "等待下载",
"kept_error_tooLarge": "太大,无法保留",
"kept_error_overLimit": "超出此设备的限制",
"kept_error_notAllowed": "你的组织不允许保留文件",
"kept_error_download": "无法下载",
"keep_offline": "离线保留",
"unpin": "不再离线保留",
"unpin_label": "不再离线保留 {name}"
},
"deep_link": {
"message_not_found": "此邮件已不可用。",
"thread_not_found": "此会话已不可用。",