Merge branch 'dev' of https://github.com/bulwarkmail/webmail into dev
This commit is contained in:
@@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions from settings
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
@@ -3070,13 +3070,13 @@ export function EmailViewer({
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label}
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -3674,11 +3674,11 @@ export function EmailViewer({
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||
) : null;
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||
return (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -67,9 +67,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = (() => {
|
||||
if (colorTag) return colorTag;
|
||||
@@ -375,7 +375,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
|
||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
rule?: FilterRule;
|
||||
@@ -58,6 +59,7 @@ export function FilterRuleModal({
|
||||
}: FilterRuleModalProps) {
|
||||
const t = useTranslations("settings.filters");
|
||||
const isEdit = !!rule;
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
|
||||
const [name, setName] = useState(rule?.name || "");
|
||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||
@@ -375,12 +377,17 @@ export function FilterRuleModal({
|
||||
)}
|
||||
|
||||
{action.type === "add_label" && (
|
||||
<Input
|
||||
<select
|
||||
value={action.value || ""}
|
||||
onChange={(e) => updateAction(index, { value: e.target.value })}
|
||||
placeholder={t("label_placeholder")}
|
||||
className="flex-1 min-w-[140px]"
|
||||
/>
|
||||
className={`${selectClass} flex-1 min-w-[140px]`}
|
||||
aria-label={t("label_placeholder")}
|
||||
>
|
||||
<option value="">{t("label_placeholder")}</option>
|
||||
{emailKeywords.map((kw) => (
|
||||
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
+27
-2
@@ -14,6 +14,7 @@ import type {
|
||||
AdminPageSection,
|
||||
CalendarEventAction,
|
||||
SlotName,
|
||||
PluginI18n,
|
||||
} from './plugin-types';
|
||||
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
||||
import {
|
||||
@@ -22,8 +23,9 @@ import {
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks, avatarHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks,
|
||||
} from './plugin-hooks';
|
||||
import { createPluginI18n } from './plugin-i18n';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
@@ -109,6 +111,8 @@ function createPluginLogger(pluginId: string) {
|
||||
|
||||
export interface PluginAPI {
|
||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||
/** Localisation API — register translations and call t() to get strings */
|
||||
i18n: PluginI18n;
|
||||
ui: {
|
||||
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
||||
registerEmailBanner: (factory: BannerFactory) => Disposable;
|
||||
@@ -149,6 +153,8 @@ export interface PluginHooksAPI {
|
||||
onEmailClose: (handler: () => void) => Disposable;
|
||||
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */
|
||||
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -157,6 +163,10 @@ export interface PluginHooksAPI {
|
||||
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Emitted after emails are moved to the Archive mailbox */
|
||||
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||
/** Emitted after emails are moved out of the Archive mailbox */
|
||||
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -173,6 +183,8 @@ export interface PluginHooksAPI {
|
||||
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept — receives MailtoContext, return false to prevent the system mail client */
|
||||
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
// Calendar
|
||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -215,6 +227,8 @@ export interface PluginHooksAPI {
|
||||
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */
|
||||
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -316,6 +330,9 @@ export interface PluginHooksAPI {
|
||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Avatar
|
||||
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Render — transform hook for email list row badges
|
||||
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
}
|
||||
|
||||
// --- Permission mapping for hooks ----------------------------
|
||||
@@ -324,14 +341,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
// Email
|
||||
onEmailOpen: 'email:read', onEmailClose: 'email:read',
|
||||
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
|
||||
onComposerOpen: 'email:read', onDraftAutoSave: 'email:read',
|
||||
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
|
||||
onDraftAutoSave: 'email:read',
|
||||
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
|
||||
onSearch: 'email:read', onSearchResults: 'email:read',
|
||||
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
||||
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
||||
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
|
||||
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
||||
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
||||
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
||||
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
||||
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
|
||||
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
|
||||
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
|
||||
@@ -358,6 +378,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
|
||||
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
|
||||
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
|
||||
onBeforeFileRename: 'files:write',
|
||||
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
|
||||
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
|
||||
// Auth
|
||||
@@ -469,6 +490,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||
// Avatar
|
||||
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||
// Render
|
||||
...Object.fromEntries(Object.entries(renderHooks)),
|
||||
};
|
||||
|
||||
// --- Slot registration bridge --------------------------------
|
||||
@@ -530,6 +553,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||
settings: { ...plugin.settings },
|
||||
},
|
||||
|
||||
i18n: createPluginI18n(plugin.id),
|
||||
|
||||
ui: {
|
||||
registerToolbarAction: (action: ToolbarAction) => {
|
||||
requirePermission(plugin, 'ui:toolbar');
|
||||
|
||||
+26
-1
@@ -172,6 +172,10 @@ export const emailHooks = {
|
||||
onEmailClose: new HookBus(),
|
||||
onEmailContentRender: new HookBus(),
|
||||
onThreadExpand: new HookBus(),
|
||||
// Intercept hook — fires before the composer opens.
|
||||
// Handlers receive ComposeOptions and may mutate fields in place.
|
||||
// Return false to cancel opening the composer.
|
||||
onBeforeCompose: new HookBus(),
|
||||
onComposerOpen: new HookBus(),
|
||||
onBeforeEmailSend: new HookBus(),
|
||||
onAfterEmailSend: new HookBus(),
|
||||
@@ -180,6 +184,10 @@ export const emailHooks = {
|
||||
onAfterEmailDelete: new HookBus(),
|
||||
onBeforeEmailMove: new HookBus(),
|
||||
onAfterEmailMove: new HookBus(),
|
||||
// Fired after one or more emails are archived to the Archive mailbox
|
||||
onEmailArchive: new HookBus(),
|
||||
// Fired after one or more emails are moved out of the Archive mailbox
|
||||
onEmailUnarchive: new HookBus(),
|
||||
onEmailReadStateChange: new HookBus(),
|
||||
onEmailStarToggle: new HookBus(),
|
||||
onEmailSpamToggle: new HookBus(),
|
||||
@@ -196,6 +204,9 @@ export const emailHooks = {
|
||||
onNewEmailReceived: new HookBus(),
|
||||
onPushConnectionChange: new HookBus(),
|
||||
onQuotaChange: new HookBus(),
|
||||
// Intercept hook — fired when a mailto: link is clicked.
|
||||
// Return false to prevent the browser from opening the system mail client.
|
||||
onMailtoIntercept: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.2 Calendar Hooks
|
||||
@@ -250,6 +261,10 @@ export const fileHooks = {
|
||||
onDirectoryCreate: new HookBus(),
|
||||
onBeforeFileDelete: new HookBus(),
|
||||
onAfterFileDelete: new HookBus(),
|
||||
// Intercept hook — fires before a file is renamed.
|
||||
// Receives { file: FileResourceView, newName: string }.
|
||||
// Return false to cancel the rename.
|
||||
onBeforeFileRename: new HookBus(),
|
||||
onFileRename: new HookBus(),
|
||||
onFileMove: new HookBus(),
|
||||
onFileCopy: new HookBus(),
|
||||
@@ -406,6 +421,16 @@ export const avatarHooks = {
|
||||
onAvatarResolve: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.22 Render Hooks
|
||||
export const renderHooks = {
|
||||
// Transform hook — runs for each visible email list row.
|
||||
// Initial value: EmailListBadge[] (always starts as [])
|
||||
// Second argument: { emailId: string; email: EmailReadView }
|
||||
// Handlers return a new (or extended) badges array.
|
||||
// Rendered by the email list row component next to the subject line.
|
||||
onEmailListItemRender: new HookBus(),
|
||||
};
|
||||
|
||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||
|
||||
const allHookGroups = [
|
||||
@@ -414,7 +439,7 @@ const allHookGroups = [
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||
avatarHooks,
|
||||
avatarHooks, renderHooks,
|
||||
];
|
||||
|
||||
export function removeAllPluginHooks(pluginId: string): void {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Plugin i18n registry — manages per-plugin translation tables
|
||||
//
|
||||
// Each plugin gets its own namespace keyed by:
|
||||
// pluginId → locale → { messageKey → translated string }
|
||||
//
|
||||
// Resolution order when calling t(key):
|
||||
// 1. Exact locale match ("fr-CA")
|
||||
// 2. Language-prefix match ("fr" from "fr-CA")
|
||||
// 3. English fallback ("en")
|
||||
// 4. Raw key (plugin is never broken by missing strings)
|
||||
//
|
||||
// Interpolation uses {paramName} placeholders.
|
||||
|
||||
// ─── Registry ────────────────────────────────────────────────
|
||||
|
||||
/** pluginId → locale → key → translated string */
|
||||
const registry = new Map<string, Map<string, Record<string, string>>>();
|
||||
|
||||
let currentLocale = 'en';
|
||||
|
||||
// ─── Locale sync (called by plugin-loader) ───────────────────
|
||||
|
||||
/** Keep the registry in sync with the app locale */
|
||||
export function setPluginI18nLocale(locale: string): void {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
export function getPluginI18nLocale(): string {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────
|
||||
|
||||
/** Remove all translations for a plugin (called on deactivation) */
|
||||
export function clearPluginI18nTranslations(pluginId: string): void {
|
||||
registry.delete(pluginId);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
|
||||
}
|
||||
|
||||
function resolve(pluginId: string, key: string): string | undefined {
|
||||
const byLocale = registry.get(pluginId);
|
||||
if (!byLocale) return undefined;
|
||||
|
||||
// 1. Exact locale (e.g. "fr-CA")
|
||||
const exact = byLocale.get(currentLocale)?.[key];
|
||||
if (exact !== undefined) return exact;
|
||||
|
||||
// 2. Language prefix (e.g. "fr" from "fr-CA")
|
||||
const lang = currentLocale.split('-')[0];
|
||||
if (lang !== currentLocale) {
|
||||
const langMatch = byLocale.get(lang)?.[key];
|
||||
if (langMatch !== undefined) return langMatch;
|
||||
}
|
||||
|
||||
// 3. English fallback
|
||||
return byLocale.get('en')?.[key];
|
||||
}
|
||||
|
||||
// ─── Public API factory ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the i18n API object exposed as `api.i18n` inside each plugin.
|
||||
*
|
||||
* @example
|
||||
* // In your plugin activate():
|
||||
* api.i18n.addTranslations('en', { 'banner.title': 'Hello' });
|
||||
* api.i18n.addTranslations('de', { 'banner.title': 'Hallo' });
|
||||
*
|
||||
* // Later, in any React component the plugin renders:
|
||||
* const title = api.i18n.t('banner.title');
|
||||
* const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!'
|
||||
*/
|
||||
export function createPluginI18n(pluginId: string) {
|
||||
return {
|
||||
/**
|
||||
* Register translations for one locale.
|
||||
* Multiple calls for the same locale are merged (last-write-wins on key collision).
|
||||
*
|
||||
* @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA"
|
||||
* @param strings Key → translated string map. Use {paramName} for interpolation.
|
||||
*/
|
||||
addTranslations(locale: string, strings: Record<string, string>): void {
|
||||
let byLocale = registry.get(pluginId);
|
||||
if (!byLocale) {
|
||||
byLocale = new Map<string, Record<string, string>>();
|
||||
registry.set(pluginId, byLocale);
|
||||
}
|
||||
const existing = byLocale.get(locale) ?? {};
|
||||
byLocale.set(locale, { ...existing, ...strings });
|
||||
},
|
||||
|
||||
/**
|
||||
* Translate a key using the current app locale.
|
||||
* Falls back through: exact locale → language prefix → 'en' → raw key.
|
||||
*
|
||||
* @param key Translation key, e.g. `'banner.title'`
|
||||
* @param params Optional interpolation values, e.g. `{ count: 3 }`
|
||||
*/
|
||||
t(key: string, params?: Record<string, string | number>): string {
|
||||
const template = resolve(pluginId, key);
|
||||
if (template !== undefined) return interpolate(template, params);
|
||||
return key; // never throw — just return the key
|
||||
},
|
||||
|
||||
/** The current app locale (e.g. "en", "de", "fr") */
|
||||
getLocale(): string {
|
||||
return currentLocale;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginI18nInstance = ReturnType<typeof createPluginI18n>;
|
||||
+25
-1
@@ -1,15 +1,18 @@
|
||||
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
||||
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
||||
|
||||
import type { InstalledPlugin, Disposable } from './plugin-types';
|
||||
import { pluginStorage } from './plugin-storage';
|
||||
import { createPluginAPI, type PluginAPI } from './plugin-api';
|
||||
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
|
||||
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import * as ReactJSX from 'react/jsx-runtime';
|
||||
|
||||
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
||||
|
||||
let localeSyncInitialised = false;
|
||||
|
||||
export function exposePluginExternals(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
|
||||
ReactDOM,
|
||||
ReactJSX,
|
||||
};
|
||||
|
||||
// Sync plugin i18n with the app locale (runs once per page load)
|
||||
if (!localeSyncInitialised) {
|
||||
localeSyncInitialised = true;
|
||||
// Dynamic import avoids a circular dependency chain at module evaluation time
|
||||
import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
||||
setPluginI18nLocale(useLocaleStore.getState().locale);
|
||||
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
|
||||
}).catch(() => {/* locale sync is best-effort */});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Active plugin tracking ----------------------------------
|
||||
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
// 4. Build sandboxed API
|
||||
const api = createPluginAPI(plugin);
|
||||
|
||||
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
|
||||
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
|
||||
if (plugin.locales) {
|
||||
for (const [locale, strings] of Object.entries(plugin.locales)) {
|
||||
api.i18n.addTranslations(locale, strings);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Call activate
|
||||
const disposable = await mod.activate(api);
|
||||
|
||||
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
|
||||
// Remove all hook subscriptions for this plugin
|
||||
removeAllPluginHooks(pluginId);
|
||||
|
||||
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
|
||||
clearPluginI18nTranslations(pluginId);
|
||||
|
||||
// Reset error tracker
|
||||
pluginErrorTracker.reset(pluginId);
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface PluginManifest {
|
||||
entrypoint: string;
|
||||
minAppVersion?: string;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
/**
|
||||
* Bundled translations shipped inside the plugin ZIP.
|
||||
* Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …).
|
||||
* The loader auto-registers these before calling activate(),
|
||||
* so plugins can use api.i18n.t() without calling addTranslations() first.
|
||||
*/
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface SettingFieldSchema {
|
||||
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
|
||||
adminApproved?: boolean;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
settings: Record<string, unknown>;
|
||||
/** Bundled translations, carried over from the manifest on install. */
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
// ─── UI Slots ────────────────────────────────────────────────
|
||||
@@ -388,6 +397,86 @@ export interface ComposerContext {
|
||||
originalSubject?: string;
|
||||
}
|
||||
|
||||
// ─── New hook context types ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Passed to onBeforeCompose handlers.
|
||||
* Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose.
|
||||
*/
|
||||
export interface ComposeOptions {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
mode: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||
}
|
||||
|
||||
/**
|
||||
* A small visual indicator injected into an email list row via onEmailListItemRender.
|
||||
*/
|
||||
export interface EmailListBadge {
|
||||
/** Stable unique key within the plugin — used as React key */
|
||||
key: string;
|
||||
/** Short label text displayed in the badge */
|
||||
label: string;
|
||||
/** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */
|
||||
color?: string;
|
||||
/** Tooltip / aria-label */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to onMailtoIntercept handlers.
|
||||
* Return false to prevent the browser from opening the system mail client.
|
||||
*/
|
||||
export interface MailtoContext {
|
||||
/** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */
|
||||
href: string;
|
||||
/** Parsed list of recipient addresses */
|
||||
to: string[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
// ─── Plugin i18n API ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Localisation API exposed as `api.i18n` inside every plugin.
|
||||
*
|
||||
* Plugins ship their own translation tables; the app locale is tracked
|
||||
* automatically so `t()` always returns the right string without any
|
||||
* extra setup from the plugin side.
|
||||
*/
|
||||
export interface PluginI18n {
|
||||
/**
|
||||
* Register translations for one locale.
|
||||
* Multiple calls for the same locale are merged (last-write-wins per key).
|
||||
*
|
||||
* @param locale BCP-47 tag, e.g. "en", "de", "fr-CA"
|
||||
* @param strings Key → translated string map. Use {paramName} for interpolation.
|
||||
*
|
||||
* @example
|
||||
* api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' });
|
||||
* api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' });
|
||||
*/
|
||||
addTranslations(locale: string, strings: Record<string, string>): void;
|
||||
|
||||
/**
|
||||
* Return the translated string for `key` using the current app locale,
|
||||
* with optional {param} interpolation.
|
||||
*
|
||||
* Falls back: exact locale → language prefix → "en" → raw key.
|
||||
*
|
||||
* @example
|
||||
* api.i18n.t('banner.title')
|
||||
* api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items'
|
||||
*/
|
||||
t(key: string, params?: Record<string, string | number>): string;
|
||||
|
||||
/** The current app locale string (e.g. "en", "de", "fr") */
|
||||
getLocale(): string;
|
||||
}
|
||||
|
||||
// ─── Permission Reference ────────────────────────────────────
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
|
||||
@@ -194,9 +194,9 @@ describe('generateScript', () => {
|
||||
expect(script).toContain('addflag "\\\\Flagged";');
|
||||
});
|
||||
|
||||
it('generates add_label as addflag $Label', () => {
|
||||
it('generates add_label as addflag $label:Label', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
|
||||
expect(script).toContain('addflag "$Important";');
|
||||
expect(script).toContain('addflag "$label:Important";');
|
||||
});
|
||||
|
||||
it('generates discard', () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ function generateActions(actions: FilterAction[]): string[] {
|
||||
case 'star':
|
||||
return 'addflag "\\\\Flagged";';
|
||||
case 'add_label':
|
||||
return `addflag "$${escapeString(action.value || '')}";`;
|
||||
return `addflag "$label:${escapeString(action.value || '')}";`;
|
||||
case 'discard':
|
||||
return 'discard;';
|
||||
case 'reject':
|
||||
|
||||
+12
-12
@@ -657,7 +657,7 @@
|
||||
"filters": "Filter",
|
||||
"templates": "Vorlagen",
|
||||
"folders": "Ordner",
|
||||
"keywords": "Schlüsselwörter",
|
||||
"keywords": "Labels",
|
||||
"security": "Sicherheit",
|
||||
"encryption": "Verschlüsselung",
|
||||
"files": "Dateien",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "E-Mail-Schlüsselwörter",
|
||||
"description": "Definieren Sie Schlüsselwörter (Labels/Tags) zum Organisieren Ihrer E-Mails mit Farben.",
|
||||
"add_keyword": "Schlüsselwort hinzufügen",
|
||||
"title": "E-Mail-Labels",
|
||||
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
|
||||
"add_keyword": "Label hinzufügen",
|
||||
"reset_defaults": "Auf Standard zurücksetzen",
|
||||
"label_field": "Anzeigename",
|
||||
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
|
||||
"id_field": "Schlüsselwort-ID",
|
||||
"id_field": "Label-ID",
|
||||
"id_placeholder": "z.B. arbeit, privat",
|
||||
"color_field": "Farbe",
|
||||
"id_exists": "Diese Schlüsselwort-ID existiert bereits",
|
||||
"edit": "Schlüsselwort bearbeiten",
|
||||
"delete": "Schlüsselwort löschen",
|
||||
"id_exists": "Diese Label-ID existiert bereits",
|
||||
"edit": "Label bearbeiten",
|
||||
"delete": "Label löschen",
|
||||
"save": "Speichern",
|
||||
"add": "Hinzufügen",
|
||||
"cancel": "Abbrechen",
|
||||
"migrating": "Schlüsselwort bei bestehenden E-Mails aktualisieren…",
|
||||
"migration_error": "Schlüsselwort konnte bei bestehenden E-Mails nicht aktualisiert werden"
|
||||
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
|
||||
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Benachrichtigungston testen",
|
||||
@@ -923,7 +923,7 @@
|
||||
"star": "Markieren / Markierung aufheben",
|
||||
"mark_read": "Als gelesen / ungelesen markieren",
|
||||
"archive": "Archivieren",
|
||||
"tag": "Schlagwort",
|
||||
"tag": "Label",
|
||||
"spam": "Als Spam markieren",
|
||||
"none_selected": "Keine Aktionen ausgewählt",
|
||||
"mode_label": "Anzeigemodus",
|
||||
@@ -1350,7 +1350,7 @@
|
||||
"reject_message": "Ablehnungsnachricht",
|
||||
"reject_placeholder": "Ihre E-Mail wurde abgelehnt",
|
||||
"label_name": "Label-Name",
|
||||
"label_placeholder": "z.B. wichtig",
|
||||
"label_placeholder": "Label auswählen",
|
||||
"header_name": "Header-Name",
|
||||
"header_placeholder": "z.B. X-Mailing-List",
|
||||
"size_bytes": "Größe in Bytes",
|
||||
|
||||
+15
-15
@@ -657,7 +657,7 @@
|
||||
"filters": "Filters",
|
||||
"templates": "Templates",
|
||||
"folders": "Folders",
|
||||
"keywords": "Keywords",
|
||||
"keywords": "Tags",
|
||||
"security": "Security",
|
||||
"files": "Files",
|
||||
"contacts": "Contacts",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Email Keywords",
|
||||
"description": "Define keywords (labels/tags) to organize your emails with colors. These are stored as JMAP keywords on the server.",
|
||||
"add_keyword": "Add Keyword",
|
||||
"title": "Email Tags",
|
||||
"description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
|
||||
"add_keyword": "Add Tag",
|
||||
"reset_defaults": "Reset to Defaults",
|
||||
"label_field": "Display Name",
|
||||
"label_placeholder": "e.g. Work, Personal, Urgent",
|
||||
"id_field": "Keyword ID",
|
||||
"id_field": "Tag ID",
|
||||
"id_placeholder": "e.g. work, personal",
|
||||
"color_field": "Color",
|
||||
"id_exists": "This keyword ID already exists",
|
||||
"edit": "Edit keyword",
|
||||
"delete": "Delete keyword",
|
||||
"id_exists": "This tag ID already exists",
|
||||
"edit": "Edit tag",
|
||||
"delete": "Delete tag",
|
||||
"save": "Save",
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"migrating": "Updating keyword on existing emails…",
|
||||
"migration_error": "Failed to update keyword on existing emails"
|
||||
"migrating": "Updating tag on existing emails…",
|
||||
"migration_error": "Failed to update tag on existing emails"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Test notification sound",
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"forward": "Forward to",
|
||||
"mark_read": "Mark as read",
|
||||
"star": "Star message",
|
||||
"add_label": "Add label",
|
||||
"add_label": "Add tag",
|
||||
"discard": "Discard (delete silently)",
|
||||
"reject": "Reject with message",
|
||||
"keep": "Keep in inbox",
|
||||
@@ -1349,8 +1349,8 @@
|
||||
"forward_placeholder": "email@example.com",
|
||||
"reject_message": "Rejection message",
|
||||
"reject_placeholder": "Your email has been rejected",
|
||||
"label_name": "Label name",
|
||||
"label_placeholder": "e.g., important",
|
||||
"label_name": "Tag name",
|
||||
"label_placeholder": "Select tag",
|
||||
"header_name": "Header name",
|
||||
"header_placeholder": "e.g., X-Mailing-List",
|
||||
"size_bytes": "Size in bytes",
|
||||
@@ -1524,8 +1524,8 @@
|
||||
"delete": "Delete",
|
||||
"mark_as_spam": "Report spam",
|
||||
"not_spam": "Not spam",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Remove Label",
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Remove tag",
|
||||
"items_selected": "{count} emails selected",
|
||||
"edit_draft": "Edit Draft"
|
||||
},
|
||||
|
||||
@@ -657,7 +657,7 @@
|
||||
"filters": "Filtros",
|
||||
"templates": "Plantillas",
|
||||
"folders": "Carpetas",
|
||||
"keywords": "Palabras clave",
|
||||
"keywords": "Etiquetas",
|
||||
"security": "Seguridad",
|
||||
"encryption": "Cifrado",
|
||||
"files": "Archivos",
|
||||
@@ -725,18 +725,18 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Palabras clave de correo",
|
||||
"description": "Define palabras clave (etiquetas) para organizar tus correos con colores.",
|
||||
"add_keyword": "Añadir palabra clave",
|
||||
"title": "Etiquetas de correo",
|
||||
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
|
||||
"add_keyword": "Añadir etiqueta",
|
||||
"reset_defaults": "Restablecer valores predeterminados",
|
||||
"label_field": "Nombre para mostrar",
|
||||
"label_placeholder": "ej. Trabajo, Personal, Urgente",
|
||||
"id_field": "ID de palabra clave",
|
||||
"id_field": "ID de etiqueta",
|
||||
"id_placeholder": "ej. trabajo, personal",
|
||||
"color_field": "Color",
|
||||
"id_exists": "Esta ID de palabra clave ya existe",
|
||||
"edit": "Editar palabra clave",
|
||||
"delete": "Eliminar palabra clave",
|
||||
"id_exists": "Este ID de etiqueta ya existe",
|
||||
"edit": "Editar etiqueta",
|
||||
"delete": "Eliminar etiqueta",
|
||||
"save": "Guardar",
|
||||
"add": "Añadir",
|
||||
"cancel": "Cancelar",
|
||||
@@ -1350,7 +1350,7 @@
|
||||
"reject_message": "Mensaje de rechazo",
|
||||
"reject_placeholder": "Su correo ha sido rechazado",
|
||||
"label_name": "Nombre de la etiqueta",
|
||||
"label_placeholder": "ej. importante",
|
||||
"label_placeholder": "Seleccionar etiqueta",
|
||||
"header_name": "Nombre del encabezado",
|
||||
"header_placeholder": "ej. X-Mailing-List",
|
||||
"size_bytes": "Tamaño en bytes",
|
||||
|
||||
+13
-13
@@ -657,7 +657,7 @@
|
||||
"filters": "Filtres",
|
||||
"templates": "Modèles",
|
||||
"folders": "Dossiers",
|
||||
"keywords": "Mots-clés",
|
||||
"keywords": "Étiquettes",
|
||||
"security": "Sécurité",
|
||||
"encryption": "Chiffrement",
|
||||
"files": "Fichiers",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Mots-clés des e-mails",
|
||||
"description": "Définissez des mots-clés (étiquettes) pour organiser vos e-mails avec des couleurs.",
|
||||
"add_keyword": "Ajouter un mot-clé",
|
||||
"title": "Étiquettes de messagerie",
|
||||
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
|
||||
"add_keyword": "Ajouter une étiquette",
|
||||
"reset_defaults": "Réinitialiser par défaut",
|
||||
"label_field": "Nom d'affichage",
|
||||
"label_placeholder": "ex. Travail, Personnel, Urgent",
|
||||
"id_field": "ID du mot-clé",
|
||||
"id_field": "ID d'étiquette",
|
||||
"id_placeholder": "ex. travail, personnel",
|
||||
"color_field": "Couleur",
|
||||
"id_exists": "Cet ID de mot-clé existe déjà",
|
||||
"edit": "Modifier le mot-clé",
|
||||
"delete": "Supprimer le mot-clé",
|
||||
"id_exists": "Cet ID d'étiquette existe déjà",
|
||||
"edit": "Éditer l'étiquette",
|
||||
"delete": "Supprimer l'étiquette",
|
||||
"save": "Enregistrer",
|
||||
"add": "Ajouter",
|
||||
"cancel": "Annuler",
|
||||
"migrating": "Mise à jour du mot-clé sur les e-mails existants…",
|
||||
"migration_error": "Échec de la mise à jour du mot-clé sur les e-mails existants"
|
||||
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
|
||||
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Tester le son de notification",
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"forward": "Transférer à",
|
||||
"mark_read": "Marquer comme lu",
|
||||
"star": "Marquer d'une étoile",
|
||||
"add_label": "Ajouter un libellé",
|
||||
"add_label": "Ajouter une étiquette",
|
||||
"discard": "Supprimer silencieusement",
|
||||
"reject": "Rejeter avec un message",
|
||||
"keep": "Conserver dans la boîte de réception",
|
||||
@@ -1349,8 +1349,8 @@
|
||||
"forward_placeholder": "email@exemple.com",
|
||||
"reject_message": "Message de rejet",
|
||||
"reject_placeholder": "Votre e-mail a été rejeté",
|
||||
"label_name": "Nom du libellé",
|
||||
"label_placeholder": "ex. important",
|
||||
"label_name": "Nom de l'étiquette",
|
||||
"label_placeholder": "Sélectionner une étiquette",
|
||||
"header_name": "Nom de l'en-tête",
|
||||
"header_placeholder": "ex. X-Mailing-List",
|
||||
"size_bytes": "Taille en octets",
|
||||
|
||||
+11
-11
@@ -657,7 +657,7 @@
|
||||
"filters": "Filtri",
|
||||
"templates": "Modelli",
|
||||
"folders": "Cartelle",
|
||||
"keywords": "Parole chiave",
|
||||
"keywords": "Etichette",
|
||||
"security": "Sicurezza",
|
||||
"encryption": "Cifratura",
|
||||
"files": "File",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Parole chiave e-mail",
|
||||
"description": "Definisci parole chiave (etichette) per organizzare le tue e-mail con colori.",
|
||||
"add_keyword": "Aggiungi parola chiave",
|
||||
"title": "Etichette e-mail",
|
||||
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
|
||||
"add_keyword": "Aggiungi etichetta",
|
||||
"reset_defaults": "Ripristina predefiniti",
|
||||
"label_field": "Nome visualizzato",
|
||||
"label_placeholder": "es. Lavoro, Personale, Urgente",
|
||||
"id_field": "ID parola chiave",
|
||||
"id_field": "ID etichetta",
|
||||
"id_placeholder": "es. lavoro, personale",
|
||||
"color_field": "Colore",
|
||||
"id_exists": "Questo ID parola chiave esiste già",
|
||||
"edit": "Modifica parola chiave",
|
||||
"delete": "Elimina parola chiave",
|
||||
"id_exists": "Questo ID etichetta esiste già",
|
||||
"edit": "Modifica etichetta",
|
||||
"delete": "Elimina etichetta",
|
||||
"save": "Salva",
|
||||
"add": "Aggiungi",
|
||||
"cancel": "Annulla",
|
||||
"migrating": "Aggiornamento parola chiave sulle email esistenti…",
|
||||
"migration_error": "Impossibile aggiornare la parola chiave sulle email esistenti"
|
||||
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
|
||||
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Testa il suono di notifica",
|
||||
@@ -1350,7 +1350,7 @@
|
||||
"reject_message": "Messaggio di rifiuto",
|
||||
"reject_placeholder": "La tua email è stata rifiutata",
|
||||
"label_name": "Nome dell'etichetta",
|
||||
"label_placeholder": "es. importante",
|
||||
"label_placeholder": "Seleziona etichetta",
|
||||
"header_name": "Nome dell'intestazione",
|
||||
"header_placeholder": "es. X-Mailing-List",
|
||||
"size_bytes": "Dimensione in byte",
|
||||
|
||||
+12
-12
@@ -657,7 +657,7 @@
|
||||
"filters": "フィルター",
|
||||
"templates": "テンプレート",
|
||||
"folders": "フォルダー",
|
||||
"keywords": "キーワード",
|
||||
"keywords": "ラベル",
|
||||
"security": "セキュリティ",
|
||||
"encryption": "暗号化",
|
||||
"files": "ファイル",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "メールキーワード",
|
||||
"description": "色でメールを整理するためのキーワード(ラベル/タグ)を定義します。",
|
||||
"add_keyword": "キーワードを追加",
|
||||
"title": "メールラベル",
|
||||
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
|
||||
"add_keyword": "ラベルを追加",
|
||||
"reset_defaults": "デフォルトに戻す",
|
||||
"label_field": "表示名",
|
||||
"label_placeholder": "例:仕事、個人、緊急",
|
||||
"id_field": "キーワードID",
|
||||
"id_field": "ラベルID",
|
||||
"id_placeholder": "例:work、personal",
|
||||
"color_field": "色",
|
||||
"id_exists": "このキーワードIDは既に存在します",
|
||||
"edit": "キーワードを編集",
|
||||
"delete": "キーワードを削除",
|
||||
"id_exists": "このラベルIDは既に存在します",
|
||||
"edit": "ラベルを編集",
|
||||
"delete": "ラベルを削除",
|
||||
"save": "保存",
|
||||
"add": "追加",
|
||||
"cancel": "キャンセル",
|
||||
"migrating": "既存のメールでキーワードを更新中…",
|
||||
"migration_error": "既存のメールでのキーワード更新に失敗しました"
|
||||
"migrating": "既存のメールのラベルを更新中…",
|
||||
"migration_error": "既存のメールのラベルの更新に失敗しました"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "通知音をテスト",
|
||||
@@ -923,7 +923,7 @@
|
||||
"star": "スター付け / 解除",
|
||||
"mark_read": "既読 / 未読にする",
|
||||
"archive": "アーカイブ",
|
||||
"tag": "タグ",
|
||||
"tag": "ラベル",
|
||||
"spam": "スパムとしてマーク",
|
||||
"none_selected": "アクションが選択されていません",
|
||||
"mode_label": "表示モード",
|
||||
@@ -1350,7 +1350,7 @@
|
||||
"reject_message": "拒否メッセージ",
|
||||
"reject_placeholder": "あなたのメールは拒否されました",
|
||||
"label_name": "ラベル名",
|
||||
"label_placeholder": "例:重要",
|
||||
"label_placeholder": "ラベルを選択",
|
||||
"header_name": "ヘッダー名",
|
||||
"header_placeholder": "例:X-Mailing-List",
|
||||
"size_bytes": "サイズ(バイト)",
|
||||
|
||||
+15
-15
@@ -657,7 +657,7 @@
|
||||
"filters": "필터",
|
||||
"templates": "템플릿",
|
||||
"folders": "폴더",
|
||||
"keywords": "키워드",
|
||||
"keywords": "태그",
|
||||
"security": "보안",
|
||||
"files": "파일",
|
||||
"contacts": "연락처",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "이메일 키워드",
|
||||
"description": "이메일을 분류할 키워드(라벨/태그)를 설정해 보세요. 설정한 키워드는 서버에 저장돼요.",
|
||||
"add_keyword": "키워드 추가",
|
||||
"title": "이메일 태그",
|
||||
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
|
||||
"add_keyword": "태그 추가",
|
||||
"reset_defaults": "기본값으로 초기화",
|
||||
"label_field": "표시 이름",
|
||||
"label_placeholder": "예: 업무, 개인, 긴급",
|
||||
"id_field": "키워드 ID",
|
||||
"id_field": "태그 ID",
|
||||
"id_placeholder": "예: work, personal",
|
||||
"color_field": "색상",
|
||||
"id_exists": "이미 존재하는 키워드 ID예요",
|
||||
"edit": "키워드 수정",
|
||||
"delete": "키워드 삭제",
|
||||
"id_exists": "이 태그 ID는 이미 존재합니다",
|
||||
"edit": "태그 편집",
|
||||
"delete": "태그 삭제",
|
||||
"save": "저장",
|
||||
"add": "추가",
|
||||
"cancel": "취소",
|
||||
"migrating": "기존 이메일의 키워드를 업데이트하는 중...",
|
||||
"migration_error": "기존 이메일의 키워드를 업데이트하지 못했어요"
|
||||
"migrating": "기존 이메일의 태그 업데이트 중…",
|
||||
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "알림음 테스트",
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"forward": "다음으로 전달",
|
||||
"mark_read": "읽은 상태로 표시",
|
||||
"star": "별표 달기",
|
||||
"add_label": "라벨(태그) 추가",
|
||||
"add_label": "태그 추가",
|
||||
"discard": "삭제 (조용히 지움)",
|
||||
"reject": "메시지와 함께 수신 거부",
|
||||
"keep": "받은편지함에 유지",
|
||||
@@ -1349,8 +1349,8 @@
|
||||
"forward_placeholder": "email@example.com",
|
||||
"reject_message": "거부 메시지",
|
||||
"reject_placeholder": "메일 수신이 거부되었습니다",
|
||||
"label_name": "라벨 이름",
|
||||
"label_placeholder": "예: important",
|
||||
"label_name": "태그 이름",
|
||||
"label_placeholder": "태그 선택",
|
||||
"header_name": "헤더 이름",
|
||||
"header_placeholder": "예: X-Mailing-List",
|
||||
"size_bytes": "크기 (바이트)",
|
||||
@@ -1524,8 +1524,8 @@
|
||||
"delete": "삭제",
|
||||
"mark_as_spam": "스팸 신고",
|
||||
"not_spam": "정상 메일",
|
||||
"color_tag": "라벨 지정",
|
||||
"remove_color": "라벨 제거",
|
||||
"color_tag": "태그",
|
||||
"remove_color": "태그 제거",
|
||||
"items_selected": "{count}개의 메일 선택됨",
|
||||
"edit_draft": "임시보관 메일 수정"
|
||||
},
|
||||
|
||||
+15
-15
@@ -657,7 +657,7 @@
|
||||
"filters": "Filtri",
|
||||
"templates": "Veidnes",
|
||||
"folders": "Mapes",
|
||||
"keywords": "Atslēgvārdi",
|
||||
"keywords": "Tagi",
|
||||
"security": "Drošība",
|
||||
"files": "Faili",
|
||||
"contacts": "Kontakti",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Vēstuļu atslēgvārdi",
|
||||
"description": "Definējiet atslēgvārdus (etiķetes/tagus) vēstuļu organizēšanai ar krāsām. Tie tiek saglabāti serverī kā JMAP atslēgvārdi.",
|
||||
"add_keyword": "Pievienot atslēgvārdu",
|
||||
"title": "E-pasta tagi",
|
||||
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
|
||||
"add_keyword": "Pievienot tagu",
|
||||
"reset_defaults": "Atiestatīt noklusējumu",
|
||||
"label_field": "Redzamais nosaukums",
|
||||
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
|
||||
"id_field": "Atslēgvārda identifikators",
|
||||
"id_field": "Taga identifikators",
|
||||
"id_placeholder": "piem., darbs, personigi",
|
||||
"color_field": "Krāsa",
|
||||
"id_exists": "Šāds atslēgvārda identifikators jau eksistē",
|
||||
"edit": "Rediģēt atslēgvārdu",
|
||||
"delete": "Dzēst atslēgvārdu",
|
||||
"id_exists": "Šāds taga identifikators jau pastāv",
|
||||
"edit": "Rediģēt tagu",
|
||||
"delete": "Dzēst tagu",
|
||||
"save": "Saglabāt",
|
||||
"add": "Pievienot",
|
||||
"cancel": "Atcelt",
|
||||
"migrating": "Atjaunina atslēgvārdu esošajās vēstulēs...",
|
||||
"migration_error": "Neizdevās atjaunināt atslēgvārdu esošajās vēstulēs"
|
||||
"migrating": "Taga atjaunināšana esošajos e-pastos…",
|
||||
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Pārbaudīt paziņojuma skaņu",
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"forward": "Pārsūtīt uz",
|
||||
"mark_read": "Atzīmēt kā izlasītu",
|
||||
"star": "Pievienot zvaigznīti",
|
||||
"add_label": "Pievienot etiķeti",
|
||||
"add_label": "Pievienot tagu",
|
||||
"discard": "Dzēst (bez paziņojuma)",
|
||||
"reject": "Noraidīt ar ziņojumu",
|
||||
"keep": "Atstāt iesūtnē",
|
||||
@@ -1349,8 +1349,8 @@
|
||||
"forward_placeholder": "lietotajs@piemers.lv",
|
||||
"reject_message": "Noraidīšanas ziņojums",
|
||||
"reject_placeholder": "Jūsu e-pasts tika noraidīts",
|
||||
"label_name": "Etiķetes nosaukums",
|
||||
"label_placeholder": "piem., svarigi",
|
||||
"label_name": "Taga nosaukums",
|
||||
"label_placeholder": "Izvēlēties tagu",
|
||||
"header_name": "Galvenes nosaukums",
|
||||
"header_placeholder": "piem., X-Mailing-List",
|
||||
"size_bytes": "Izmērs baitos",
|
||||
@@ -1524,8 +1524,8 @@
|
||||
"delete": "Dzēst",
|
||||
"mark_as_spam": "Atzīmēt kā mēstuli",
|
||||
"not_spam": "Nav mēstule",
|
||||
"color_tag": "Etiķete",
|
||||
"remove_color": "Noņemt etiķeti",
|
||||
"color_tag": "Tags",
|
||||
"remove_color": "Noņemt tagu",
|
||||
"items_selected": "{count} vēstules atlasītas",
|
||||
"edit_draft": "Rediģēt melnrakstu"
|
||||
},
|
||||
|
||||
+11
-11
@@ -657,7 +657,7 @@
|
||||
"filters": "Filters",
|
||||
"templates": "Sjablonen",
|
||||
"folders": "Mappen",
|
||||
"keywords": "Sleutelwoorden",
|
||||
"keywords": "Labels",
|
||||
"security": "Beveiliging",
|
||||
"encryption": "Versleuteling",
|
||||
"files": "Bestanden",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "E-mail trefwoorden",
|
||||
"description": "Definieer trefwoorden (labels/tags) om uw e-mails met kleuren te organiseren.",
|
||||
"add_keyword": "Trefwoord toevoegen",
|
||||
"title": "E-maillabels",
|
||||
"description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
|
||||
"add_keyword": "Label toevoegen",
|
||||
"reset_defaults": "Standaardwaarden herstellen",
|
||||
"label_field": "Weergavenaam",
|
||||
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
||||
"id_field": "Trefwoord-ID",
|
||||
"id_field": "Label-ID",
|
||||
"id_placeholder": "bijv. werk, persoonlijk",
|
||||
"color_field": "Kleur",
|
||||
"id_exists": "Dit trefwoord-ID bestaat al",
|
||||
"edit": "Trefwoord bewerken",
|
||||
"delete": "Trefwoord verwijderen",
|
||||
"id_exists": "Deze label-ID bestaat al",
|
||||
"edit": "Label bewerken",
|
||||
"delete": "Label verwijderen",
|
||||
"save": "Opslaan",
|
||||
"add": "Toevoegen",
|
||||
"cancel": "Annuleren",
|
||||
"migrating": "Trefwoord bijwerken op bestaande e-mails…",
|
||||
"migration_error": "Kan trefwoord niet bijwerken op bestaande e-mails"
|
||||
"migrating": "Label bijwerken op bestaande e-mails…",
|
||||
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Meldingsgeluid testen",
|
||||
@@ -1350,7 +1350,7 @@
|
||||
"reject_message": "Afwijzingsbericht",
|
||||
"reject_placeholder": "Uw e-mail is afgewezen",
|
||||
"label_name": "Labelnaam",
|
||||
"label_placeholder": "bijv. belangrijk",
|
||||
"label_placeholder": "Label kiezen",
|
||||
"header_name": "Headernaam",
|
||||
"header_placeholder": "bijv. X-Mailing-List",
|
||||
"size_bytes": "Grootte in bytes",
|
||||
|
||||
+11
-11
@@ -657,7 +657,7 @@
|
||||
"filters": "Filtry",
|
||||
"templates": "Szablony",
|
||||
"folders": "Foldery",
|
||||
"keywords": "Słowa kluczowe",
|
||||
"keywords": "Etykiety",
|
||||
"security": "Bezpieczeństwo",
|
||||
"files": "Pliki",
|
||||
"contacts": "Kontakty",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Słowa kluczowe wiadomości e-mail",
|
||||
"description": "Zdefiniuj słowa kluczowe (etykiety/tagi), aby porządkować wiadomości e-mail kolorami. Są one przechowywane na serwerze jako słowa kluczowe JMAP.",
|
||||
"add_keyword": "Dodaj słowo kluczowe",
|
||||
"title": "Etykiety e-mail",
|
||||
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
|
||||
"add_keyword": "Dodaj etykietę",
|
||||
"reset_defaults": "Przywróć domyślne",
|
||||
"label_field": "Nazwa wyświetlana",
|
||||
"label_placeholder": "np. Praca, Osobiste, Pilne",
|
||||
"id_field": "Identyfikator słowa kluczowego",
|
||||
"id_field": "ID etykiety",
|
||||
"id_placeholder": "np. praca, osobiste",
|
||||
"color_field": "Kolor",
|
||||
"id_exists": "Ten identyfikator słowa kluczowego już istnieje",
|
||||
"edit": "Edytuj słowo kluczowe",
|
||||
"delete": "Usuń słowo kluczowe",
|
||||
"id_exists": "Ten ID etykiety już istnieje",
|
||||
"edit": "Edytuj etykietę",
|
||||
"delete": "Usuń etykietę",
|
||||
"save": "Zapisz",
|
||||
"add": "Dodaj",
|
||||
"cancel": "Anuluj",
|
||||
"migrating": "Aktualizowanie słowa kluczowego w istniejących wiadomościach e-mail…",
|
||||
"migration_error": "Nie udało się zaktualizować słowa kluczowego w istniejących wiadomościach e-mail"
|
||||
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
|
||||
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Przetestuj dźwięk powiadomienia",
|
||||
@@ -1350,7 +1350,7 @@
|
||||
"reject_message": "Wiadomość odrzucenia",
|
||||
"reject_placeholder": "Twoja wiadomość e-mail została odrzucona",
|
||||
"label_name": "Nazwa etykiety",
|
||||
"label_placeholder": "np. ważne",
|
||||
"label_placeholder": "Wybierz etykietę",
|
||||
"header_name": "Nazwa nagłówka",
|
||||
"header_placeholder": "np. X-Mailing-List",
|
||||
"size_bytes": "Rozmiar w bajtach",
|
||||
|
||||
+12
-12
@@ -657,7 +657,7 @@
|
||||
"filters": "Filtros",
|
||||
"templates": "Modelos",
|
||||
"folders": "Pastas",
|
||||
"keywords": "Palavras-chave",
|
||||
"keywords": "Etiquetas",
|
||||
"security": "Segurança",
|
||||
"encryption": "Criptografia",
|
||||
"files": "Arquivos",
|
||||
@@ -725,22 +725,22 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Palavras-chave de e-mail",
|
||||
"description": "Defina palavras-chave (rótulos/tags) para organizar seus e-mails com cores.",
|
||||
"add_keyword": "Adicionar palavra-chave",
|
||||
"title": "Etiquetas de e-mail",
|
||||
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
|
||||
"add_keyword": "Adicionar etiqueta",
|
||||
"reset_defaults": "Restaurar padrões",
|
||||
"label_field": "Nome de exibição",
|
||||
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
|
||||
"id_field": "ID da palavra-chave",
|
||||
"id_field": "ID da etiqueta",
|
||||
"id_placeholder": "ex. trabalho, pessoal",
|
||||
"color_field": "Cor",
|
||||
"id_exists": "Este ID de palavra-chave já existe",
|
||||
"edit": "Editar palavra-chave",
|
||||
"delete": "Excluir palavra-chave",
|
||||
"id_exists": "Este ID de etiqueta já existe",
|
||||
"edit": "Editar etiqueta",
|
||||
"delete": "Eliminar etiqueta",
|
||||
"save": "Salvar",
|
||||
"add": "Adicionar",
|
||||
"cancel": "Cancelar",
|
||||
"migrating": "Atualizando etiqueta nos e-mails existentes…",
|
||||
"migrating": "A atualizar etiqueta nos e-mails existentes…",
|
||||
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"forward": "Encaminhar para",
|
||||
"mark_read": "Marcar como lido",
|
||||
"star": "Destacar mensagem",
|
||||
"add_label": "Adicionar rótulo",
|
||||
"add_label": "Adicionar etiqueta",
|
||||
"discard": "Descartar (excluir silenciosamente)",
|
||||
"reject": "Rejeitar com mensagem",
|
||||
"keep": "Manter na caixa de entrada",
|
||||
@@ -1349,8 +1349,8 @@
|
||||
"forward_placeholder": "email@exemplo.com",
|
||||
"reject_message": "Mensagem de rejeição",
|
||||
"reject_placeholder": "Seu e-mail foi rejeitado",
|
||||
"label_name": "Nome do rótulo",
|
||||
"label_placeholder": "ex. importante",
|
||||
"label_name": "Nome da etiqueta",
|
||||
"label_placeholder": "Selecionar etiqueta",
|
||||
"header_name": "Nome do cabeçalho",
|
||||
"header_placeholder": "ex. X-Mailing-List",
|
||||
"size_bytes": "Tamanho em bytes",
|
||||
|
||||
+15
-15
@@ -657,7 +657,7 @@
|
||||
"filters": "Фильтры",
|
||||
"templates": "Шаблоны",
|
||||
"folders": "Папки",
|
||||
"keywords": "Ключевые слова",
|
||||
"keywords": "Теги",
|
||||
"security": "Безопасность",
|
||||
"files": "Файлы",
|
||||
"contacts": "Контакты",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Ключевые слова писем",
|
||||
"description": "Определите ключевые слова (метки/теги) для организации писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
|
||||
"add_keyword": "Добавить ключевое слово",
|
||||
"title": "Теги электронной почты",
|
||||
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
|
||||
"add_keyword": "Добавить тег",
|
||||
"reset_defaults": "Сбросить по умолчанию",
|
||||
"label_field": "Отображаемое название",
|
||||
"label_placeholder": "напр., Работа, Личное, Срочно",
|
||||
"id_field": "Идентификатор ключевого слова",
|
||||
"id_field": "Идентификатор тега",
|
||||
"id_placeholder": "напр., work, personal",
|
||||
"color_field": "Цвет",
|
||||
"id_exists": "Этот идентификатор ключевого слова уже существует",
|
||||
"edit": "Редактировать ключевое слово",
|
||||
"delete": "Удалить ключевое слово",
|
||||
"id_exists": "Этот идентификатор тега уже существует",
|
||||
"edit": "Редактировать тег",
|
||||
"delete": "Удалить тег",
|
||||
"save": "Сохранить",
|
||||
"add": "Добавить",
|
||||
"cancel": "Отмена",
|
||||
"migrating": "Обновление ключевого слова в существующих письмах…",
|
||||
"migration_error": "Не удалось обновить ключевое слово в существующих письмах"
|
||||
"migrating": "Обновление тега в существующих письмах…",
|
||||
"migration_error": "Не удалось обновить тег в существующих письмах"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Проверить звук уведомления",
|
||||
@@ -1337,7 +1337,7 @@
|
||||
"forward": "Переслать на",
|
||||
"mark_read": "Отметить прочитанным",
|
||||
"star": "Пометить сообщение",
|
||||
"add_label": "Добавить метку",
|
||||
"add_label": "Добавить тег",
|
||||
"discard": "Удалить (без уведомления)",
|
||||
"reject": "Отклонить с сообщением",
|
||||
"keep": "Оставить во входящих",
|
||||
@@ -1349,8 +1349,8 @@
|
||||
"forward_placeholder": "user@пример.рф",
|
||||
"reject_message": "Сообщение об отклонении",
|
||||
"reject_placeholder": "Ваше письмо было отклонено",
|
||||
"label_name": "Название метки",
|
||||
"label_placeholder": "напр., важное",
|
||||
"label_name": "Название тега",
|
||||
"label_placeholder": "Выбрать тег",
|
||||
"header_name": "Имя заголовка",
|
||||
"header_placeholder": "напр., X-Mailing-List",
|
||||
"size_bytes": "Размер в байтах",
|
||||
@@ -1524,8 +1524,8 @@
|
||||
"delete": "Удалить",
|
||||
"mark_as_spam": "Отметить как спам",
|
||||
"not_spam": "Не спам",
|
||||
"color_tag": "Метка",
|
||||
"remove_color": "Убрать метку",
|
||||
"color_tag": "Тег",
|
||||
"remove_color": "Удалить тег",
|
||||
"items_selected": "{count} писем выбрано",
|
||||
"edit_draft": "Редактировать черновик"
|
||||
},
|
||||
|
||||
+12
-12
@@ -657,7 +657,7 @@
|
||||
"filters": "过滤器",
|
||||
"templates": "模板",
|
||||
"folders": "文件夹",
|
||||
"keywords": "关键词",
|
||||
"keywords": "标签",
|
||||
"security": "安全",
|
||||
"files": "文件",
|
||||
"contacts": "联系人",
|
||||
@@ -725,23 +725,23 @@
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"title": "邮件关键字",
|
||||
"description": "定义关键字(标签)并用颜色整理邮件。这些关键字会作为 JMAP 关键字保存在服务器上。",
|
||||
"add_keyword": "添加关键字",
|
||||
"title": "电子邮件标签",
|
||||
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
|
||||
"add_keyword": "添加标签",
|
||||
"reset_defaults": "重置为默认值",
|
||||
"label_field": "显示名称",
|
||||
"label_placeholder": "例如工作、个人、紧急",
|
||||
"id_field": "关键字 ID",
|
||||
"id_field": "标签ID",
|
||||
"id_placeholder": "例如工作、个人",
|
||||
"color_field": "颜色",
|
||||
"id_exists": "该关键字 ID 已存在",
|
||||
"edit": "编辑关键字",
|
||||
"delete": "删除关键字",
|
||||
"id_exists": "此标签ID已存在",
|
||||
"edit": "编辑标签",
|
||||
"delete": "删除标签",
|
||||
"save": "保存",
|
||||
"add": "添加",
|
||||
"cancel": "取消",
|
||||
"migrating": "正在更新现有邮件的关键字...",
|
||||
"migration_error": "无法更新现有邮件的关键字"
|
||||
"migrating": "正在更新现有邮件的标签…",
|
||||
"migration_error": "更新现有邮件的标签失败"
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "测试通知声音",
|
||||
@@ -1350,7 +1350,7 @@
|
||||
"reject_message": "拒绝留言",
|
||||
"reject_placeholder": "您的邮件已被拒绝",
|
||||
"label_name": "标签名称",
|
||||
"label_placeholder": "例如,重要",
|
||||
"label_placeholder": "选择标签",
|
||||
"header_name": "标头名称",
|
||||
"header_placeholder": "例如,X-Mailing-List",
|
||||
"size_bytes": "大小(以字节为单位)",
|
||||
@@ -1525,7 +1525,7 @@
|
||||
"mark_as_spam": "举报垃圾邮件",
|
||||
"not_spam": "不是垃圾邮件",
|
||||
"color_tag": "标签",
|
||||
"remove_color": "移除标签",
|
||||
"remove_color": "删除标签",
|
||||
"items_selected": "已选择 {count} 封邮件",
|
||||
"edit_draft": "编辑草稿"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user