mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-ux-p2-settings-misc' into feat/do-ux-p2-settings-fix
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import React from 'react';
|
||||
|
||||
import { RichTextEditor } from '../rich-text-editor';
|
||||
|
||||
// settings-p2-19: a three-line out-of-office reply doesn't need the
|
||||
// composer's headings, code block and table. The short toolbar keeps bold,
|
||||
// italic, underline, the lists and link.
|
||||
|
||||
async function setup(toolbar?: 'full' | 'short') {
|
||||
let ready = false;
|
||||
render(<RichTextEditor content="<p>Hello</p>" onChange={() => {}} toolbar={toolbar} onEditorReady={() => { ready = true; }} />);
|
||||
await waitFor(() => expect(ready).toBe(true));
|
||||
return Array.from(screen.getByRole('toolbar', { name: 'formatting' }).querySelectorAll<HTMLElement>('[data-toolbar-item]'))
|
||||
.map((el) => el.dataset.toolbarItem);
|
||||
}
|
||||
|
||||
describe('the short formatting toolbar', () => {
|
||||
it('has only bold, italic, underline, the lists and link', async () => {
|
||||
expect(await setup('short')).toEqual(['bold', 'italic', 'underline', 'bullet-list', 'ordered-list', 'link']);
|
||||
});
|
||||
|
||||
it('leaves the full toolbar as it was', async () => {
|
||||
const items = await setup();
|
||||
for (const name of ['font-size', 'heading-1', 'code-block', 'table', 'text-color', 'undo']) expect(items).toContain(name);
|
||||
});
|
||||
});
|
||||
@@ -178,6 +178,11 @@ interface RichTextEditorProps {
|
||||
* away entirely.
|
||||
*/
|
||||
toolbarPlacement?: 'top' | 'bottom' | 'hidden';
|
||||
/**
|
||||
* "short" keeps bold, italic, underline, the lists and link: enough for a
|
||||
* few lines such as an out-of-office reply. "full" is the composer's.
|
||||
*/
|
||||
toolbar?: 'full' | 'short';
|
||||
/** The editing area's accessible name ("Message"). */
|
||||
ariaLabel?: string;
|
||||
/** The body failed validation: exposed as aria-invalid on the editing area. */
|
||||
@@ -478,6 +483,7 @@ export function RichTextEditor({
|
||||
onMentionAdd,
|
||||
collapseToolbarOnMobile = false,
|
||||
toolbarPlacement = 'top',
|
||||
toolbar = 'full',
|
||||
ariaLabel,
|
||||
ariaInvalid,
|
||||
ariaDescribedBy,
|
||||
@@ -911,7 +917,9 @@ export function RichTextEditor({
|
||||
// A group that doesn't fit renders in the "More formatting" panel instead, in row order.
|
||||
const folded = new Set<FoldableGroup>(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id));
|
||||
const overflow: React.ReactNode[] = [];
|
||||
const short = toolbar === 'short';
|
||||
const place = (group: FoldableGroup, node: React.ReactNode) => {
|
||||
if (short) return null;
|
||||
if (!folded.has(group)) return node;
|
||||
overflow.push(<React.Fragment key={group}>{node}</React.Fragment>);
|
||||
return null;
|
||||
@@ -1016,6 +1024,7 @@ export function RichTextEditor({
|
||||
>
|
||||
{/* Font size - two baseline-aligned A's read as "size" at a glance;
|
||||
once a size is chosen the button shows that number instead. */}
|
||||
{!short && <>
|
||||
<div ref={fontSizeWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
ref={fontSizeButtonRef}
|
||||
@@ -1081,6 +1090,7 @@ export function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<ToolbarSeparator />
|
||||
</>}
|
||||
|
||||
<ToolbarButton
|
||||
item="bold"
|
||||
@@ -1106,6 +1116,7 @@ export function RichTextEditor({
|
||||
>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{!short && <>
|
||||
<ToolbarButton
|
||||
item="strike"
|
||||
pressed={editor.isActive("strike")}
|
||||
@@ -1199,6 +1210,7 @@ export function RichTextEditor({
|
||||
</ToolbarPopover>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { sortFolderTree } from "@/lib/move-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -41,6 +41,16 @@ interface FilterRuleModalProps {
|
||||
suggestions?: RuleSuggestion[];
|
||||
/** Offer "Also apply to existing messages in this folder". */
|
||||
offerApplyToExisting?: boolean;
|
||||
/**
|
||||
* The folder the offer is about, by name ("Inbox"), when the dialog isn't
|
||||
* opened from that folder (Settings › Filters).
|
||||
*/
|
||||
applyExistingFolder?: string;
|
||||
/**
|
||||
* How many messages in that folder the rule as typed would change. The
|
||||
* offer says the number, and is greyed when it is 0.
|
||||
*/
|
||||
countExisting?: (rule: FilterRule) => Promise<number>;
|
||||
mailboxes: Mailbox[];
|
||||
/** Server cap on redirects per message (Sieve `maxNumberRedirects`). */
|
||||
maxRedirects?: number | null;
|
||||
@@ -198,6 +208,8 @@ export function FilterRuleModal({
|
||||
initialRule,
|
||||
suggestions = [],
|
||||
offerApplyToExisting = false,
|
||||
applyExistingFolder,
|
||||
countExisting,
|
||||
mailboxes,
|
||||
maxRedirects,
|
||||
forwardsBefore = 0,
|
||||
@@ -345,6 +357,30 @@ export function FilterRuleModal({
|
||||
return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok;
|
||||
}, [offerApplyToExisting, validConditions, validActions, periodOn]);
|
||||
|
||||
// How many messages the rule would change there, asked of the server a
|
||||
// moment after the conditions and actions stop changing. null: not known
|
||||
// (still asking, no action yet, or the server didn't answer).
|
||||
const [existingCount, setExistingCount] = useState<number | null>(null);
|
||||
const countKey = canApplyToExisting && countExisting && validActions.length > 0
|
||||
? JSON.stringify([matchType, validConditions, validActions])
|
||||
: null;
|
||||
const countRef = useRef(countExisting);
|
||||
countRef.current = countExisting;
|
||||
useEffect(() => {
|
||||
setExistingCount(null);
|
||||
if (!countKey || !countRef.current) return;
|
||||
const [type, conds, acts] = JSON.parse(countKey) as [FilterRule["matchType"], FilterCondition[], FilterAction[]];
|
||||
let stale = false;
|
||||
const timer = setTimeout(() => {
|
||||
countRef.current!({ id: "count", name: "", enabled: true, matchType: type, conditions: conds, actions: acts, stopProcessing: false })
|
||||
.then((count) => { if (!stale) setExistingCount(count); })
|
||||
.catch(() => { /* the offer stays, without a number */ });
|
||||
}, 400);
|
||||
return () => { stale = true; clearTimeout(timer); };
|
||||
}, [countKey]);
|
||||
const noneToApply = existingCount === 0;
|
||||
const canTickApply = canApplyToExisting && !noneToApply;
|
||||
|
||||
// What keeps the rule from being saved, said under the part it is about
|
||||
// once Save was pressed, and updated as the user fixes it.
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
@@ -427,8 +463,8 @@ export function FilterRuleModal({
|
||||
includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined,
|
||||
activeFrom: activeFrom ?? undefined,
|
||||
activeUntil: activeUntil ?? undefined,
|
||||
}, { applyToExisting: applyToExisting && canApplyToExisting });
|
||||
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canApplyToExisting]);
|
||||
}, { applyToExisting: applyToExisting && canTickApply });
|
||||
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canTickApply]);
|
||||
|
||||
const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id));
|
||||
|
||||
@@ -954,22 +990,28 @@ export function FilterRuleModal({
|
||||
<input
|
||||
type="checkbox"
|
||||
id="applyToExisting"
|
||||
checked={applyToExisting && canApplyToExisting}
|
||||
disabled={!canApplyToExisting}
|
||||
checked={applyToExisting && canTickApply}
|
||||
disabled={!canTickApply}
|
||||
onChange={(e) => setApplyToExisting(e.target.checked)}
|
||||
className="rounded border-input disabled:opacity-50"
|
||||
aria-describedby={canApplyToExisting ? undefined : "applyToExistingHint"}
|
||||
aria-describedby={canTickApply ? undefined : "applyToExistingHint"}
|
||||
/>
|
||||
<label
|
||||
htmlFor="applyToExisting"
|
||||
className={cn("text-sm", canApplyToExisting ? "text-foreground" : "text-muted-foreground")}
|
||||
className={cn("text-sm", canTickApply ? "text-foreground" : "text-muted-foreground")}
|
||||
>
|
||||
{t("apply_existing")}
|
||||
{applyExistingFolder === undefined
|
||||
? t("apply_existing")
|
||||
: existingCount && canApplyToExisting
|
||||
? t("apply_existing_count", { count: existingCount, folder: applyExistingFolder })
|
||||
: t("apply_existing_in", { folder: applyExistingFolder })}
|
||||
</label>
|
||||
</div>
|
||||
{!canApplyToExisting && (
|
||||
{!canTickApply && (
|
||||
<p id="applyToExistingHint" className="mt-1 ms-6 text-xs text-muted-foreground">
|
||||
{periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")}
|
||||
{!canApplyToExisting
|
||||
? (periodOn ? t("apply_existing_period") : t("apply_existing_unsupported"))
|
||||
: t("apply_existing_none", { folder: applyExistingFolder ?? "" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// "Vacation responder is active" opens Settings > Out of Office, not
|
||||
@@ -21,10 +21,13 @@ vi.mock('@/hooks/use-mailbox-drop', () => ({
|
||||
}));
|
||||
vi.mock('@/hooks/use-tag-drop', () => ({ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }) }));
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const { useVacationStore } = await import('@/stores/vacation-store');
|
||||
const { buildSettingsPath } = await import('@/lib/deep-links');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
|
||||
describe('vacation banner', () => {
|
||||
it('opens the Out of Office settings', () => {
|
||||
@@ -34,4 +37,25 @@ describe('vacation banner', () => {
|
||||
expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation'));
|
||||
expect(buildSettingsPath('vacation')).not.toBe('/settings');
|
||||
});
|
||||
|
||||
// settings-p2-12: the banner says "Out of office is on", as the setting is
|
||||
// called, and turns it off without a trip to Settings, like Gmail's "End now".
|
||||
it('turns out of office off on the server', async () => {
|
||||
const update = vi.fn(async () => true);
|
||||
useAuthStore.setState({ client: {} as never });
|
||||
useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never);
|
||||
render(<Sidebar mailboxes={[]} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' }));
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.anything(), { isEnabled: false }, undefined));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says so when turning it off fails', async () => {
|
||||
const update = vi.fn(async () => { throw new Error('down'); });
|
||||
useAuthStore.setState({ client: {} as never });
|
||||
useVacationStore.setState({ isEnabled: true, isSupported: true, updateVacationResponse: update } as never);
|
||||
render(<Sidebar mailboxes={[]} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'vacation_turn_off' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('vacation_save_failed'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ import { sidebarFolded, useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { unifiedLoadProgress } from "@/lib/unified-mailbox";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useEmailStore, buildTagViewAccountClients } from "@/stores/email-store";
|
||||
@@ -1070,26 +1071,59 @@ function DemoBanner({ ruled }: { ruled: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VacationBanner() {
|
||||
export function VacationBanner() {
|
||||
const t = useTranslations('sidebar');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tFilters = useTranslations('settings.filters');
|
||||
const router = useRouter();
|
||||
const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported })));
|
||||
const { isEnabled, isSupported, isSaving } = useVacationStore(useShallow((state) => ({
|
||||
isEnabled: state.isEnabled, isSupported: state.isSupported, isSaving: state.isSaving,
|
||||
})));
|
||||
// The store holds the reply of the account Settings last loaded it for.
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [turningOff, setTurningOff] = useState(false);
|
||||
|
||||
if (!isSupported || !isEnabled) return null;
|
||||
|
||||
// Like Gmail's "End now": off on the server, without opening Settings.
|
||||
const turnOff = async () => {
|
||||
if (!client || turningOff) return;
|
||||
setTurningOff(true);
|
||||
try {
|
||||
await useVacationStore.getState().updateVacationResponse(client, { isEnabled: false }, managedAccountId ?? undefined);
|
||||
} catch {
|
||||
toast.error(tNotifications('vacation_save_failed'));
|
||||
} finally {
|
||||
setTurningOff(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => router.push(buildSettingsPath('vacation'))}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 text-xs",
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-xs",
|
||||
"bg-amber-500/10 dark:bg-amber-400/10 text-amber-700 dark:text-amber-400",
|
||||
"hover:bg-amber-500/15 dark:hover:bg-amber-400/15 transition-colors"
|
||||
)}
|
||||
>
|
||||
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate font-medium">{t("vacation_active")}</span>
|
||||
<Settings className="w-3 h-3 ms-auto flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(buildSettingsPath('vacation'))}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 py-0.5 text-start hover:underline"
|
||||
>
|
||||
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate font-medium">{tFilters("vacation_active")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void turnOff()}
|
||||
disabled={turningOff || isSaving}
|
||||
className="inline-flex flex-shrink-0 items-center gap-1 px-2 py-0.5 rounded-sm font-medium hover:bg-amber-500/15 dark:hover:bg-amber-400/15 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{turningOff ? <Loader2 className="w-3 h-3 animate-spin" aria-hidden /> : null}
|
||||
{t("vacation_turn_off")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,4 +65,12 @@ describe('Settings > Calendar on a phone', () => {
|
||||
expect(screen.queryByRole('button', { name: 'title' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'add_calendar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// settings-p2-21: at 800 px the three buttons ran 4 px past the edge.
|
||||
it('lets the New, Import and Subscribe buttons wrap when a row is too narrow', () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
const row = screen.getByTestId('calendar-create-buttons');
|
||||
expect(row.className).toMatch(/(^|\s)sm:flex-wrap(\s|$)/);
|
||||
expect(row.querySelectorAll('button')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,8 @@ import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// settings-13 (usability pass 2026-11-04): a rule made in Settings only sees
|
||||
// new mail, so after Save the page offers to apply it to the Inbox; the raw
|
||||
// Sieve editor is an advanced tool.
|
||||
// new mail, so its dialog offers to apply it to the Inbox; the raw Sieve
|
||||
// editor is an advanced tool.
|
||||
|
||||
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), progress: vi.fn(), dismiss: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast }));
|
||||
@@ -57,6 +57,7 @@ beforeEach(() => {
|
||||
updateSieveScript: vi.fn(async () => {}),
|
||||
getSieveAccountId: () => 'primary',
|
||||
queryEmailFields,
|
||||
batchUpdateKeywords: vi.fn(async () => {}),
|
||||
};
|
||||
target.value = {
|
||||
client, clientAccountId: null, key: 'k', accountId: 'primary', sieveAccountId: 'primary',
|
||||
@@ -89,40 +90,71 @@ function showSettings(rules: FilterRule[] = [], extra: Record<string, unknown> =
|
||||
render(<FilterSettings />);
|
||||
}
|
||||
|
||||
async function createRule(from: string, action: string) {
|
||||
async function fillRule(from: string, action: string) {
|
||||
fireEvent.click(screen.getByText('add_rule'));
|
||||
fireEvent.change(screen.getByLabelText('rule_name'), { target: { value: 'News' } });
|
||||
fireEvent.change(screen.getByLabelText('condition_value'), { target: { value: from } });
|
||||
fireEvent.change(screen.getAllByLabelText('actions')[0], { target: { value: action } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
}
|
||||
|
||||
describe('a new rule in Settings › Filters', () => {
|
||||
it('offers to apply it to the messages already in the Inbox', async () => {
|
||||
// settings-p2-6: the offer is a box in the rule's dialog, with the number of
|
||||
// messages it would change, not a toast that is gone in a few seconds.
|
||||
describe('a rule in Settings › Filters', () => {
|
||||
it('offers, in its dialog, to apply it to the messages already in the Inbox', async () => {
|
||||
showSettings();
|
||||
await createRule('news@example.org', 'star');
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
const [, options] = toast.success.mock.calls.at(-1)!;
|
||||
expect(options.action.label).toBe('apply_to_existing');
|
||||
await fillRule('news@example.org', 'star');
|
||||
const box = await screen.findByRole('checkbox', { name: 'apply_existing_count' }, { timeout: 2000 });
|
||||
expect(box).toBeEnabled();
|
||||
expect(queryEmailFields).toHaveBeenCalled();
|
||||
fireEvent.click(box);
|
||||
queryEmailFields.mockClear();
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('rule_applied', expect.anything()));
|
||||
// Saving ran the rule; no toast offered it.
|
||||
expect(queryEmailFields).toHaveBeenCalled();
|
||||
expect(toast.success.mock.calls.some(([, options]) => options?.action)).toBe(false);
|
||||
});
|
||||
|
||||
it('says nothing more when no message in the Inbox matches', async () => {
|
||||
it('runs nothing on old mail when the box is left empty', async () => {
|
||||
showSettings();
|
||||
await createRule('nobody@example.com', 'star');
|
||||
await waitFor(() => expect(queryEmailFields).toHaveBeenCalled());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is not offered for a rule that can only act on arriving mail', async () => {
|
||||
showSettings();
|
||||
await createRule('news@example.org', 'discard');
|
||||
await fillRule('news@example.org', 'star');
|
||||
await screen.findByRole('checkbox', { name: 'apply_existing_count' }, { timeout: 2000 });
|
||||
queryEmailFields.mockClear();
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
await waitFor(() => expect(client.updateSieveScript).toHaveBeenCalled());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(queryEmailFields).not.toHaveBeenCalled();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('greys the box when no message in the Inbox matches', async () => {
|
||||
showSettings();
|
||||
await fillRule('nobody@example.com', 'star');
|
||||
await waitFor(() => expect(queryEmailFields).toHaveBeenCalled(), { timeout: 2000 });
|
||||
await waitFor(() => expect(screen.getByText('apply_existing_none')).toBeInTheDocument());
|
||||
expect(screen.getByRole('checkbox', { name: 'apply_existing_in' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is greyed for a rule that can only act on arriving mail', async () => {
|
||||
showSettings();
|
||||
await fillRule('news@example.org', 'discard');
|
||||
expect(screen.getByRole('checkbox', { name: 'apply_existing_in' })).toBeDisabled();
|
||||
expect(screen.getByText('apply_existing_unsupported')).toBeInTheDocument();
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
expect(queryEmailFields).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the rule list', () => {
|
||||
it('says what its switch does', () => {
|
||||
showSettings([{
|
||||
id: 'r1', name: 'News', enabled: true, matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'news@example.org' }],
|
||||
actions: [{ type: 'star' }], stopProcessing: false,
|
||||
} as FilterRule]);
|
||||
expect(screen.getByRole('switch', { name: 'show_rule_details' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('expanded_view')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the raw Sieve editor button', () => {
|
||||
|
||||
@@ -73,4 +73,27 @@ describe('time zone setting', () => {
|
||||
fireEvent.change(input, { target: { value: '+05:45' } });
|
||||
expect(optionTexts()).toEqual([expect.stringMatching(/^Kathmandu/)]);
|
||||
});
|
||||
|
||||
// settings-p2-20: abbreviations and an exact offset find their zones first.
|
||||
it('finds zones by abbreviation', () => {
|
||||
const input = openTimeZones();
|
||||
fireEvent.change(input, { target: { value: 'CET' } });
|
||||
const found = optionTexts();
|
||||
expect(found.length).toBeGreaterThan(0);
|
||||
expect(found).toEqual(expect.arrayContaining([expect.stringMatching(/^Berlin/), expect.stringMatching(/^Paris/)]));
|
||||
fireEvent.change(input, { target: { value: 'pst' } });
|
||||
expect(optionTexts()[0]).toMatch(/^Los Angeles/);
|
||||
});
|
||||
|
||||
it('lists the exact offset before offsets that start with the same digits', () => {
|
||||
const input = openTimeZones();
|
||||
fireEvent.change(input, { target: { value: 'GMT+1' } });
|
||||
const found = optionTexts();
|
||||
const offsetOf = (text: string) => /GMT[+-]\d{2}:\d{2}/.exec(text)?.[0];
|
||||
const first = offsetOf(found[0]);
|
||||
// Whatever +1 is today (winter or summer time), it's listed first.
|
||||
expect(first).toMatch(/^GMT\+01:00$/);
|
||||
const firstOther = found.findIndex((text) => offsetOf(text) !== first);
|
||||
expect(found.slice(firstOther).every((text) => offsetOf(text) !== 'GMT+01:00')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,6 +378,9 @@ describe('the vacation period in the card', () => {
|
||||
it('checks the fields again on save, which a field filled in only half may never have told', async () => {
|
||||
const server = stalwart([rule('a')]);
|
||||
await openCard(server);
|
||||
// Typing in it: an empty field left alone turns into a text field with a
|
||||
// placeholder (settings-p2-19).
|
||||
fireEvent.focus(dateField(1));
|
||||
fireEvent.change(dateField(1), { target: { value: '' } });
|
||||
halfFilledIn(dateField(1), true);
|
||||
expect(await screen.findByText('warnings.invalid_date', undefined, { timeout: 3000 })).toBeInTheDocument();
|
||||
|
||||
@@ -18,8 +18,8 @@ vi.mock('next-intl', () => {
|
||||
});
|
||||
// The editor hands the card its HTML as typed.
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: ({ content, onChange, placeholder }: { content: string; onChange: (html: string) => void; placeholder?: string }) => (
|
||||
<textarea data-testid="message-editor" value={content} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||||
RichTextEditor: ({ content, onChange, placeholder, toolbar }: { content: string; onChange: (html: string) => void; placeholder?: string; toolbar?: string }) => (
|
||||
<textarea data-testid="message-editor" data-toolbar={toolbar ?? 'full'} value={content} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -138,3 +138,33 @@ describe('the out-of-office message', () => {
|
||||
expect(lastSent(server)).toMatchObject({ isEnabled: true, htmlBody: '<p>Back on 5 May.</p>', textBody: 'Back soon.' });
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// settings-p2-19: a short toolbar for a few lines, and date fields that say
|
||||
// what an empty one means instead of the browser's "dd.mm.yyyy --:--".
|
||||
describe('the out-of-office form', () => {
|
||||
it('gives the message the short formatting toolbar', async () => {
|
||||
await openCard(blank());
|
||||
expect(editor().dataset.toolbar).toBe('short');
|
||||
});
|
||||
|
||||
it('shows empty dates as words, and the date picker once a field is used', async () => {
|
||||
await openCard(blank());
|
||||
const from = screen.getByLabelText('from_label') as HTMLInputElement;
|
||||
const until = screen.getByLabelText('until_label') as HTMLInputElement;
|
||||
expect(from.type).toBe('text');
|
||||
expect(from.placeholder).toBe('from_placeholder');
|
||||
expect(until.type).toBe('text');
|
||||
expect(until.placeholder).toBe('until_placeholder');
|
||||
|
||||
fireEvent.focus(from);
|
||||
expect(from.type).toBe('datetime-local');
|
||||
fireEvent.change(from, { target: { value: '2030-01-02T09:00' } });
|
||||
fireEvent.blur(from);
|
||||
// A field with a date keeps the picker; the empty one goes back to words.
|
||||
expect(from.type).toBe('datetime-local');
|
||||
expect(from.value).toBe('2030-01-02T09:00');
|
||||
fireEvent.focus(until);
|
||||
fireEvent.blur(until);
|
||||
expect(until.type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -858,8 +858,9 @@ export function CalendarManagementSettings() {
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
// Stacked on a phone: side by side the three labels wrapped.
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
// Stacked on a phone: side by side the three labels wrapped. Too
|
||||
// narrow for all three in a row (800 px), the last one goes under.
|
||||
<div data-testid="calendar-create-buttons" className="flex flex-col sm:flex-row sm:flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreating(true)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { MoveButtons } from "@/components/ui/move-buttons";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,9 +24,8 @@ import { supportsRegex } from "@/lib/sieve/regex";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
import { offerRuleOnFolder } from "@/lib/filters/quick-rule-flow";
|
||||
import { planOnServer, runRuleOnFolder } from "@/lib/filters/quick-rule-flow";
|
||||
import { resolveMailboxRuleTarget } from "@/lib/filters/quick-rule-target";
|
||||
import { retroactiveSupport } from "@/lib/filters/retroactive";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
Plus,
|
||||
@@ -215,7 +214,6 @@ export function FilterSettings() {
|
||||
const tNotifications = useTranslations("notifications");
|
||||
const tMenu = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const tFolders = useTranslations("uxs_folders");
|
||||
// The raw script editor is for people who asked for the advanced settings,
|
||||
// unless the script is one only it can edit.
|
||||
const showAdvanced = useSettingsStore((s) => s.showAdvancedSettings);
|
||||
@@ -384,8 +382,24 @@ export function FilterSettings() {
|
||||
[client, deleteRule, saveFilters, tNotifications]
|
||||
);
|
||||
|
||||
// A rule only sees new mail. Its dialog offers to sort what is already in
|
||||
// the Inbox too, as Gmail's "Also apply filter to matching conversations"
|
||||
// does: for the user's own account, where the Inbox can be searched.
|
||||
const inboxOffer = useMemo(() => {
|
||||
if (!isPrimaryAccount || !client) return null;
|
||||
const inbox = storeMailboxes.find((m) => m.role === "inbox" && !m.isShared);
|
||||
const target = inbox ? resolveMailboxRuleTarget(inbox) : null;
|
||||
if (!inbox || !target || target.client !== client) return null;
|
||||
const folder = localizeMailboxName(inbox.role, inbox.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
return { inbox, target, folder };
|
||||
}, [isPrimaryAccount, client, storeMailboxes, tSidebar]);
|
||||
const countInInbox = useCallback(async (rule: FilterRule) => {
|
||||
if (!inboxOffer) return 0;
|
||||
return (await planOnServer(inboxOffer.target, rule, inboxOffer.inbox.id)).ids.length;
|
||||
}, [inboxOffer]);
|
||||
|
||||
const handleSaveRule = useCallback(
|
||||
async (rule: FilterRule) => {
|
||||
async (rule: FilterRule, options?: { applyToExisting: boolean }) => {
|
||||
const previousRules = [...rules];
|
||||
if (editingRule) {
|
||||
updateRule(rule.id, rule);
|
||||
@@ -403,25 +417,19 @@ export function FilterSettings() {
|
||||
toast.error(tNotifications("filters_save_failed"));
|
||||
return;
|
||||
}
|
||||
// A new rule only sees new mail. Offer to sort what is already in the
|
||||
// Inbox, as Gmail's "Also apply to matching conversations" does.
|
||||
if (!editingRule && isPrimaryAccount && retroactiveSupport(rule).ok) {
|
||||
const inbox = storeMailboxes.find((m) => m.role === "inbox" && !m.isShared);
|
||||
const target = inbox ? resolveMailboxRuleTarget(inbox) : null;
|
||||
if (inbox && target && target.client === client) {
|
||||
const folder = localizeMailboxName(inbox.role, inbox.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
void offerRuleOnFolder(
|
||||
target,
|
||||
rule,
|
||||
inbox.id,
|
||||
{ notifications: tNotifications, filters: t, menu: tMenu },
|
||||
(count) => tFolders("apply_to_existing", { count, folder }),
|
||||
);
|
||||
}
|
||||
// The box in the dialog: run the saved rule over the Inbox now.
|
||||
if (options?.applyToExisting && inboxOffer) {
|
||||
void runRuleOnFolder(
|
||||
inboxOffer.target,
|
||||
rule,
|
||||
inboxOffer.inbox.id,
|
||||
inboxOffer.folder,
|
||||
{ notifications: tNotifications, filters: t, menu: tMenu },
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications, isPrimaryAccount, storeMailboxes, tSidebar, t, tMenu, tFolders]
|
||||
[editingRule, updateRule, addRule, rules, client, saveFilters, tNotifications, inboxOffer, t, tMenu]
|
||||
);
|
||||
|
||||
const handleSaveSieve = useCallback(
|
||||
@@ -804,11 +812,11 @@ export function FilterSettings() {
|
||||
)}
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("expanded_view")}</span>
|
||||
<span className="text-xs text-muted-foreground">{t("show_rule_details")}</span>
|
||||
<ToggleSwitch
|
||||
checked={expandedFilterView}
|
||||
onChange={(v) => updateSetting("expandedFilterView", v)}
|
||||
ariaLabel={t("expanded_view")}
|
||||
ariaLabel={t("show_rule_details")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -824,6 +832,9 @@ export function FilterSettings() {
|
||||
regexSupported={supportsRegex(sieveCapabilities?.sieveExtensions)}
|
||||
forwardsBefore={forwardsOfEdited.before}
|
||||
forwardsAfter={forwardsOfEdited.after}
|
||||
offerApplyToExisting={!!inboxOffer}
|
||||
applyExistingFolder={inboxOffer?.folder}
|
||||
countExisting={inboxOffer ? countInInbox : undefined}
|
||||
onSave={handleSaveRule}
|
||||
onClose={() => {
|
||||
setShowRuleModal(false);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SearchableSelect, type SearchableOption } from '@/components/ui/searchable-select';
|
||||
import { AUTO_TIME_ZONE, getBrowserTimeZone, listTimeZones } from '@/lib/timezone';
|
||||
import { timeZoneChoices, timeZoneCity, type TimeZoneChoice } from '@/lib/time-zone-choices';
|
||||
import { timeZoneChoices, timeZoneCity, timeZoneSearchRank, type TimeZoneChoice } from '@/lib/time-zone-choices';
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
|
||||
/**
|
||||
@@ -25,11 +25,13 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
|
||||
setZones(listTimeZones());
|
||||
}, []);
|
||||
|
||||
const offsets = useMemo(() => new Map<string, number>(), []);
|
||||
const options = useMemo<SearchableOption[]>(() => {
|
||||
// A zone synced from another browser (newer tz database) may be missing
|
||||
// from this runtime's list; keep it selectable.
|
||||
const extra = value !== AUTO_TIME_ZONE ? [value] : [];
|
||||
const { common, all } = timeZoneChoices(zones, { locale: getEffectiveLocale(), extra });
|
||||
for (const choice of all) offsets.set(choice.value, choice.offsetMinutes);
|
||||
const toOption = (group: string) => (choice: TimeZoneChoice): SearchableOption => ({
|
||||
value: choice.value,
|
||||
label: choice.city,
|
||||
@@ -42,7 +44,13 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
|
||||
...common.map(toOption(tz('time_zone_common'))),
|
||||
...all.map(toOption(tz('time_zone_all'))),
|
||||
];
|
||||
}, [zones, value, browserTimeZone, t, tz]);
|
||||
}, [zones, value, browserTimeZone, t, tz, offsets]);
|
||||
// "CET" and "GMT+1" find their zones first (settings-p2-20).
|
||||
const rank = useCallback(
|
||||
(query: string, option: SearchableOption, wordsMatch: boolean) =>
|
||||
timeZoneSearchRank(query, { value: option.value, offsetMinutes: offsets.get(option.value) }, wordsMatch),
|
||||
[offsets],
|
||||
);
|
||||
|
||||
const current = options.find((option) => option.value === value);
|
||||
return (
|
||||
@@ -52,6 +60,7 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
|
||||
value={value}
|
||||
options={options}
|
||||
onSelect={onChange}
|
||||
rank={rank}
|
||||
ariaLabel={`${t('time_zone.label')}: ${current?.label ?? timeZoneCity(value)}`}
|
||||
searchLabel={tz('time_zone_search')}
|
||||
noMatchLabel={tz('time_zone_no_match')}
|
||||
|
||||
@@ -159,6 +159,19 @@ export function VacationSettings() {
|
||||
const [halfFilled, setHalfFilled] = useState({ from: false, to: false });
|
||||
const fromRef = useRef<HTMLInputElement>(null);
|
||||
const toRef = useRef<HTMLInputElement>(null);
|
||||
// An empty date field that isn't being edited is a text field with a
|
||||
// placeholder in words: the browser's own date field shows its raw pattern
|
||||
// ("dd.mm.yyyy --:--") there, whatever the app's date format.
|
||||
const [dateEditing, setDateEditing] = useState<'from' | 'to' | null>(null);
|
||||
const dateFieldType = (field: 'from' | 'to') =>
|
||||
dateEditing === field || halfFilled[field] || (field === 'from' ? fromInput : toInput) ? 'datetime-local' : 'text';
|
||||
useEffect(() => {
|
||||
if (!dateEditing) return;
|
||||
const input = (dateEditing === 'from' ? fromRef : toRef).current;
|
||||
// Opens the browser's picker after a click; a Tab into the field has no
|
||||
// user activation and just leaves the field focused for typing.
|
||||
try { input?.showPicker?.(); } catch { /* not allowed without a click */ }
|
||||
}, [dateEditing]);
|
||||
const localFromDate = fieldMoment(fromInput, fromDate, inputZone);
|
||||
const localToDate = fieldMoment(toInput, toDate, inputZone);
|
||||
useEffect(() => {
|
||||
@@ -568,11 +581,13 @@ export function VacationSettings() {
|
||||
>
|
||||
<input
|
||||
ref={fromRef}
|
||||
type="datetime-local"
|
||||
max={DATE_INPUT_MAX}
|
||||
type={dateFieldType('from')}
|
||||
max={dateFieldType('from') === 'datetime-local' ? DATE_INPUT_MAX : undefined}
|
||||
value={fromInput}
|
||||
placeholder={tx('from_placeholder')}
|
||||
onFocus={() => setDateEditing('from')}
|
||||
onChange={(e) => readDateField('from', e.currentTarget)}
|
||||
onBlur={(e) => readDateField('from', e.currentTarget)}
|
||||
onBlur={(e) => { readDateField('from', e.currentTarget); setDateEditing(null); }}
|
||||
aria-label={tx('from_label')}
|
||||
aria-invalid={!!fromError}
|
||||
aria-describedby={fromError ? 'vacation-from-error' : undefined}
|
||||
@@ -587,11 +602,13 @@ export function VacationSettings() {
|
||||
>
|
||||
<input
|
||||
ref={toRef}
|
||||
type="datetime-local"
|
||||
max={DATE_INPUT_MAX}
|
||||
type={dateFieldType('to')}
|
||||
max={dateFieldType('to') === 'datetime-local' ? DATE_INPUT_MAX : undefined}
|
||||
value={toInput}
|
||||
placeholder={tx('until_placeholder')}
|
||||
onFocus={() => setDateEditing('to')}
|
||||
onChange={(e) => readDateField('to', e.currentTarget)}
|
||||
onBlur={(e) => readDateField('to', e.currentTarget)}
|
||||
onBlur={(e) => { readDateField('to', e.currentTarget); setDateEditing(null); }}
|
||||
aria-label={tx('until_label')}
|
||||
aria-invalid={!!toError}
|
||||
aria-describedby={toError ? 'vacation-to-error' : undefined}
|
||||
@@ -643,6 +660,7 @@ export function VacationSettings() {
|
||||
<RichTextEditor
|
||||
content={localBody}
|
||||
onChange={editBody}
|
||||
toolbar="short"
|
||||
ariaLabel={tx('message_label')}
|
||||
ariaInvalid={!!bodyError}
|
||||
ariaDescribedBy={bodyError ? 'vacation-body-error' : undefined}
|
||||
|
||||
@@ -33,6 +33,13 @@ interface SearchableSelectProps {
|
||||
renderValue?: (option: SearchableOption | undefined) => ReactNode;
|
||||
className?: string;
|
||||
menuClassName?: string;
|
||||
/**
|
||||
* Orders the results, lower first, and can add or drop options:
|
||||
* `wordsMatch` says whether every typed word is in the option's text.
|
||||
* null leaves the option out. Without it, the matching options keep
|
||||
* their order.
|
||||
*/
|
||||
rank?: (query: string, option: SearchableOption, wordsMatch: boolean) => number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +57,7 @@ export function SearchableSelect({
|
||||
renderValue,
|
||||
className,
|
||||
menuClassName,
|
||||
rank,
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -74,12 +82,24 @@ export function SearchableSelect({
|
||||
const words = needle.split(/\s+/);
|
||||
// An option listed in two groups shows once in the results.
|
||||
const seen = new Set<string>();
|
||||
if (rank) {
|
||||
const ranked: { entry: (typeof entries)[number]; rank: number }[] = [];
|
||||
for (const e of entries) {
|
||||
if (seen.has(e.value)) continue;
|
||||
const r = rank(query, e, words.every((w) => e.haystack.includes(w)));
|
||||
if (r === null) continue;
|
||||
seen.add(e.value);
|
||||
ranked.push({ entry: e, rank: r });
|
||||
}
|
||||
// Array sort is stable: equal ranks keep the list's order.
|
||||
return ranked.sort((a, b) => a.rank - b.rank).map((r) => r.entry);
|
||||
}
|
||||
return entries.filter((e) => {
|
||||
if (seen.has(e.value) || !words.every((w) => e.haystack.includes(w))) return false;
|
||||
seen.add(e.value);
|
||||
return true;
|
||||
});
|
||||
}, [entries, query]);
|
||||
}, [entries, query, rank]);
|
||||
|
||||
const close = (refocus: boolean) => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { timeZoneChoices, timeZoneCity } from '../time-zone-choices';
|
||||
import { parseTypedOffset, timeZoneChoices, timeZoneCity, timeZoneSearchRank } from '../time-zone-choices';
|
||||
|
||||
const JAN = new Date(Date.UTC(2026, 0, 15, 12));
|
||||
|
||||
@@ -38,3 +38,23 @@ describe('time zone choices', () => {
|
||||
expect(all.map((c) => c.value)).toContain('America/Ciudad_Juarez');
|
||||
});
|
||||
});
|
||||
|
||||
describe('time zone search', () => {
|
||||
it('reads offsets as people type them', () => {
|
||||
expect(parseTypedOffset('GMT+1')).toBe(60);
|
||||
expect(parseTypedOffset('utc-5')).toBe(-300);
|
||||
expect(parseTypedOffset('+05:30')).toBe(330);
|
||||
expect(parseTypedOffset('-0800')).toBe(-480);
|
||||
expect(parseTypedOffset('Berlin')).toBeNull();
|
||||
expect(parseTypedOffset('GMT')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts the zones of an abbreviation and the exact offset first', () => {
|
||||
expect(timeZoneSearchRank('CET', { value: 'Europe/Berlin', offsetMinutes: 60 }, false)).toBe(0);
|
||||
expect(timeZoneSearchRank('cest', { value: 'Europe/Paris', offsetMinutes: 120 }, false)).toBe(0);
|
||||
expect(timeZoneSearchRank('CET', { value: 'Asia/Tokyo', offsetMinutes: 540 }, false)).toBeNull();
|
||||
expect(timeZoneSearchRank('GMT+1', { value: 'Europe/Berlin', offsetMinutes: 60 }, true)).toBe(0);
|
||||
expect(timeZoneSearchRank('GMT+1', { value: 'Australia/Sydney', offsetMinutes: 660 }, true)).toBe(2);
|
||||
expect(timeZoneSearchRank('Berlin', { value: 'Europe/Berlin', offsetMinutes: 60 }, true)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,30 +213,6 @@ async function applyAndReport(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a rule was saved in Settings › Filters: when it would change mail
|
||||
* already in `mailboxId` (the Inbox), say how much and offer to apply it
|
||||
* there. Nothing is said when it would change nothing or can't be checked.
|
||||
*/
|
||||
export async function offerRuleOnFolder(
|
||||
target: QuickRuleTarget,
|
||||
rule: FilterRule,
|
||||
mailboxId: string,
|
||||
text: QuickRuleText,
|
||||
applyLabel: (count: number) => string,
|
||||
): Promise<void> {
|
||||
const plan = await planSafely(target, rule, mailboxId);
|
||||
if (plan.ids.length === 0) return;
|
||||
toast.success(text.notifications('rule_created'), {
|
||||
message: rule.name,
|
||||
duration: SAVED_TOAST_MS,
|
||||
action: {
|
||||
label: applyLabel(plan.ids.length),
|
||||
onClick: () => { void applyAndReport(target, rule, mailboxId, text); },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a saved rule over the mail already in a folder, from the folder's
|
||||
* context menu. A progress toast stays up while the server is asked which
|
||||
|
||||
@@ -134,3 +134,90 @@ export function timeZoneChoices(
|
||||
}
|
||||
return { common, all };
|
||||
}
|
||||
|
||||
|
||||
// Abbreviations people type for a zone, with the zones they stand for. Both
|
||||
// the winter and the summer name, since people type either all year.
|
||||
const CET = ['Europe/Berlin', 'Europe/Paris', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Brussels',
|
||||
'Europe/Vienna', 'Europe/Zurich', 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Warsaw',
|
||||
'Europe/Prague', 'Europe/Budapest', 'Europe/Belgrade', 'Europe/Luxembourg'];
|
||||
const EET = ['Europe/Athens', 'Europe/Helsinki', 'Europe/Kyiv', 'Europe/Kiev', 'Europe/Bucharest', 'Europe/Sofia',
|
||||
'Europe/Riga', 'Europe/Tallinn', 'Europe/Vilnius', 'Africa/Cairo'];
|
||||
const WET = ['Europe/Lisbon', 'Atlantic/Canary', 'Atlantic/Madeira', 'Atlantic/Faroe', 'Atlantic/Faeroe'];
|
||||
const US_EAST = ['America/New_York', 'America/Toronto', 'America/Detroit', 'America/Indiana/Indianapolis'];
|
||||
const US_CENTRAL = ['America/Chicago', 'America/Winnipeg', 'America/Mexico_City'];
|
||||
const US_MOUNTAIN = ['America/Denver', 'America/Phoenix', 'America/Edmonton', 'America/Boise'];
|
||||
const US_PACIFIC = ['America/Los_Angeles', 'America/Vancouver', 'America/Tijuana'];
|
||||
const AU_EAST = ['Australia/Sydney', 'Australia/Melbourne', 'Australia/Brisbane', 'Australia/Hobart', 'Australia/Canberra'];
|
||||
const UK = ['Europe/London', 'Europe/Dublin'];
|
||||
|
||||
export const TIME_ZONE_ABBREVIATIONS: Record<string, readonly string[]> = {
|
||||
UTC: ['UTC', 'Etc/UTC'],
|
||||
GMT: ['UTC', 'Etc/UTC', ...UK],
|
||||
BST: UK,
|
||||
IST: ['Asia/Kolkata', 'Asia/Calcutta'],
|
||||
CET, CEST: CET, MEZ: CET, MESZ: CET,
|
||||
EET, EEST: EET,
|
||||
WET, WEST: WET,
|
||||
MSK: ['Europe/Moscow'],
|
||||
EST: US_EAST, EDT: US_EAST, ET: US_EAST,
|
||||
CST: [...US_CENTRAL, 'Asia/Shanghai'], CDT: US_CENTRAL, CT: US_CENTRAL,
|
||||
MST: US_MOUNTAIN, MDT: US_MOUNTAIN, MT: US_MOUNTAIN,
|
||||
PST: US_PACIFIC, PDT: US_PACIFIC, PT: US_PACIFIC,
|
||||
AKST: ['America/Anchorage'], AKDT: ['America/Anchorage'],
|
||||
HST: ['Pacific/Honolulu'],
|
||||
JST: ['Asia/Tokyo'],
|
||||
KST: ['Asia/Seoul'],
|
||||
HKT: ['Asia/Hong_Kong'],
|
||||
SGT: ['Asia/Singapore'],
|
||||
AEST: AU_EAST, AEDT: AU_EAST,
|
||||
ACST: ['Australia/Adelaide', 'Australia/Darwin'], ACDT: ['Australia/Adelaide'],
|
||||
AWST: ['Australia/Perth'],
|
||||
NZST: ['Pacific/Auckland'], NZDT: ['Pacific/Auckland'],
|
||||
BRT: ['America/Sao_Paulo'],
|
||||
ART: ['America/Argentina/Buenos_Aires', 'America/Buenos_Aires'],
|
||||
SAST: ['Africa/Johannesburg'],
|
||||
WAT: ['Africa/Lagos'],
|
||||
EAT: ['Africa/Nairobi'],
|
||||
PKT: ['Asia/Karachi'],
|
||||
WIB: ['Asia/Jakarta'],
|
||||
ICT: ['Asia/Bangkok', 'Asia/Ho_Chi_Minh', 'Asia/Saigon'],
|
||||
PHT: ['Asia/Manila'],
|
||||
GST: ['Asia/Dubai'],
|
||||
};
|
||||
|
||||
/**
|
||||
* An offset typed in the search ("GMT+1", "UTC-5", "+05:30", "-0800"), in
|
||||
* minutes east of UTC; null when the text isn't one. "GMT" alone is UTC.
|
||||
*/
|
||||
export function parseTypedOffset(text: string): number | null {
|
||||
const m = /^(?:gmt|utc)?\s*([+\-−])\s*(\d{1,2})(?::?(\d{2}))?$/i.exec(text.trim());
|
||||
if (!m) return null;
|
||||
const hours = Number(m[2]);
|
||||
const minutes = m[3] ? Number(m[3]) : 0;
|
||||
if (hours > 14 || minutes > 59) return null;
|
||||
return (m[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a zone goes in the search results, lower first; null leaves it out.
|
||||
* An abbreviation ("CET") or an exact offset ("GMT+1") finds its zones
|
||||
* first; anything the words match comes after. Without this, "GMT+1" listed
|
||||
* GMT+11 and GMT+10 before GMT+01:00, and "CET" found nothing.
|
||||
*/
|
||||
export function timeZoneSearchRank(
|
||||
query: string,
|
||||
zone: { value: string; offsetMinutes?: number },
|
||||
wordsMatch: boolean,
|
||||
): number | null {
|
||||
const typed = query.trim();
|
||||
const zones = TIME_ZONE_ABBREVIATIONS[typed.toUpperCase()];
|
||||
if (zones?.includes(zone.value)) return 0;
|
||||
const offset = parseTypedOffset(typed);
|
||||
if (offset !== null && zone.offsetMinutes !== undefined) {
|
||||
if (zone.offsetMinutes === offset) return 0;
|
||||
// "+1" also finds "+10", but after the zones at +01:00.
|
||||
return wordsMatch ? 2 : null;
|
||||
}
|
||||
return wordsMatch ? 1 : null;
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "مسح البحث",
|
||||
"vacation_active": "الرد التلقائي أثناء الغياب مفعّل",
|
||||
"vacation_turn_off": "إيقاف",
|
||||
"demo_banner": "وضع تجريبي",
|
||||
"demo_reset": "إعادة تعيين",
|
||||
"demo_tour": "جولة",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "اتركه فارغًا للبدء فورًا.",
|
||||
"until_label": "حتى (اختياري)",
|
||||
"until_description": "اتركه فارغًا لمواصلة الرد حتى توقفه.",
|
||||
"from_placeholder": "فورًا",
|
||||
"until_placeholder": "حتى توقفه",
|
||||
"audience_all": "الجميع",
|
||||
"audience_internal": "مؤسستي فقط ({domains})",
|
||||
"audience_external": "من هم خارج مؤسستي فقط",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "فتح محرر Sieve الخام",
|
||||
"fetch_error": "فشل تحميل المرشحات",
|
||||
"expanded_view": "عرض موسّع",
|
||||
"show_rule_details": "عرض شروط كل قاعدة وإجراءاتها",
|
||||
"expanded_view_description": "إظهار قواعد المرشح مع كتل الشروط والإجراءات المفصّلة",
|
||||
"if": "إذا",
|
||||
"then": "عندئذٍ",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "أدخل تاريخ بدء أو انتهاء للفترة",
|
||||
"validation_period_invalid": "أدخل تاريخًا صالحًا مع الوقت للفترة، أو اترك الحقل فارغًا",
|
||||
"apply_existing_period": "القاعدة ذات الفترة الزمنية تعمل فقط على الرسائل التي تصل خلالها، لذا لا يمكن تطبيقها على الرسائل الموجودة.",
|
||||
"apply_existing_in": "طبّقها أيضًا على الرسائل الموجودة في {folder}",
|
||||
"apply_existing_count": "{count, plural, zero {طبّقها أيضًا على الرسائل الموجودة في {folder}} one {طبّقها أيضًا على الرسالة الموجودة في {folder}} two {طبّقها أيضًا على الرسالتين الموجودتين في {folder}} few {طبّقها أيضًا على # رسائل موجودة في {folder}} many {طبّقها أيضًا على # رسالة موجودة في {folder}} other {طبّقها أيضًا على # رسالة موجودة في {folder}}}",
|
||||
"apply_existing_none": "لا توجد رسالة في {folder} تطابق هذه القاعدة بعد.",
|
||||
"period_status_scheduled": "مجدولة",
|
||||
"period_status_active": "نشطة",
|
||||
"period_status_expired": "منتهية"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Neteja la cerca",
|
||||
"vacation_active": "La resposta automàtica està activa",
|
||||
"vacation_turn_off": "Desactiva",
|
||||
"demo_banner": "Mode de demostració",
|
||||
"demo_reset": "Reinicia",
|
||||
"demo_tour": "Visita guiada",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Deixa-ho buit per començar ara mateix.",
|
||||
"until_label": "Fins a (opcional)",
|
||||
"until_description": "Deixa-ho buit per continuar responent fins que ho desactivis.",
|
||||
"from_placeholder": "Des d’ara",
|
||||
"until_placeholder": "Fins que el desactiveu",
|
||||
"audience_all": "Tothom",
|
||||
"audience_internal": "Només la meva organització ({domains})",
|
||||
"audience_external": "Només persones de fora de la meva organització",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "Obre l'editor Sieve en brut",
|
||||
"fetch_error": "No s'han pogut carregar els filtres",
|
||||
"expanded_view": "Vista ampliada",
|
||||
"show_rule_details": "Mostra les condicions i les accions de cada regla",
|
||||
"expanded_view_description": "Mostra les regles de filtre amb blocs detallats de condicions i accions",
|
||||
"if": "Si",
|
||||
"then": "Aleshores",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "Introdueix una data d'inici o de finalització per al període",
|
||||
"validation_period_invalid": "Introdueix una data vàlida amb l'hora per al període, o deixa el camp buit",
|
||||
"apply_existing_period": "Una regla amb període només actua sobre els missatges que arriben dins d'aquest període, de manera que no es pot aplicar als missatges existents.",
|
||||
"apply_existing_in": "Aplica-la també als missatges que ja són a {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Aplica-la també al # missatge que ja és a {folder}} other {Aplica-la també als # missatges que ja són a {folder}}}",
|
||||
"apply_existing_none": "Encara cap missatge de {folder} no compleix aquesta regla.",
|
||||
"period_status_scheduled": "programada",
|
||||
"period_status_active": "activa",
|
||||
"period_status_expired": "caducada"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Vymazat hledání",
|
||||
"vacation_active": "Automatická odpověď je aktivní",
|
||||
"vacation_turn_off": "Vypnout",
|
||||
"demo_banner": "Demo režim",
|
||||
"demo_reset": "Resetovat",
|
||||
"demo_tour": "Průvodce",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Nechte prázdné, aby to začalo hned.",
|
||||
"until_label": "Do (nepovinné)",
|
||||
"until_description": "Nechte prázdné, aby se odpovídalo, dokud to nevypnete.",
|
||||
"from_placeholder": "Hned",
|
||||
"until_placeholder": "Dokud ji nevypnete",
|
||||
"audience_all": "Všem",
|
||||
"audience_internal": "Jen mé organizaci ({domains})",
|
||||
"audience_external": "Jen lidem mimo mou organizaci",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Otevřít textový editor Sieve",
|
||||
"fetch_error": "Nepodařilo se načíst filtry",
|
||||
"expanded_view": "Rozšířené zobrazení",
|
||||
"show_rule_details": "Zobrazit podmínky a akce každého pravidla",
|
||||
"expanded_view_description": "Zobrazovat pravidla filtrů s podrobnými bloky podmínek a akcí",
|
||||
"if": "Pokud",
|
||||
"then": "Pak",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Zadejte datum zahájení nebo ukončení období",
|
||||
"validation_period_invalid": "Zadejte pro období platné datum včetně času, nebo pole nechte prázdné",
|
||||
"apply_existing_period": "Pravidlo s obdobím působí jen na zprávy, které v něm přijdou, a proto ho nelze použít na existující zprávy.",
|
||||
"apply_existing_in": "Použít i na zprávy, které už jsou ve složce {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Použít i na # zprávu, která už je ve složce {folder}} few {Použít i na # zprávy, které už jsou ve složce {folder}} many {Použít i na # zprávy, které už jsou ve složce {folder}} other {Použít i na # zpráv, které už jsou ve složce {folder}}}",
|
||||
"apply_existing_none": "Tomuto pravidlu zatím neodpovídá žádná zpráva ve složce {folder}.",
|
||||
"period_status_scheduled": "naplánováno",
|
||||
"period_status_active": "aktivní",
|
||||
"period_status_expired": "vypršelo"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Ryd søgning",
|
||||
"vacation_active": "Fraværsbeskeder er aktive",
|
||||
"vacation_turn_off": "Slå fra",
|
||||
"demo_banner": "Demo-tilstand",
|
||||
"demo_reset": "Nulstil",
|
||||
"demo_tour": "Rundvisning",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Lad feltet være tomt for at starte med det samme.",
|
||||
"until_label": "Til (valgfrit)",
|
||||
"until_description": "Lad feltet være tomt for at blive ved med at svare, til du slår det fra.",
|
||||
"from_placeholder": "Med det samme",
|
||||
"until_placeholder": "Indtil du slår det fra",
|
||||
"audience_all": "Alle",
|
||||
"audience_internal": "Kun min organisation ({domains})",
|
||||
"audience_external": "Kun folk uden for min organisation",
|
||||
@@ -2909,6 +2912,7 @@
|
||||
"open_sieve_editor": "Åbn raw Sieve-editor",
|
||||
"fetch_error": "Kunne ikke indlæse filtre",
|
||||
"expanded_view": "Udvidet visning",
|
||||
"show_rule_details": "Vis hver regels betingelser og handlinger",
|
||||
"expanded_view_description": "Vis filterregler med detaljerede betingelses- og handlingsblokke",
|
||||
"if": "Hvis",
|
||||
"then": "Så",
|
||||
@@ -2987,6 +2991,9 @@
|
||||
"validation_period_empty": "Angiv en start- eller slutdato for perioden",
|
||||
"validation_period_invalid": "Angiv en gyldig dato med klokkeslæt for perioden, eller lad feltet stå tomt",
|
||||
"apply_existing_period": "En regel med en periode virker kun på beskeder, der ankommer i perioden, og kan derfor ikke køres på eksisterende beskeder.",
|
||||
"apply_existing_in": "Anvend også på beskederne, der allerede er i {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Anvend også på den # besked, der allerede er i {folder}} other {Anvend også på de # beskeder, der allerede er i {folder}}}",
|
||||
"apply_existing_none": "Ingen beskeder i {folder} passer til reglen endnu.",
|
||||
"period_status_scheduled": "planlagt",
|
||||
"period_status_active": "aktiv",
|
||||
"period_status_expired": "udløbet"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Suche löschen",
|
||||
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
||||
"vacation_turn_off": "Ausschalten",
|
||||
"demo_banner": "Demo-Modus",
|
||||
"demo_reset": "Zurücksetzen",
|
||||
"demo_tour": "Tour",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Leer lassen, um sofort zu beginnen.",
|
||||
"until_label": "Bis (optional)",
|
||||
"until_description": "Leer lassen, um weiter zu antworten, bis Sie es ausschalten.",
|
||||
"from_placeholder": "Ab sofort",
|
||||
"until_placeholder": "Bis Sie sie ausschalten",
|
||||
"audience_all": "Allen",
|
||||
"audience_internal": "Nur meiner Organisation ({domains})",
|
||||
"audience_external": "Nur Leuten außerhalb meiner Organisation",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Sieve-Skript-Editor öffnen",
|
||||
"fetch_error": "Filter konnten nicht geladen werden",
|
||||
"expanded_view": "Erweiterte Ansicht",
|
||||
"show_rule_details": "Bedingungen und Aktionen jeder Regel anzeigen",
|
||||
"expanded_view_description": "Filterregeln mit detaillierten Bedingungs- und Aktionsblöcken anzeigen",
|
||||
"if": "Wenn",
|
||||
"then": "Dann",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Bitte ein Start- oder Enddatum für den Zeitraum angeben",
|
||||
"validation_period_invalid": "Bitte für den Zeitraum ein gültiges Datum mit Uhrzeit angeben oder das Feld leer lassen",
|
||||
"apply_existing_period": "Eine Regel mit Zeitraum wirkt nur auf Nachrichten, die in diesem Zeitraum eintreffen, und lässt sich daher nicht auf vorhandene Nachrichten anwenden.",
|
||||
"apply_existing_in": "Auch auf die Nachrichten anwenden, die schon in {folder} sind",
|
||||
"apply_existing_count": "{count, plural, one {Auch auf die # Nachricht anwenden, die schon in {folder} ist} other {Auch auf die # Nachrichten anwenden, die schon in {folder} sind}}",
|
||||
"apply_existing_none": "Noch keine Nachricht in {folder} passt zu dieser Regel.",
|
||||
"period_status_scheduled": "geplant",
|
||||
"period_status_active": "aktiv",
|
||||
"period_status_expired": "abgelaufen"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Clear search",
|
||||
"vacation_active": "Vacation responder is active",
|
||||
"vacation_turn_off": "Turn off",
|
||||
"demo_banner": "Demo Mode",
|
||||
"demo_reset": "Reset",
|
||||
"demo_tour": "Tour",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Leave it empty to start right away.",
|
||||
"until_label": "Until (optional)",
|
||||
"until_description": "Leave it empty to keep replying until you turn it off.",
|
||||
"from_placeholder": "Right away",
|
||||
"until_placeholder": "Until you turn it off",
|
||||
"audience_all": "Everyone",
|
||||
"audience_internal": "Only my organisation ({domains})",
|
||||
"audience_external": "Only people outside my organisation",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "Open raw Sieve editor",
|
||||
"fetch_error": "Couldn't load the filters",
|
||||
"expanded_view": "Expanded view",
|
||||
"show_rule_details": "Show each rule’s conditions and actions",
|
||||
"expanded_view_description": "Show filter rules with detailed condition and action blocks",
|
||||
"if": "If",
|
||||
"then": "Then",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "Enter a start or end date for the period",
|
||||
"validation_period_invalid": "Enter a valid date with a time for the period, or leave the field empty",
|
||||
"apply_existing_period": "A rule with a period acts only on messages that arrive within it, so it cannot run on existing messages.",
|
||||
"apply_existing_in": "Also apply to the messages already in {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Also apply to the # message already in {folder}} other {Also apply to the # messages already in {folder}}}",
|
||||
"apply_existing_none": "No message in {folder} matches this rule yet.",
|
||||
"period_status_scheduled": "scheduled",
|
||||
"period_status_active": "active",
|
||||
"period_status_expired": "expired"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Limpiar búsqueda",
|
||||
"vacation_active": "Respuesta automática activa",
|
||||
"vacation_turn_off": "Desactivar",
|
||||
"demo_banner": "Modo demo",
|
||||
"demo_reset": "Restablecer",
|
||||
"demo_tour": "Tour",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Déjalo vacío para empezar ya.",
|
||||
"until_label": "Hasta (opcional)",
|
||||
"until_description": "Déjalo vacío para seguir respondiendo hasta que lo desactives.",
|
||||
"from_placeholder": "Desde ahora",
|
||||
"until_placeholder": "Hasta que lo desactive",
|
||||
"audience_all": "Todos",
|
||||
"audience_internal": "Solo mi organización ({domains})",
|
||||
"audience_external": "Solo personas de fuera de mi organización",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Abrir editor Sieve",
|
||||
"fetch_error": "Error al cargar los filtros",
|
||||
"expanded_view": "Vista expandida",
|
||||
"show_rule_details": "Mostrar las condiciones y acciones de cada regla",
|
||||
"expanded_view_description": "Mostrar reglas de filtro con bloques detallados de condiciones y acciones",
|
||||
"if": "Si",
|
||||
"then": "Entonces",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Introduce una fecha de inicio o de fin para el período",
|
||||
"validation_period_invalid": "Introduce una fecha válida con hora para el período o deja el campo vacío",
|
||||
"apply_existing_period": "Una regla con período solo actúa sobre los mensajes que llegan dentro de él, por lo que no se puede aplicar a los mensajes existentes.",
|
||||
"apply_existing_in": "Aplicar también a los mensajes que ya están en {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Aplicar también al # mensaje que ya está en {folder}} other {Aplicar también a los # mensajes que ya están en {folder}}}",
|
||||
"apply_existing_none": "Ningún mensaje de {folder} coincide todavía con esta regla.",
|
||||
"period_status_scheduled": "programada",
|
||||
"period_status_active": "activa",
|
||||
"period_status_expired": "caducada"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Tühjenda otsing",
|
||||
"vacation_active": "Puhkuse vastaja on aktiivne",
|
||||
"vacation_turn_off": "Lülita välja",
|
||||
"demo_banner": "Demorežiim",
|
||||
"demo_reset": "Lähtesta",
|
||||
"demo_tour": "Tutvustus",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Jäta tühjaks, et alustada kohe.",
|
||||
"until_label": "Kuni (valikuline)",
|
||||
"until_description": "Jäta tühjaks, et vastata, kuni selle välja lülitad.",
|
||||
"from_placeholder": "Kohe",
|
||||
"until_placeholder": "Kuni selle välja lülitate",
|
||||
"audience_all": "Kõigile",
|
||||
"audience_internal": "Ainult minu organisatsioonile ({domains})",
|
||||
"audience_external": "Ainult inimestele väljastpoolt minu organisatsiooni",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "Ava Sieve lähtekoodi redaktor",
|
||||
"fetch_error": "Filtrite laadimine ebaõnnestus",
|
||||
"expanded_view": "Laiendatud vaade",
|
||||
"show_rule_details": "Näita iga reegli tingimusi ja toiminguid",
|
||||
"expanded_view_description": "Näita filtrireegleid koos üksikasjalike tingimuste ja tegevuste plokkidega",
|
||||
"if": "Kui",
|
||||
"then": "Siis",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "Sisesta ajavahemiku algus- või lõppkuupäev",
|
||||
"validation_period_invalid": "Sisesta ajavahemiku jaoks kehtiv kuupäev koos kellaajaga või jäta väli tühjaks",
|
||||
"apply_existing_period": "Ajavahemikuga reegel toimib ainult kirjadele, mis saabuvad selle jooksul, seega ei saa seda olemasolevatele kirjadele rakendada.",
|
||||
"apply_existing_in": "Rakenda ka kirjadele, mis on juba kaustas {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Rakenda ka # kirjale, mis on juba kaustas {folder}} other {Rakenda ka # kirjale, mis on juba kaustas {folder}}}",
|
||||
"apply_existing_none": "Kaustas {folder} pole veel ühtegi kirja, mis selle reegliga sobiks.",
|
||||
"period_status_scheduled": "ajastatud",
|
||||
"period_status_active": "aktiivne",
|
||||
"period_status_expired": "aegunud"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "پاک کردن جستجو",
|
||||
"vacation_active": "پاسخگوی خودکار فعال است",
|
||||
"vacation_turn_off": "خاموش کردن",
|
||||
"demo_banner": "حالت آزمایشی",
|
||||
"demo_reset": "بازنشانی",
|
||||
"demo_tour": "تور",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "برای شروع فوری خالی بگذارید.",
|
||||
"until_label": "تا (اختیاری)",
|
||||
"until_description": "خالی بگذارید تا پاسخ دادن تا زمانی که خاموشش کنید ادامه یابد.",
|
||||
"from_placeholder": "از همین حالا",
|
||||
"until_placeholder": "تا وقتی خاموشش کنید",
|
||||
"audience_all": "همه",
|
||||
"audience_internal": "فقط سازمان من ({domains})",
|
||||
"audience_external": "فقط افراد بیرون از سازمان من",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "باز کردن ویرایشگر خام Sieve",
|
||||
"fetch_error": "بارگذاری فیلترها ناموفق بود",
|
||||
"expanded_view": "نمای گسترده",
|
||||
"show_rule_details": "نمایش شرطها و کارهای هر قانون",
|
||||
"expanded_view_description": "نمایش قوانین فیلتر با بلوکهای شرط و عملیات جزئی",
|
||||
"if": "اگر",
|
||||
"then": "آنگاه",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "تاریخ شروع یا پایان بازه را وارد کنید",
|
||||
"validation_period_invalid": "برای بازه یک تاریخ معتبر همراه با ساعت وارد کنید یا فیلد را خالی بگذارید",
|
||||
"apply_existing_period": "قانونی که بازه زمانی دارد فقط روی پیامهایی عمل میکند که در آن بازه میرسند، بنابراین روی پیامهای موجود اجرا نمیشود.",
|
||||
"apply_existing_in": "روی پیامهایی هم که الان در {folder} هستند اعمال شود",
|
||||
"apply_existing_count": "{count, plural, one {روی # پیامی هم که الان در {folder} است اعمال شود} other {روی # پیامی هم که الان در {folder} هستند اعمال شود}}",
|
||||
"apply_existing_none": "هنوز هیچ پیامی در {folder} با این قانون جور نیست.",
|
||||
"period_status_scheduled": "زمانبندیشده",
|
||||
"period_status_active": "فعال",
|
||||
"period_status_expired": "منقضیشده"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Effacer la recherche",
|
||||
"vacation_active": "Répondeur d'absence activé",
|
||||
"vacation_turn_off": "Désactiver",
|
||||
"demo_banner": "Mode démo",
|
||||
"demo_reset": "Réinitialiser",
|
||||
"demo_tour": "Visite",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Laissez vide pour commencer tout de suite.",
|
||||
"until_label": "Au (facultatif)",
|
||||
"until_description": "Laissez vide pour continuer à répondre jusqu'à ce que vous le désactiviez.",
|
||||
"from_placeholder": "Dès maintenant",
|
||||
"until_placeholder": "Jusqu’à ce que vous le désactiviez",
|
||||
"audience_all": "Tout le monde",
|
||||
"audience_internal": "Seulement mon organisation ({domains})",
|
||||
"audience_external": "Seulement les personnes hors de mon organisation",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Ouvrir l'éditeur Sieve brut",
|
||||
"fetch_error": "Échec du chargement des filtres",
|
||||
"expanded_view": "Vue étendue",
|
||||
"show_rule_details": "Afficher les conditions et actions de chaque règle",
|
||||
"expanded_view_description": "Afficher les règles de filtre avec des blocs de conditions et d'actions détaillés",
|
||||
"if": "Si",
|
||||
"then": "Alors",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Indiquez une date de début ou de fin pour la période",
|
||||
"validation_period_invalid": "Indiquez une date valide avec l'heure pour la période, ou laissez le champ vide",
|
||||
"apply_existing_period": "Une règle avec une période n'agit que sur les messages qui arrivent pendant celle-ci ; elle ne peut donc pas s'appliquer aux messages existants.",
|
||||
"apply_existing_in": "Appliquer aussi aux messages déjà dans {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Appliquer aussi au # message déjà dans {folder}} other {Appliquer aussi aux # messages déjà dans {folder}}}",
|
||||
"apply_existing_none": "Aucun message de {folder} ne correspond encore à cette règle.",
|
||||
"period_status_scheduled": "planifiée",
|
||||
"period_status_active": "active",
|
||||
"period_status_expired": "expirée"
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
},
|
||||
"clear_search": "נקה חיפוש",
|
||||
"vacation_active": "משיב חופשה פעיל",
|
||||
"vacation_turn_off": "כיבוי",
|
||||
"demo_banner": "מצב הדגמה",
|
||||
"demo_reset": "אִתחוּל",
|
||||
"demo_tour": "סִיוּר",
|
||||
@@ -1336,6 +1337,8 @@
|
||||
"from_description": "השאירו ריק כדי להתחיל מיד.",
|
||||
"until_label": "עד (לא חובה)",
|
||||
"until_description": "השאירו ריק כדי להמשיך להשיב עד שתכבו.",
|
||||
"from_placeholder": "מיד",
|
||||
"until_placeholder": "עד שתכבו",
|
||||
"audience_all": "כולם",
|
||||
"audience_internal": "רק הארגון שלי ({domains})",
|
||||
"audience_external": "רק אנשים מחוץ לארגון שלי",
|
||||
@@ -2803,6 +2806,7 @@
|
||||
"open_sieve_editor": "פתח את עורך Sieve הגולמי",
|
||||
"fetch_error": "טעינת המסננים נכשלה",
|
||||
"expanded_view": "תצוגה מורחבת",
|
||||
"show_rule_details": "הצגת התנאים והפעולות של כל כלל",
|
||||
"expanded_view_description": "הצג כללי סינון עם תנאים מפורטים ואבני פעולה",
|
||||
"if": "אם",
|
||||
"then": "ואז",
|
||||
@@ -2884,6 +2888,9 @@
|
||||
"validation_period_empty": "יש להזין תאריך התחלה או סיום לטווח",
|
||||
"validation_period_invalid": "יש להזין תאריך תקין עם שעה לטווח, או להשאיר את השדה ריק",
|
||||
"apply_existing_period": "כלל עם טווח זמן פועל רק על הודעות שמגיעות בתוכו, ולכן לא ניתן להחיל אותו על הודעות קיימות.",
|
||||
"apply_existing_in": "להחיל גם על ההודעות שכבר נמצאות בתיקייה {folder}",
|
||||
"apply_existing_count": "{count, plural, one {להחיל גם על ההודעה שכבר נמצאת בתיקייה {folder}} other {להחיל גם על # ההודעות שכבר נמצאות בתיקייה {folder}}}",
|
||||
"apply_existing_none": "עדיין אין הודעה בתיקייה {folder} שמתאימה לכלל הזה.",
|
||||
"period_status_scheduled": "מתוזמן",
|
||||
"period_status_active": "פעיל",
|
||||
"period_status_expired": "פג תוקף"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Keresés törlése",
|
||||
"vacation_active": "Automatikus válasz aktív",
|
||||
"vacation_turn_off": "Kikapcsolás",
|
||||
"demo_banner": "Demó mód",
|
||||
"demo_reset": "Visszaállítás",
|
||||
"demo_tour": "Bemutató",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Hagyja üresen az azonnali kezdéshez.",
|
||||
"until_label": "Eddig (nem kötelező)",
|
||||
"until_description": "Hagyja üresen, hogy a válaszolás addig tartson, amíg ki nem kapcsolja.",
|
||||
"from_placeholder": "Azonnal",
|
||||
"until_placeholder": "Amíg ki nem kapcsolja",
|
||||
"audience_all": "Mindenkinek",
|
||||
"audience_internal": "Csak a szervezetemnek ({domains})",
|
||||
"audience_external": "Csak a szervezetemen kívülieknek",
|
||||
@@ -2909,6 +2912,7 @@
|
||||
"open_sieve_editor": "Nyers Sieve szerkesztő megnyitása",
|
||||
"fetch_error": "Nem sikerült betölteni a szűrőket",
|
||||
"expanded_view": "Részletes nézet",
|
||||
"show_rule_details": "Minden szabály feltételeinek és műveleteinek megjelenítése",
|
||||
"expanded_view_description": "Szűrőszabályok megjelenítése részletes feltétel- és műveletblokkokkal",
|
||||
"if": "Ha",
|
||||
"then": "Akkor",
|
||||
@@ -2987,6 +2991,9 @@
|
||||
"validation_period_empty": "Adjon meg kezdő vagy befejező dátumot az időszakhoz",
|
||||
"validation_period_invalid": "Adjon meg érvényes dátumot időponttal az időszakhoz, vagy hagyja üresen a mezőt",
|
||||
"apply_existing_period": "Az időszakhoz kötött szabály csak az abban érkező üzenetekre hat, ezért meglévő üzenetekre nem alkalmazható.",
|
||||
"apply_existing_in": "Alkalmazás a már itt lévő üzenetekre is: {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Alkalmazás a már itt lévő # üzenetre is: {folder}} other {Alkalmazás a már itt lévő # üzenetre is: {folder}}}",
|
||||
"apply_existing_none": "Itt még egy üzenet sem felel meg ennek a szabálynak: {folder}.",
|
||||
"period_status_scheduled": "ütemezett",
|
||||
"period_status_active": "aktív",
|
||||
"period_status_expired": "lejárt"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Cancella ricerca",
|
||||
"vacation_active": "Risponditore automatico attivo",
|
||||
"vacation_turn_off": "Disattiva",
|
||||
"demo_banner": "Modalità demo",
|
||||
"demo_reset": "Reimposta",
|
||||
"demo_tour": "Tour",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Lascia vuoto per iniziare subito.",
|
||||
"until_label": "Al (facoltativo)",
|
||||
"until_description": "Lascia vuoto per continuare a rispondere finché non lo disattivi.",
|
||||
"from_placeholder": "Da subito",
|
||||
"until_placeholder": "Finché non lo disattivi",
|
||||
"audience_all": "Tutti",
|
||||
"audience_internal": "Solo la mia organizzazione ({domains})",
|
||||
"audience_external": "Solo persone esterne alla mia organizzazione",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Apri editor Sieve",
|
||||
"fetch_error": "Impossibile caricare i filtri",
|
||||
"expanded_view": "Vista espansa",
|
||||
"show_rule_details": "Mostra condizioni e azioni di ogni regola",
|
||||
"expanded_view_description": "Mostra le regole dei filtri con blocchi dettagliati di condizioni e azioni",
|
||||
"if": "Se",
|
||||
"then": "Allora",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Inserisci una data di inizio o di fine per il periodo",
|
||||
"validation_period_invalid": "Inserisci una data valida con l'ora per il periodo oppure lascia vuoto il campo",
|
||||
"apply_existing_period": "Una regola con periodo agisce solo sui messaggi che arrivano al suo interno, quindi non si può applicare ai messaggi esistenti.",
|
||||
"apply_existing_in": "Applica anche ai messaggi già in {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Applica anche al # messaggio già in {folder}} other {Applica anche ai # messaggi già in {folder}}}",
|
||||
"apply_existing_none": "Nessun messaggio in {folder} corrisponde ancora a questa regola.",
|
||||
"period_status_scheduled": "programmata",
|
||||
"period_status_active": "attiva",
|
||||
"period_status_expired": "scaduta"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "検索をクリア",
|
||||
"vacation_active": "不在応答が有効です",
|
||||
"vacation_turn_off": "オフにする",
|
||||
"demo_banner": "デモモード",
|
||||
"demo_reset": "リセット",
|
||||
"demo_tour": "ツアー",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "空欄にするとすぐに始まります。",
|
||||
"until_label": "終了(任意)",
|
||||
"until_description": "空欄にすると、オフにするまで返信を続けます。",
|
||||
"from_placeholder": "今すぐ",
|
||||
"until_placeholder": "オフにするまで",
|
||||
"audience_all": "全員",
|
||||
"audience_internal": "自分の組織のみ({domains})",
|
||||
"audience_external": "組織外の人のみ",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Sieveスクリプトエディタを開く",
|
||||
"fetch_error": "フィルターの読み込みに失敗しました",
|
||||
"expanded_view": "詳細表示",
|
||||
"show_rule_details": "各ルールの条件と動作を表示",
|
||||
"expanded_view_description": "フィルタールールを条件とアクションのブロックで表示",
|
||||
"if": "条件",
|
||||
"then": "実行",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "期間の開始日または終了日を入力してください",
|
||||
"validation_period_invalid": "期間には時刻付きの有効な日付を入力するか、欄を空のままにしてください",
|
||||
"apply_existing_period": "期間付きのルールは期間内に届いたメッセージにのみ適用されるため、既存のメッセージには適用できません。",
|
||||
"apply_existing_in": "{folder} にあるメッセージにも適用する",
|
||||
"apply_existing_count": "{count, plural, other {{folder} にある # 件にも適用する}}",
|
||||
"apply_existing_none": "{folder} にこのルールに合うメッセージはまだありません。",
|
||||
"period_status_scheduled": "予定",
|
||||
"period_status_active": "有効",
|
||||
"period_status_expired": "期限切れ"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "검색어 지우기",
|
||||
"vacation_active": "부재중 자동 응답이 켜져 있어요",
|
||||
"vacation_turn_off": "끄기",
|
||||
"demo_banner": "데모 모드",
|
||||
"demo_reset": "초기화",
|
||||
"demo_tour": "둘러보기",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "비워 두면 바로 시작합니다.",
|
||||
"until_label": "종료 (선택)",
|
||||
"until_description": "비워 두면 끌 때까지 계속 답장합니다.",
|
||||
"from_placeholder": "지금부터",
|
||||
"until_placeholder": "끌 때까지",
|
||||
"audience_all": "모든 사람",
|
||||
"audience_internal": "내 조직만 ({domains})",
|
||||
"audience_external": "내 조직 밖의 사람만",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "원본 Sieve 에디터 열기",
|
||||
"fetch_error": "필터를 불러오지 못했어요",
|
||||
"expanded_view": "자세히 보기",
|
||||
"show_rule_details": "각 규칙의 조건과 동작 표시",
|
||||
"expanded_view_description": "필터 규칙의 조건과 동작을 모두 펼쳐서 보여줘요",
|
||||
"if": "만약 (If)",
|
||||
"then": "다음 동작 실행 (Then)",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "기간의 시작일 또는 종료일을 입력해 주세요",
|
||||
"validation_period_invalid": "기간에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요",
|
||||
"apply_existing_period": "기간이 설정된 규칙은 그 기간에 도착한 메시지에만 적용되므로 기존 메시지에는 적용할 수 없어요.",
|
||||
"apply_existing_in": "{folder}에 이미 있는 메일에도 적용",
|
||||
"apply_existing_count": "{count, plural, other {{folder}에 이미 있는 메일 #개에도 적용}}",
|
||||
"apply_existing_none": "{folder}에 이 규칙에 맞는 메일이 아직 없습니다.",
|
||||
"period_status_scheduled": "예정",
|
||||
"period_status_active": "활성",
|
||||
"period_status_expired": "만료"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Notīrīt meklēšanu",
|
||||
"vacation_active": "Atvaļinājuma atbilde ir aktīva",
|
||||
"vacation_turn_off": "Izslēgt",
|
||||
"demo_banner": "Demo režīms",
|
||||
"demo_reset": "Atiestatīt",
|
||||
"demo_tour": "Ekskursija",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Atstājiet tukšu, lai sāktu uzreiz.",
|
||||
"until_label": "Līdz (neobligāti)",
|
||||
"until_description": "Atstājiet tukšu, lai atbildētu, līdz to izslēgsiet.",
|
||||
"from_placeholder": "Uzreiz",
|
||||
"until_placeholder": "Līdz to izslēgsiet",
|
||||
"audience_all": "Visiem",
|
||||
"audience_internal": "Tikai manai organizācijai ({domains})",
|
||||
"audience_external": "Tikai cilvēkiem ārpus manas organizācijas",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Atvērt Sieve redaktoru",
|
||||
"fetch_error": "Neizdevās ielādēt filtrus",
|
||||
"expanded_view": "Izvērstais skats",
|
||||
"show_rule_details": "Rādīt katra noteikuma nosacījumus un darbības",
|
||||
"expanded_view_description": "Rādīt filtra noteikumus ar detalizētiem nosacījumiem un darbībām",
|
||||
"if": "Ja",
|
||||
"then": "Tad",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Norādiet laika posma sākuma vai beigu datumu",
|
||||
"validation_period_invalid": "Norādiet laika posmam derīgu datumu ar laiku vai atstājiet lauku tukšu",
|
||||
"apply_existing_period": "Noteikums ar laika posmu darbojas tikai ar ziņojumiem, kas pienāk šajā posmā, tāpēc to nevar lietot esošajiem ziņojumiem.",
|
||||
"apply_existing_in": "Piemērot arī vēstulēm, kas jau ir mapē {folder}",
|
||||
"apply_existing_count": "{count, plural, zero {Piemērot arī # vēstulēm, kas jau ir mapē {folder}} one {Piemērot arī # vēstulei, kas jau ir mapē {folder}} other {Piemērot arī # vēstulēm, kas jau ir mapē {folder}}}",
|
||||
"apply_existing_none": "Mapē {folder} vēl nav nevienas vēstules, kas atbilst šim noteikumam.",
|
||||
"period_status_scheduled": "ieplānots",
|
||||
"period_status_active": "aktīvs",
|
||||
"period_status_expired": "beidzies"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Хайлтыг арилгах",
|
||||
"vacation_active": "Амралтын хариулагч идэвхтэй байна",
|
||||
"vacation_turn_off": "Унтраах",
|
||||
"demo_banner": "Демо горим",
|
||||
"demo_reset": "Дахин тохируулах",
|
||||
"demo_tour": "Аялал",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Шууд эхлүүлэхийн тулд хоосон үлдээнэ үү.",
|
||||
"until_label": "Дуусах (заавал биш)",
|
||||
"until_description": "Унтраах хүртэл хариулсаар байхын тулд хоосон үлдээнэ үү.",
|
||||
"from_placeholder": "Одоо даруй",
|
||||
"until_placeholder": "Таныг унтраах хүртэл",
|
||||
"audience_all": "Бүгдэд",
|
||||
"audience_internal": "Зөвхөн миний байгууллагад ({domains})",
|
||||
"audience_external": "Зөвхөн байгууллагаас гадуурх хүмүүст",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "Raw Sieve редакторыг нээнэ үү",
|
||||
"fetch_error": "Шүүлтүүрийг ачаалж чадсангүй",
|
||||
"expanded_view": "Өргөтгөсөн харах",
|
||||
"show_rule_details": "Дүрэм бүрийн нөхцөл, үйлдлийг харуулах",
|
||||
"expanded_view_description": "Шүүлтүүрийн дүрмийг нарийвчилсан нөхцөл, үйлдлийн блокоор харуулах",
|
||||
"if": "Хэрэв",
|
||||
"then": "Дараа нь",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "Хугацааны эхлэх эсвэл төгсгөлийн огноог оруулна уу",
|
||||
"validation_period_invalid": "Хугацаанд цагтай хүчинтэй огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү",
|
||||
"apply_existing_period": "Хугацаатай дүрэм зөвхөн тэр хугацаанд ирсэн мессежид үйлчилдэг тул одоо байгаа мессежид хэрэглэх боломжгүй.",
|
||||
"apply_existing_in": "{folder} доторх байгаа захианд бас хэрэглэх",
|
||||
"apply_existing_count": "{count, plural, one {{folder} доторх байгаа # захианд бас хэрэглэх} other {{folder} доторх байгаа # захианд бас хэрэглэх}}",
|
||||
"apply_existing_none": "{folder} дотор энэ дүрэмд тохирох захиа одоогоор алга.",
|
||||
"period_status_scheduled": "төлөвлөсөн",
|
||||
"period_status_active": "идэвхтэй",
|
||||
"period_status_expired": "хугацаа дууссан"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Tøm søket",
|
||||
"vacation_active": "Feriesvar er aktivt",
|
||||
"vacation_turn_off": "Slå av",
|
||||
"demo_banner": "Demomodus",
|
||||
"demo_reset": "Tilbakestill",
|
||||
"demo_tour": "Omvisning",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "La feltet stå tomt for å starte med en gang.",
|
||||
"until_label": "Til (valgfritt)",
|
||||
"until_description": "La feltet stå tomt for å fortsette å svare til du slår det av.",
|
||||
"from_placeholder": "Med en gang",
|
||||
"until_placeholder": "Til du slår det av",
|
||||
"audience_all": "Alle",
|
||||
"audience_internal": "Bare organisasjonen min ({domains})",
|
||||
"audience_external": "Bare folk utenfor organisasjonen min",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "Åpne redigereren for Sieve-kode",
|
||||
"fetch_error": "Kunne ikke laste inn filtre",
|
||||
"expanded_view": "Utvidet visning",
|
||||
"show_rule_details": "Vis betingelsene og handlingene for hver regel",
|
||||
"expanded_view_description": "Vis filterregler med detaljerte blokker for vilkår og handlinger",
|
||||
"if": "Hvis",
|
||||
"then": "Gjør følgende",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "Angi en start- eller sluttdato for perioden",
|
||||
"validation_period_invalid": "Angi en gyldig dato med klokkeslett for perioden, eller la feltet stå tomt",
|
||||
"apply_existing_period": "En regel med periode virker bare på meldinger som kommer i perioden, og kan derfor ikke brukes på eksisterende meldinger.",
|
||||
"apply_existing_in": "Bruk også på meldingene som allerede er i {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Bruk også på den # meldingen som allerede er i {folder}} other {Bruk også på de # meldingene som allerede er i {folder}}}",
|
||||
"apply_existing_none": "Ingen meldinger i {folder} passer med regelen ennå.",
|
||||
"period_status_scheduled": "planlagt",
|
||||
"period_status_active": "aktiv",
|
||||
"period_status_expired": "utløpt"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Zoekopdracht wissen",
|
||||
"vacation_active": "Afwezigheidsmelder is actief",
|
||||
"vacation_turn_off": "Uitzetten",
|
||||
"demo_banner": "Demomodus",
|
||||
"demo_reset": "Resetten",
|
||||
"demo_tour": "Rondleiding",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Laat leeg om meteen te beginnen.",
|
||||
"until_label": "Tot (optioneel)",
|
||||
"until_description": "Laat leeg om te blijven antwoorden tot je het uitzet.",
|
||||
"from_placeholder": "Meteen",
|
||||
"until_placeholder": "Tot u het uitzet",
|
||||
"audience_all": "Iedereen",
|
||||
"audience_internal": "Alleen mijn organisatie ({domains})",
|
||||
"audience_external": "Alleen mensen buiten mijn organisatie",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Sieve-scripteditor openen",
|
||||
"fetch_error": "Filters konden niet worden geladen",
|
||||
"expanded_view": "Uitgebreide weergave",
|
||||
"show_rule_details": "Voorwaarden en acties van elke regel tonen",
|
||||
"expanded_view_description": "Filterregels weergeven met gedetailleerde voorwaarde- en actieblokken",
|
||||
"if": "Als",
|
||||
"then": "Dan",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Vul een start- of einddatum voor de periode in",
|
||||
"validation_period_invalid": "Vul voor de periode een geldige datum met tijd in, of laat het veld leeg",
|
||||
"apply_existing_period": "Een regel met een periode werkt alleen op berichten die binnen die periode binnenkomen en kan dus niet op bestaande berichten worden toegepast.",
|
||||
"apply_existing_in": "Ook toepassen op de berichten die al in {folder} staan",
|
||||
"apply_existing_count": "{count, plural, one {Ook toepassen op het # bericht dat al in {folder} staat} other {Ook toepassen op de # berichten die al in {folder} staan}}",
|
||||
"apply_existing_none": "Nog geen bericht in {folder} past bij deze regel.",
|
||||
"period_status_scheduled": "gepland",
|
||||
"period_status_active": "actief",
|
||||
"period_status_expired": "verlopen"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Wyczyść wyszukiwanie",
|
||||
"vacation_active": "Autoresponder wakacyjny jest aktywny",
|
||||
"vacation_turn_off": "Wyłącz",
|
||||
"demo_banner": "Tryb demo",
|
||||
"demo_reset": "Resetuj",
|
||||
"demo_tour": "Przewodnik",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Zostaw puste, aby zacząć od razu.",
|
||||
"until_label": "Do (opcjonalnie)",
|
||||
"until_description": "Zostaw puste, aby odpowiadać, dopóki tego nie wyłączysz.",
|
||||
"from_placeholder": "Od teraz",
|
||||
"until_placeholder": "Dopóki jej nie wyłączysz",
|
||||
"audience_all": "Wszystkim",
|
||||
"audience_internal": "Tylko mojej organizacji ({domains})",
|
||||
"audience_external": "Tylko osobom spoza mojej organizacji",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Otwórz surowy edytor Sieve",
|
||||
"fetch_error": "Nie udało się załadować filtrów",
|
||||
"expanded_view": "Widok rozszerzony",
|
||||
"show_rule_details": "Pokaż warunki i działania każdej reguły",
|
||||
"expanded_view_description": "Pokazuj reguły filtrów ze szczegółowymi blokami warunków i akcji",
|
||||
"if": "Jeśli",
|
||||
"then": "Wtedy",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Podaj datę rozpoczęcia lub zakończenia okresu",
|
||||
"validation_period_invalid": "Podaj prawidłową datę z godziną dla okresu albo pozostaw pole puste",
|
||||
"apply_existing_period": "Reguła z okresem działa tylko na wiadomości, które przychodzą w tym okresie, dlatego nie można jej zastosować do istniejących wiadomości.",
|
||||
"apply_existing_in": "Zastosuj też do wiadomości, które już są w folderze {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Zastosuj też do # wiadomości, która już jest w folderze {folder}} few {Zastosuj też do # wiadomości, które już są w folderze {folder}} many {Zastosuj też do # wiadomości, które już są w folderze {folder}} other {Zastosuj też do # wiadomości, które już są w folderze {folder}}}",
|
||||
"apply_existing_none": "Żadna wiadomość w folderze {folder} nie pasuje jeszcze do tej reguły.",
|
||||
"period_status_scheduled": "zaplanowana",
|
||||
"period_status_active": "aktywna",
|
||||
"period_status_expired": "wygasła"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Limpar busca",
|
||||
"vacation_active": "Resposta automática ativa",
|
||||
"vacation_turn_off": "Desativar",
|
||||
"demo_banner": "Modo de demonstração",
|
||||
"demo_reset": "Repor",
|
||||
"demo_tour": "Tour",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Deixe vazio para começar já.",
|
||||
"until_label": "Até (opcional)",
|
||||
"until_description": "Deixe vazio para continuar a responder até desligar.",
|
||||
"from_placeholder": "A partir de agora",
|
||||
"until_placeholder": "Até desativar",
|
||||
"audience_all": "Todos",
|
||||
"audience_internal": "Só a minha organização ({domains})",
|
||||
"audience_external": "Só pessoas de fora da minha organização",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Abrir editor Sieve",
|
||||
"fetch_error": "Falha ao carregar filtros",
|
||||
"expanded_view": "Vista expandida",
|
||||
"show_rule_details": "Mostrar as condições e ações de cada regra",
|
||||
"expanded_view_description": "Mostrar regras de filtro com blocos detalhados de condições e ações",
|
||||
"if": "Se",
|
||||
"then": "Então",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Indique uma data de início ou de fim para o período",
|
||||
"validation_period_invalid": "Indique uma data válida com hora para o período ou deixe o campo vazio",
|
||||
"apply_existing_period": "Uma regra com período só atua sobre as mensagens que chegam dentro dele, por isso não pode ser aplicada às mensagens existentes.",
|
||||
"apply_existing_in": "Aplicar também às mensagens que já estão em {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Aplicar também à # mensagem que já está em {folder}} other {Aplicar também às # mensagens que já estão em {folder}}}",
|
||||
"apply_existing_none": "Nenhuma mensagem em {folder} corresponde ainda a esta regra.",
|
||||
"period_status_scheduled": "agendada",
|
||||
"period_status_active": "ativa",
|
||||
"period_status_expired": "expirada"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Șterge căutarea",
|
||||
"vacation_active": "Răspunsul automat de vacanță este activ",
|
||||
"vacation_turn_off": "Dezactivați",
|
||||
"demo_banner": "Modul demo",
|
||||
"demo_reset": "Resetare",
|
||||
"demo_tour": "Tur de prezentare",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Lăsați gol pentru a începe imediat.",
|
||||
"until_label": "Până la (opțional)",
|
||||
"until_description": "Lăsați gol pentru a răspunde până îl dezactivați.",
|
||||
"from_placeholder": "Imediat",
|
||||
"until_placeholder": "Până îl dezactivați",
|
||||
"audience_all": "Tuturor",
|
||||
"audience_internal": "Doar organizației mele ({domains})",
|
||||
"audience_external": "Doar persoanelor din afara organizației mele",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "Deschideți editorul de fișiere Sieve în format brut",
|
||||
"fetch_error": "Nu s-au putut încărca filtrele",
|
||||
"expanded_view": "Vizualizare extinsă",
|
||||
"show_rule_details": "Afișați condițiile și acțiunile fiecărei reguli",
|
||||
"expanded_view_description": "Afișați regulile de filtrare cu blocuri detaliate de condiții și acțiuni",
|
||||
"if": "Dacă",
|
||||
"then": "Apoi",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "Introduceți o dată de începere sau de încheiere pentru perioadă",
|
||||
"validation_period_invalid": "Introduceți o dată validă cu oră pentru perioadă sau lăsați câmpul gol",
|
||||
"apply_existing_period": "O regulă cu perioadă acționează doar asupra mesajelor care sosesc în acea perioadă, deci nu poate fi aplicată mesajelor existente.",
|
||||
"apply_existing_in": "Aplicați și mesajelor care sunt deja în {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Aplicați și # mesajului care este deja în {folder}} few {Aplicați și celor # mesaje care sunt deja în {folder}} other {Aplicați și celor # de mesaje care sunt deja în {folder}}}",
|
||||
"apply_existing_none": "Niciun mesaj din {folder} nu se potrivește încă acestei reguli.",
|
||||
"period_status_scheduled": "programată",
|
||||
"period_status_active": "activă",
|
||||
"period_status_expired": "expirată"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Очистить поиск",
|
||||
"vacation_active": "Автоответ на отпуск активен",
|
||||
"vacation_turn_off": "Выключить",
|
||||
"demo_banner": "Режим демо",
|
||||
"demo_reset": "Сбросить",
|
||||
"demo_tour": "Тур",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Оставьте пустым, чтобы начать сразу.",
|
||||
"until_label": "По (необязательно)",
|
||||
"until_description": "Оставьте пустым, чтобы отвечать, пока вы это не выключите.",
|
||||
"from_placeholder": "Сразу",
|
||||
"until_placeholder": "Пока вы не выключите",
|
||||
"audience_all": "Всем",
|
||||
"audience_internal": "Только моей организации ({domains})",
|
||||
"audience_external": "Только тем, кто вне моей организации",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Открыть редактор Sieve",
|
||||
"fetch_error": "Не удалось загрузить фильтры",
|
||||
"expanded_view": "Расширенный вид",
|
||||
"show_rule_details": "Показывать условия и действия каждого правила",
|
||||
"expanded_view_description": "Показывать правила фильтрации с подробными блоками условий и действий",
|
||||
"if": "Если",
|
||||
"then": "То",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Укажите дату начала или окончания периода",
|
||||
"validation_period_invalid": "Укажите для периода корректную дату со временем или оставьте поле пустым",
|
||||
"apply_existing_period": "Правило с периодом действует только на письма, пришедшие в этот период, поэтому его нельзя применить к уже имеющимся письмам.",
|
||||
"apply_existing_in": "Применить и к письмам, которые уже есть в папке {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Применить и к # письму, которое уже есть в папке {folder}} few {Применить и к # письмам, которые уже есть в папке {folder}} many {Применить и к # письмам, которые уже есть в папке {folder}} other {Применить и к # письма, которые уже есть в папке {folder}}}",
|
||||
"apply_existing_none": "В папке {folder} пока нет писем под это правило.",
|
||||
"period_status_scheduled": "запланировано",
|
||||
"period_status_active": "активно",
|
||||
"period_status_expired": "истекло"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Vymazať hľadanie",
|
||||
"vacation_active": "Automatická odpoveď je aktívna",
|
||||
"vacation_turn_off": "Vypnúť",
|
||||
"demo_banner": "Demo režim",
|
||||
"demo_reset": "Resetovať",
|
||||
"demo_tour": "Sprievodca",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Nechajte prázdne, aby sa začalo hneď.",
|
||||
"until_label": "Do (nepovinné)",
|
||||
"until_description": "Nechajte prázdne, aby sa odpovedalo, kým to nevypnete.",
|
||||
"from_placeholder": "Hneď",
|
||||
"until_placeholder": "Kým ju nevypnete",
|
||||
"audience_all": "Všetkým",
|
||||
"audience_internal": "Len mojej organizácii ({domains})",
|
||||
"audience_external": "Len ľuďom mimo mojej organizácie",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "Otvoriť textový editor Sieve",
|
||||
"fetch_error": "Nepodarilo sa načítať filtre",
|
||||
"expanded_view": "Rozšírené zobrazenie",
|
||||
"show_rule_details": "Zobraziť podmienky a akcie každého pravidla",
|
||||
"expanded_view_description": "Zobrazovať pravidlá filtrov s podrobnými blokmi podmienok a akcií",
|
||||
"if": "Ak",
|
||||
"then": "Potom",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "Zadajte dátum začiatku alebo ukončenia obdobia",
|
||||
"validation_period_invalid": "Zadajte pre obdobie platný dátum aj s časom alebo pole nechajte prázdne",
|
||||
"apply_existing_period": "Pravidlo s obdobím pôsobí len na správy, ktoré v ňom prídu, preto ho nemožno použiť na existujúce správy.",
|
||||
"apply_existing_in": "Použiť aj na správy, ktoré už sú v priečinku {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Použiť aj na # správu, ktorá už je v priečinku {folder}} few {Použiť aj na # správy, ktoré už sú v priečinku {folder}} many {Použiť aj na # správy, ktoré už sú v priečinku {folder}} other {Použiť aj na # správ, ktoré už sú v priečinku {folder}}}",
|
||||
"apply_existing_none": "Tomuto pravidlu zatiaľ nezodpovedá žiadna správa v priečinku {folder}.",
|
||||
"period_status_scheduled": "naplánované",
|
||||
"period_status_active": "aktívne",
|
||||
"period_status_expired": "uplynulo"
|
||||
|
||||
@@ -320,6 +320,7 @@
|
||||
},
|
||||
"clear_search": "ล้างการค้นหา",
|
||||
"vacation_active": "เปิดใช้งานการตอบกลับอัตโนมัติอยู่",
|
||||
"vacation_turn_off": "ปิด",
|
||||
"demo_banner": "โหมดทดลองใช้งาน (Demo)",
|
||||
"demo_reset": "รีเซ็ตข้อมูล",
|
||||
"demo_tour": "แนะนำการใช้งาน",
|
||||
@@ -749,6 +750,8 @@
|
||||
"from_description": "เว้นว่างไว้เพื่อเริ่มทันที",
|
||||
"until_label": "จนถึง (ไม่บังคับ)",
|
||||
"until_description": "เว้นว่างไว้เพื่อตอบกลับต่อไปจนกว่าคุณจะปิด",
|
||||
"from_placeholder": "ทันที",
|
||||
"until_placeholder": "จนกว่าคุณจะปิด",
|
||||
"audience_all": "ทุกคน",
|
||||
"audience_internal": "เฉพาะองค์กรของฉัน ({domains})",
|
||||
"audience_external": "เฉพาะคนนอกองค์กรของฉัน",
|
||||
@@ -5913,6 +5916,7 @@
|
||||
"open_sieve_editor": "เปิดตัวแก้ไข Sieve โดยตรง",
|
||||
"fetch_error": "โหลดตัวกรองไม่สำเร็จ",
|
||||
"expanded_view": "มุมมองขยาย",
|
||||
"show_rule_details": "แสดงเงื่อนไขและการทำงานของแต่ละกฎ",
|
||||
"expanded_view_description": "แสดงกฎตัวกรองพร้อมบล็อกเงื่อนไขและการดำเนินการโดยละเอียด",
|
||||
"if": "ถ้า (If)",
|
||||
"then": "แล้ว (Then)",
|
||||
@@ -5991,6 +5995,9 @@
|
||||
"validation_period_empty": "ป้อนวันที่เริ่มต้นหรือวันที่สิ้นสุดสำหรับช่วงเวลา",
|
||||
"validation_period_invalid": "ป้อนวันที่และเวลาที่ถูกต้อง หรือเว้นว่างไว้",
|
||||
"apply_existing_period": "กฎที่มีการกำหนดช่วงเวลาจะมีผลกับข้อความที่มาถึงในช่วงเวลานั้นเท่านั้น จึงไม่สามารถใช้กับข้อความที่มีอยู่ได้",
|
||||
"apply_existing_in": "ใช้กับข้อความที่อยู่ใน {folder} แล้วด้วย",
|
||||
"apply_existing_count": "{count, plural, other {ใช้กับข้อความ # ฉบับที่อยู่ใน {folder} แล้วด้วย}}",
|
||||
"apply_existing_none": "ยังไม่มีข้อความใน {folder} ที่ตรงกับกฎนี้",
|
||||
"period_status_scheduled": "กำหนดเวลาแล้ว",
|
||||
"period_status_active": "ใช้งานอยู่",
|
||||
"period_status_expired": "หมดอายุแล้ว"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Aramayı temizle",
|
||||
"vacation_active": "Tatil otomatik yanıtı aktif",
|
||||
"vacation_turn_off": "Kapat",
|
||||
"demo_banner": "Demo Modu",
|
||||
"demo_reset": "Sıfırla",
|
||||
"demo_tour": "Tur",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Hemen başlamak için boş bırakın.",
|
||||
"until_label": "Bitiş (isteğe bağlı)",
|
||||
"until_description": "Siz kapatana kadar yanıt vermeye devam etmek için boş bırakın.",
|
||||
"from_placeholder": "Hemen",
|
||||
"until_placeholder": "Siz kapatana kadar",
|
||||
"audience_all": "Herkese",
|
||||
"audience_internal": "Yalnızca kuruluşuma ({domains})",
|
||||
"audience_external": "Yalnızca kuruluşum dışındakilere",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Ham Sieve düzenleyicisini aç",
|
||||
"fetch_error": "Filtreler yüklenemedi",
|
||||
"expanded_view": "Genişletilmiş görünüm",
|
||||
"show_rule_details": "Her kuralın koşullarını ve işlemlerini göster",
|
||||
"expanded_view_description": "Filtre kurallarını ayrıntılı koşul ve işlem bloklarıyla göster",
|
||||
"if": "Eğer",
|
||||
"then": "O zaman",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Dönem için bir başlangıç veya bitiş tarihi girin",
|
||||
"validation_period_invalid": "Dönem için saatiyle birlikte geçerli bir tarih girin veya alanı boş bırakın",
|
||||
"apply_existing_period": "Dönemi olan bir kural yalnızca o dönemde gelen iletilere uygulanır, bu nedenle mevcut iletilere uygulanamaz.",
|
||||
"apply_existing_in": "{folder} içindeki mevcut iletilere de uygula",
|
||||
"apply_existing_count": "{count, plural, one {{folder} içindeki # mevcut iletiye de uygula} other {{folder} içindeki # mevcut iletiye de uygula}}",
|
||||
"apply_existing_none": "{folder} içinde bu kurala uyan ileti henüz yok.",
|
||||
"period_status_scheduled": "planlandı",
|
||||
"period_status_active": "etkin",
|
||||
"period_status_expired": "süresi doldu"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Очистити пошук",
|
||||
"vacation_active": "Автовідповідач активний",
|
||||
"vacation_turn_off": "Вимкнути",
|
||||
"demo_banner": "Демонстраційний режим",
|
||||
"demo_reset": "Скинути",
|
||||
"demo_tour": "Тур",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "Залиште порожнім, щоб почати одразу.",
|
||||
"until_label": "До (необов'язково)",
|
||||
"until_description": "Залиште порожнім, щоб відповідати, доки ви це не вимкнете.",
|
||||
"from_placeholder": "Одразу",
|
||||
"until_placeholder": "Доки ви не вимкнете",
|
||||
"audience_all": "Усім",
|
||||
"audience_internal": "Лише моїй організації ({domains})",
|
||||
"audience_external": "Лише тим, хто поза моєю організацією",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "Відкрийте редактор Raw Sieve",
|
||||
"fetch_error": "Не вдалося завантажити фільтри",
|
||||
"expanded_view": "Розгорнутий вигляд",
|
||||
"show_rule_details": "Показувати умови й дії кожного правила",
|
||||
"expanded_view_description": "Показати правила фільтрації з детальними умовами та блоками дій",
|
||||
"if": "Якщо",
|
||||
"then": "Потім",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "Вкажіть дату початку або закінчення періоду",
|
||||
"validation_period_invalid": "Вкажіть для періоду дійсну дату з часом або залиште поле порожнім",
|
||||
"apply_existing_period": "Правило з періодом діє лише на листи, що надходять у цей період, тому його не можна застосувати до наявних листів.",
|
||||
"apply_existing_in": "Застосувати й до листів, які вже є в теці {folder}",
|
||||
"apply_existing_count": "{count, plural, one {Застосувати й до # листа, який уже є в теці {folder}} few {Застосувати й до # листів, які вже є в теці {folder}} many {Застосувати й до # листів, які вже є в теці {folder}} other {Застосувати й до # листа, які вже є в теці {folder}}}",
|
||||
"apply_existing_none": "У теці {folder} поки немає листів під це правило.",
|
||||
"period_status_scheduled": "заплановано",
|
||||
"period_status_active": "активне",
|
||||
"period_status_expired": "завершено"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "Xóa tìm kiếm",
|
||||
"vacation_active": "Đang bật trả lời tự động khi vắng mặt",
|
||||
"vacation_turn_off": "Tắt",
|
||||
"demo_banner": "Chế độ demo",
|
||||
"demo_reset": "Đặt lại",
|
||||
"demo_tour": "Tham quan",
|
||||
@@ -1405,6 +1406,8 @@
|
||||
"from_description": "Để trống để bắt đầu ngay.",
|
||||
"until_label": "Đến (không bắt buộc)",
|
||||
"until_description": "Để trống để tiếp tục trả lời cho đến khi bạn tắt.",
|
||||
"from_placeholder": "Ngay bây giờ",
|
||||
"until_placeholder": "Cho đến khi bạn tắt",
|
||||
"audience_all": "Mọi người",
|
||||
"audience_internal": "Chỉ tổ chức của tôi ({domains})",
|
||||
"audience_external": "Chỉ người ngoài tổ chức của tôi",
|
||||
@@ -2925,6 +2928,7 @@
|
||||
"open_sieve_editor": "Mở trình chỉnh sửa mã Sieve",
|
||||
"fetch_error": "Không thể tải bộ lọc",
|
||||
"expanded_view": "Chế độ xem mở rộng",
|
||||
"show_rule_details": "Hiện điều kiện và hành động của từng quy tắc",
|
||||
"expanded_view_description": "Hiển thị quy tắc lọc với các khối hành động và điều kiện chi tiết",
|
||||
"if": "Nếu",
|
||||
"then": "Sau đó",
|
||||
@@ -3003,6 +3007,9 @@
|
||||
"validation_period_empty": "Nhập ngày bắt đầu hoặc ngày kết thúc cho khoảng thời gian",
|
||||
"validation_period_invalid": "Nhập ngày hợp lệ cùng với thời gian trong khoảng thời gian hoặc để trống trường",
|
||||
"apply_existing_period": "Quy tắc có giới hạn thời gian chỉ áp dụng cho thư đến trong khoảng đó, nên không thể chạy trên các thư hiện có.",
|
||||
"apply_existing_in": "Áp dụng cả cho các thư đã có trong {folder}",
|
||||
"apply_existing_count": "{count, plural, other {Áp dụng cả cho # thư đã có trong {folder}}}",
|
||||
"apply_existing_none": "Chưa có thư nào trong {folder} khớp với quy tắc này.",
|
||||
"period_status_scheduled": "Theo lịch trình",
|
||||
"period_status_active": "Hoạt động",
|
||||
"period_status_expired": "Hết hạn"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "清除搜尋",
|
||||
"vacation_active": "休假自動回覆已開啟",
|
||||
"vacation_turn_off": "關閉",
|
||||
"demo_banner": "示範模式",
|
||||
"demo_reset": "重設",
|
||||
"demo_tour": "功能導覽",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "留空即立即開始。",
|
||||
"until_label": "結束(選填)",
|
||||
"until_description": "留空即持續回覆,直到您關閉。",
|
||||
"from_placeholder": "立即",
|
||||
"until_placeholder": "直到您關閉",
|
||||
"audience_all": "所有人",
|
||||
"audience_internal": "僅限我的組織({domains})",
|
||||
"audience_external": "僅限組織外的人",
|
||||
@@ -2913,6 +2916,7 @@
|
||||
"open_sieve_editor": "開啟原始 Sieve 編輯器",
|
||||
"fetch_error": "無法載入篩選規則",
|
||||
"expanded_view": "展開檢視",
|
||||
"show_rule_details": "顯示每條規則的條件和動作",
|
||||
"expanded_view_description": "顯示包含詳細條件與動作區塊的篩選規則",
|
||||
"if": "如果",
|
||||
"then": "然後",
|
||||
@@ -2991,6 +2995,9 @@
|
||||
"validation_period_empty": "請輸入期間的開始日期或結束日期",
|
||||
"validation_period_invalid": "請為期間輸入含時間的有效日期,或將該欄位留空",
|
||||
"apply_existing_period": "設定了期間的規則只作用於在該期間內收到的郵件,因此無法套用到既有郵件。",
|
||||
"apply_existing_in": "同時套用到{folder}中既有的郵件",
|
||||
"apply_existing_count": "{count, plural, other {同時套用到{folder}中既有的 # 封郵件}}",
|
||||
"apply_existing_none": "{folder}中還沒有符合此規則的郵件。",
|
||||
"period_status_scheduled": "尚未開始",
|
||||
"period_status_active": "生效中",
|
||||
"period_status_expired": "已過期"
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
},
|
||||
"clear_search": "清除搜索",
|
||||
"vacation_active": "假期自动回复已开启",
|
||||
"vacation_turn_off": "关闭",
|
||||
"demo_banner": "演示模式",
|
||||
"demo_reset": "重置",
|
||||
"demo_tour": "引导",
|
||||
@@ -1393,6 +1394,8 @@
|
||||
"from_description": "留空则立即开始。",
|
||||
"until_label": "结束(可选)",
|
||||
"until_description": "留空则一直回复,直到您关闭。",
|
||||
"from_placeholder": "立即",
|
||||
"until_placeholder": "直到您关闭",
|
||||
"audience_all": "所有人",
|
||||
"audience_internal": "仅限我的组织({domains})",
|
||||
"audience_external": "仅限组织外的人",
|
||||
@@ -2906,6 +2909,7 @@
|
||||
"open_sieve_editor": "打开原始 Sieve 编辑器",
|
||||
"fetch_error": "加载过滤器失败",
|
||||
"expanded_view": "展开视图",
|
||||
"show_rule_details": "显示每条规则的条件和操作",
|
||||
"expanded_view_description": "显示包含详细条件和操作的过滤规则",
|
||||
"if": "如果",
|
||||
"then": "然后",
|
||||
@@ -2984,6 +2988,9 @@
|
||||
"validation_period_empty": "请输入时间段的开始日期或结束日期",
|
||||
"validation_period_invalid": "请为时间段输入带时间的有效日期,或将该字段留空",
|
||||
"apply_existing_period": "设置了时间段的规则只作用于在该时间段内收到的邮件,因此无法应用于已有邮件。",
|
||||
"apply_existing_in": "同时应用于{folder}中已有的邮件",
|
||||
"apply_existing_count": "{count, plural, other {同时应用于{folder}中已有的 # 封邮件}}",
|
||||
"apply_existing_none": "{folder}中还没有符合此规则的邮件。",
|
||||
"period_status_scheduled": "未开始",
|
||||
"period_status_active": "生效中",
|
||||
"period_status_expired": "已过期"
|
||||
|
||||
Reference in New Issue
Block a user