Merge pull request #925 from ponchofiesta/feature/oauth-callback-handler-cleaned

OAuth handler for plugins; extended contacts and addressbook API
This commit is contained in:
Linus Rath
2026-08-31 18:08:46 +02:00
committed by GitHub
36 changed files with 998 additions and 56 deletions
+2
View File
@@ -10,6 +10,7 @@ import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { locales } from "@/i18n/routing";
@@ -45,6 +46,7 @@ export default async function LocaleLayout({
{children}
<PluginDialogHost />
<PluginConsentDialog />
<PluginOAuthCallbackListener />
<PWAInstallPrompt />
<PushNotificationPrompt />
</ProtocolLaunchHandlerProvider>
@@ -0,0 +1,151 @@
"use client";
/**
* Generic OAuth callback landing page for plugins.
*
* Plugins that connect to third-party OAuth providers (Google, Nextcloud, …)
* cannot register their own routes. They send the user to the provider's
* authorize URL with a redirect_uri pointing here; this page parks the
* authorization code + state and notifies the plugin that initiated the flow.
* No tokens ever pass through this page - the plugin exchanges the code
* itself (via api.http.fetch, through its origin allowlist).
*
* Delivery chain:
* 1. The payload `{ code, state, receivedAt }` is written to sessionStorage
* (`plugin-oauth-callback`) and mirrored into localStorage under the same
* key, plus a same-tab CustomEvent `plugin-oauth-callback`.
* 2. The host listens via `PluginOAuthCallbackListener` (mounted in the
* [locale] layout): the localStorage write raises a `storage` event in
* every OTHER tab of this origin, including the tab that started the
* flow. The listener forwards the payload to plugins through the
* `authHooks.onOAuthCallback` hook bus.
* 3. Each plugin validates `state` against the verifier it stashed before
* redirecting and ignores payloads that are not its own.
*
* The page then shows a status screen - the user closes the tab (or switches
* back) while the original tab completes the token exchange.
*/
import { OAuthStatusCard } from "@/components/auth/oauth-status-card";
import {
PLUGIN_OAUTH_EVENT,
PLUGIN_OAUTH_STORAGE_KEY,
type OAuthCallbackPayload,
} from "@/lib/plugin-oauth";
import { CheckCircle2, XCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
function PluginOAuthCallbackInner() {
const searchParams = useSearchParams();
const t = useTranslations("plugins");
const [status, setStatus] = useState<"waiting" | "ok" | "error">("waiting");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
const code = searchParams.get("code");
const state = searchParams.get("state");
const errorParam = searchParams.get("error");
const errorDescription = searchParams.get("error_description");
// Build the payload to hand off. Error/denial responses still carry
// `state`, so the initiating plugin can match and terminate its pending
// flow instead of waiting forever.
let payload: OAuthCallbackPayload;
let errorMessageText: string;
if (errorParam) {
payload = {
state: state ?? undefined,
error: errorParam,
error_description: errorDescription ?? undefined,
receivedAt: Date.now(),
};
errorMessageText =
errorParam === "access_denied"
? t("oauth_callback.denied")
: t("oauth_callback.failed_detail", { error: errorParam });
} else if (!code || !state) {
payload = { state: state ?? undefined, error: "invalid_request", receivedAt: Date.now() };
errorMessageText = t("oauth_callback.missing_params");
} else {
payload = { code, state, receivedAt: Date.now() };
errorMessageText = "";
}
let delivered = false;
try {
sessionStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
} catch {
// sessionStorage unavailable - the event still fires for same-tab flows
}
try {
// localStorage (unlike sessionStorage) raises a `storage` event in the
// OTHER tabs of this origin - that is what carries the payload across
// to the tab where the plugin's background instance is running.
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
delivered = true;
} catch {
// localStorage unavailable - fall through to the same-tab CustomEvent
}
// Same-tab consumers can react immediately without waiting for storage.
window.dispatchEvent(
new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }),
);
if (errorMessageText) {
setErrorMessage(errorMessageText);
setStatus("error");
} else if (delivered) {
setStatus("ok");
} else {
// Storage write failed: the payload cannot reach the tab that started
// the flow (the listener is disabled on this route, so the same-tab
// event alone is not enough). Report failure instead of a false "ok".
setStatus("error");
setErrorMessage(t("oauth_callback.delivery_failed"));
}
}, [searchParams, t]);
return (
<OAuthStatusCard
icon={
status === "ok"
? CheckCircle2
: status === "error"
? XCircle
: undefined
}
iconClassName={
status === "ok"
? "bg-green-500/10 text-green-600 dark:text-green-400"
: status === "error"
? "bg-destructive/10 text-destructive"
: undefined
}
title={
status === "waiting"
? t("oauth_callback.completing")
: status === "ok"
? t("oauth_callback.received_title")
: t("oauth_callback.failed_title")
}
message={
status === "waiting"
? t("oauth_callback.completing_message")
: status === "ok"
? t("oauth_callback.received_message")
: (errorMessage ?? undefined)
}
/>
);
}
export default function PluginOAuthCallbackPage() {
return (
<Suspense fallback={<OAuthStatusCard title="" />}>
<PluginOAuthCallbackInner />
</Suspense>
);
}
@@ -0,0 +1,173 @@
import { authHooks } from "@/lib/plugin-hooks";
import {
PLUGIN_OAUTH_EVENT,
PLUGIN_OAUTH_STORAGE_KEY,
} from "@/lib/plugin-oauth";
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginOAuthCallbackListener } from "../providers/plugin-oauth-callback-listener";
vi.mock("next/navigation", () => ({
usePathname: vi.fn(),
}));
import { usePathname } from "next/navigation";
const mockUsePathname = vi.mocked(usePathname);
function emitStorageEvent(newValue: string | null) {
act(() => {
window.dispatchEvent(
new StorageEvent("storage", {
key: PLUGIN_OAUTH_STORAGE_KEY,
newValue,
}),
);
});
}
describe("PluginOAuthCallbackListener", () => {
let handler: (payload: unknown) => Promise<void>;
let disposable: { dispose: () => void };
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockUsePathname.mockReturnValue("/en/mail");
handler = vi.fn<(payload: unknown) => Promise<void>>().mockResolvedValue(undefined);
disposable = authHooks.onOAuthCallback.register("test-plugin", handler);
});
afterEach(() => {
disposable.dispose();
});
it("delivers a success payload via the storage event", () => {
const { unmount } = render(<PluginOAuthCallbackListener />);
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ code: "abc", state: "xyz" }),
);
// Best-effort cleanup after delivery
expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBeNull();
unmount();
});
it("delivers an error payload (denial) with state", () => {
const { unmount } = render(<PluginOAuthCallbackListener />);
const payload = { state: "xyz", error: "access_denied", receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ state: "xyz", error: "access_denied" }),
);
unmount();
});
it("does nothing on the callback route", () => {
mockUsePathname.mockReturnValue("/en/plugins/oauth/callback");
const { unmount } = render(<PluginOAuthCallbackListener />);
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
act(() => {
window.dispatchEvent(
new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }),
);
});
expect(handler).not.toHaveBeenCalled();
// No cleanup either - the payload stays parked for the other tab
expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBe(payload ? JSON.stringify(payload) : null);
unmount();
});
it("ignores malformed payloads", () => {
const { unmount } = render(<PluginOAuthCallbackListener />);
emitStorageEvent("not json");
emitStorageEvent(JSON.stringify({ foo: "bar" }));
emitStorageEvent(JSON.stringify({ code: 123, state: "xyz" }));
emitStorageEvent(null);
expect(handler).not.toHaveBeenCalled();
unmount();
});
it("ignores payloads without state", () => {
const { unmount } = render(<PluginOAuthCallbackListener />);
// A payload with no state at all cannot be matched to any pending flow,
// so the listener drops it rather than broadcasting an unmatchable event.
emitStorageEvent(JSON.stringify({ code: "abc", receivedAt: 1 }));
emitStorageEvent(JSON.stringify({ error: "server_error", receivedAt: 1 }));
expect(handler).not.toHaveBeenCalled();
unmount();
});
it("deduplicates identical payloads within the window", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(0);
const { unmount } = render(<PluginOAuthCallbackListener />);
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
expect(handler).toHaveBeenCalledOnce();
// Outside the dedup window the same payload is delivered again
vi.setSystemTime(61_000);
emitStorageEvent(JSON.stringify(payload));
expect(handler).toHaveBeenCalledTimes(2);
unmount();
} finally {
vi.useRealTimers();
}
});
it("delivers same-tab custom events", () => {
const { unmount } = render(<PluginOAuthCallbackListener />);
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
act(() => {
window.dispatchEvent(
new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }),
);
});
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ code: "abc", state: "xyz" }),
);
unmount();
});
it("stops listening after unmount", () => {
const { unmount } = render(<PluginOAuthCallbackListener />);
unmount();
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
act(() => {
window.dispatchEvent(
new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }),
);
});
expect(handler).not.toHaveBeenCalled();
});
});
+113
View File
@@ -0,0 +1,113 @@
"use client";
/**
* Branded full-screen status card for OAuth callback pages.
*/
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/theme-store";
import type { LucideIcon } from "lucide-react";
import { Loader2 } from "lucide-react";
export interface OAuthStatusAction {
label: string;
onClick: () => void;
}
export interface OAuthStatusCardProps {
/** Icon badge shown below the logo (defaults to a spinner) */
icon?: LucideIcon;
/** Tailwind color classes for the icon badge, e.g. "bg-green-500/10 text-green-600" */
iconClassName?: string;
title: string;
message?: string;
action?: OAuthStatusAction;
/** Extra content rendered below the message (e.g. detail lines) */
children?: React.ReactNode;
}
export function OAuthStatusCard({
icon: Icon,
iconClassName,
title,
message,
action,
children,
}: OAuthStatusCardProps) {
const {
appName,
appLogoLightUrl,
appLogoDarkUrl,
loginLogoLightUrl,
loginLogoDarkUrl,
} = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
// Same logo resolution order as the admin layout: app logo first, then the
// login logo (which has Bulwark defaults), so an unbranded instance still
// shows the Bulwark mark.
const logoUrl = withBasePath(
resolvedTheme === "dark"
? appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl
: appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl,
);
return (
<div className="min-h-dvh flex flex-col items-center justify-center bg-linear-to-br from-background via-background to-muted/20 px-4 py-10">
<div className="w-full max-w-100 mx-auto">
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header with instance branding */}
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<img
src={logoUrl}
alt={appName}
className="max-w-16 max-h-16 object-contain"
/>
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{appName}
</h1>
{/* Status badge */}
<div
className={cn(
"mt-6 inline-flex items-center justify-center w-14 h-14 rounded-2xl",
iconClassName ?? "bg-primary/10 text-primary",
)}
>
{Icon ? (
<Icon className="w-7 h-7" />
) : (
<Loader2 className="w-7 h-7 animate-spin" />
)}
</div>
</div>
{/* Status body */}
<div className="px-8 pb-10 pt-0 text-center">
<p className="text-base font-medium text-foreground">{title}</p>
{message && (
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
{message}
</p>
)}
{children}
{action && (
<Button
variant="outline"
className="mt-6"
onClick={action.onClick}
>
{action.label}
</Button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,109 @@
'use client';
/**
* Bridges third-party OAuth callbacks to plugins.
*
* The generic callback page (`/[locale]/plugins/oauth/callback`) parks the
* provider's `{ code, state }` payload in localStorage. A `storage` event
* fires in every OTHER same-origin tab - i.e. in the tab where the plugin's
* background sandbox instance is actually running. This listener forwards
* those payloads (and same-tab CustomEvents from the callback page) into the
* `authHooks.onOAuthCallback` hook bus so plugins can finish their PKCE
* exchange.
*
* This component intentionally does nothing when mounted ON the callback
* route: the callback tab must not process its own payload, otherwise two
* app instances would race to exchange the single-use authorization code.
*/
import { authHooks } from '@/lib/plugin-hooks';
import {
PLUGIN_OAUTH_EVENT as EVENT_NAME,
PLUGIN_OAUTH_STORAGE_KEY as STORAGE_KEY,
type OAuthCallbackPayload,
} from '@/lib/plugin-oauth';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
/** Window in which identical payloads are treated as duplicates. */
const DEDUP_MS = 60_000;
function parsePayload(raw: string | null | undefined): OAuthCallbackPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsed !== 'object' || parsed === null) {
return null;
}
const state = typeof parsed.state === 'string' ? parsed.state : undefined;
// A payload without `state` cannot be matched to any pending flow, so
// drop it instead of broadcasting an unmatchable event.
if (state === undefined) {
return null;
}
const receivedAt = typeof parsed.receivedAt === 'number' ? parsed.receivedAt : Date.now();
// Success payloads carry { code, ... }; error/denial payloads carry
// { error, ... }. Exactly one must identify the kind.
if (typeof parsed.code === 'string') {
return { code: parsed.code, state, receivedAt };
}
if (typeof parsed.error === 'string') {
return {
error: parsed.error,
state,
error_description:
typeof parsed.error_description === 'string' ? parsed.error_description : undefined,
receivedAt,
};
}
return null;
} catch {
return null;
}
}
export function PluginOAuthCallbackListener() {
const pathname = usePathname();
// The callback route ends with /plugins/oauth/callback (locale is a
// preceding segment). On that route this component is a no-op.
const isCallbackRoute = !!pathname && pathname.endsWith('/plugins/oauth/callback');
useEffect(() => {
if (isCallbackRoute) return;
let lastCode: string | null = null;
let lastState: string | null = null;
let lastAt = 0;
const deliver = (payload: OAuthCallbackPayload | null) => {
if (!payload) return;
const now = Date.now();
const code = 'code' in payload ? payload.code : null;
if (code === lastCode && payload.state === lastState && now - lastAt < DEDUP_MS) return;
lastCode = code;
lastState = payload.state ?? null;
lastAt = now;
void authHooks.onOAuthCallback.emit(payload);
// Best-effort cleanup so a reload doesn't replay an old payload.
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
};
const onStorage = (ev: StorageEvent) => {
if (ev.key !== STORAGE_KEY && ev.key !== null) return;
deliver(parsePayload(ev.newValue ?? (() => { try { return localStorage.getItem(STORAGE_KEY); } catch { return null; } })()));
};
const onCustomEvent = (ev: Event) => {
const detail = (ev as CustomEvent<OAuthCallbackPayload>).detail;
if (detail) deliver(detail);
};
window.addEventListener('storage', onStorage);
window.addEventListener(EVENT_NAME, onCustomEvent);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener(EVENT_NAME, onCustomEvent);
};
}, [isCallbackRoute]);
return null;
}
+9
View File
@@ -427,6 +427,15 @@ export const authHooks = {
onAccountRemove: new HookBus(),
onTokenRefresh: new HookBus(),
onAuthReady: new HookBus(),
/**
* Fired when a third-party OAuth provider redirects back to the generic
* plugin OAuth callback page (`/[locale]/plugins/oauth/callback`). The
* payload is `{ code, state, receivedAt }` exactly as the provider sent it.
* Each plugin validates `state` against the verifier it stashed before
* redirecting, so plugins that did not initiate a flow simply ignore the
* event. See `components/providers/plugin-oauth-callback-listener.tsx`.
*/
onOAuthCallback: new HookBus(),
};
// §7.6 Settings Hooks
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared constants and types for the plugin OAuth callback handoff.
*
* The callback page (`/[locale]/plugins/oauth/callback`) parks the provider's
* payload in storage and dispatches events; the host relays it to plugins via
* the `authHooks.onOAuthCallback` hook bus. Both sides share the storage key,
* event name and payload contract from this module.
*/
export const PLUGIN_OAUTH_STORAGE_KEY = "plugin-oauth-callback";
export const PLUGIN_OAUTH_EVENT = "plugin-oauth-callback";
/**
* Payload delivered to plugins through `authHooks.onOAuthCallback`.
*
* Two shapes, discriminated by which field the provider returned:
* - Success: `{ code, state?, receivedAt? }`
* - Denial/failure: `{ error, state?, error_description?, receivedAt? }`
*
* `state` stays optional in both variants because a provider may omit it even
* on error; plugins match the flow against the verifier they stashed before
* redirecting and ignore payloads whose `state` does not match. Narrow with
* `'code' in payload` / `'error' in payload`.
*/
export type OAuthCallbackPayload =
| { code: string; state?: string; receivedAt?: number }
| {
error: string;
state?: string;
error_description?: string;
receivedAt?: number;
};
+47 -1
View File
@@ -27,7 +27,7 @@ import { KEYWORD_PREFIX } from '../thread-utils';
import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog';
import { fileStorage } from '../plugin-storage';
import { generateUUID } from '../utils';
import { ContactCard, Identity } from '../jmap/types';
import { AddressBook, ContactCard, Identity } from '../jmap/types';
import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecurityStore } from '@/stores/account-security-store';
import { createHash } from 'crypto';
@@ -113,6 +113,11 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'contact.update': 'contacts:write',
'contact.create': 'contacts:write',
'contact.search': 'contacts:read',
'contact.list': 'contacts:read',
'contact.delete': 'contacts:write',
// addressbook
'addressbook.list': 'contacts:read',
'addressbook.create': 'contacts:write',
// user
'user.getAccounts': 'account:read',
'user.getIdentities': 'identity:read',
@@ -648,6 +653,42 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
return await client.createContact(contact);
}
async function doContactList(addressBookId?: string): Promise<ContactCard[]> {
const { client } = useAuthStore.getState();
if (!client) {
throw new Error('contact.list: no active session');
}
// Propagate failures: a plugin must not mistake an outage for an empty
// address book.
return await client.getContacts(addressBookId, { throwOnError: true });
}
async function doContactDelete(contactId: string): Promise<void> {
const { client } = useAuthStore.getState();
if (!client) {
throw new Error('contact.delete: no active session');
}
await client.deleteContact(contactId);
}
async function doAddressBookList(): Promise<AddressBook[]> {
const { client } = useAuthStore.getState();
if (!client) {
throw new Error('addressbook.list: no active session');
}
// Propagate failures so plugins can tell "no books" from a failed fetch
// (see #730).
return await client.getAddressBooks({ throwOnError: true });
}
async function doAddressBookCreate(name: string): Promise<AddressBook> {
const { client } = useAuthStore.getState();
if (!client) {
throw new Error('addressbook.create: no active session');
}
return await client.createAddressBook(name);
}
// ─── Crypto (privileged tier) ─────────────────────────────────────────────
type PRFResult =
@@ -1406,6 +1447,11 @@ export async function dispatchApiCall(
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
case 'contact.create': return doContactCreate(args[0] as ContactCard);
case 'contact.search': return doContactSearch(args[0] as string);
case 'contact.list': return doContactList(args[0] as string | undefined);
case 'contact.delete': return doContactDelete(args[0] as string);
case 'addressbook.list': return doAddressBookList();
case 'addressbook.create': return doAddressBookCreate(args[0] as string);
case 'user.getAccounts': return doUserGetAccounts();
case 'user.getIdentities': return doUserGetIdentities();
+10 -4
View File
@@ -29,7 +29,7 @@ import type {
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import type { SlotName } from '../plugin-types';
import { ContactCard } from '../jmap/types';
import { AddressBook, ContactCard } from '../jmap/types';
import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store';
// ─── Module-scope state ──────────────────────────────────────
@@ -279,10 +279,16 @@ function buildPluginApi(manifest: PluginManifest) {
) => callApi('jmap.importRaw', [rawBytes, mailboxRoles, opts]),
},
contacts: {
get: (contactId: string) => callApi('contact.get', [contactId]) as Promise<ContactCard>,
update: (contactId: string, updates: Partial<ContactCard>) => callApi('contact.update', [contactId, updates]),
create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise<string>,
get: (contactId: string) => callApi('contact.get', [contactId]) as Promise<ContactCard | null>,
update: (contactId: string, updates: Partial<ContactCard>) => callApi('contact.update', [contactId, updates]) as Promise<void>,
create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise<ContactCard>,
search: (query: string) => callApi('contact.search', [query]) as Promise<ContactCard[]>,
list: (addressBookId?: string) => callApi('contact.list', [addressBookId]) as Promise<ContactCard[]>,
remove: (contactId: string) => callApi('contact.delete', [contactId]) as Promise<void>,
},
addressBooks: {
list: () => callApi('addressbook.list', []) as Promise<AddressBook[]>,
create: (name: string) => callApi('addressbook.create', [name]) as Promise<AddressBook>,
},
/**
* Used to alterate files before they are uploaded to server.
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "نقل",
"print": "طباعة",
"view_source": "عرض المصدر",
"fullscreen": "ملء الشاشة",
"exit_fullscreen": "الخروج من ملء الشاشة",
"export_email": "تصدير كملف ‎.eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "نسخ رابط الحدث",
"copied": "تم نسخ الرابط",
"copy_failed": "تعذّر نسخ الرابط"
},
"plugins": {
"oauth_callback": {
"completing": "جارٍ إكمال التفويض…",
"completing_message": "يرجى الانتظار بينما يتم إنشاء الاتصال.",
"received_title": "تم استلام التفويض",
"received_message": "يمكنك العودة إلى علامة التبويب التي بدأت فيها الاتصال.",
"failed_title": "فشل التفويض",
"denied": "تم رفض التفويض.",
"failed_detail": "فشل التفويض: {error}",
"missing_params": "رمز التفويض أو الحالة مفقود.",
"delivery_failed": "تعذّر إرسال نتيجة التفويض إلى تبويب التطبيق. يرجى إغلاق هذه النافذة والمحاولة مرة أخرى."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Mou",
"print": "Imprimeix",
"view_source": "Mostra el codi font",
"fullscreen": "Pantalla completa",
"exit_fullscreen": "Surt de la pantalla completa",
"export_email": "Exporta com a .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Copia l'enllaç de l'esdeveniment",
"copied": "Enllaç copiat",
"copy_failed": "No s'ha pogut copiar l'enllaç"
},
"plugins": {
"oauth_callback": {
"completing": "Completant l'autorització…",
"completing_message": "Espereu mentre s'estableix la connexió.",
"received_title": "S'ha rebut l'autorització",
"received_message": "Podeu tornar a la pestanya on heu iniciat la connexió.",
"failed_title": "Ha fallat l'autorització",
"denied": "Sha denegat lautorització.",
"failed_detail": "Ha fallat lautorització: {error}",
"missing_params": "Falta el codi dautorització o lestat.",
"delivery_failed": "No sha pogut lliurar el resultat de lautorització a la pestanya de laplicació. Tanqueu aquesta finestra i torneu-ho a provar."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Přesunout",
"print": "Tisk",
"view_source": "Zobrazit zdrojový kód",
"fullscreen": "Celá obrazovka",
"exit_fullscreen": "Ukončit režim celé obrazovky",
"export_email": "Exportovat jako .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Kopírovat odkaz na událost",
"copied": "Odkaz zkopírován",
"copy_failed": "Odkaz se nepodařilo zkopírovat"
},
"plugins": {
"oauth_callback": {
"completing": "Dokončování autorizace…",
"completing_message": "Počkejte prosím, než se naváže spojení.",
"received_title": "Autorizace přijata",
"received_message": "Můžete se vrátit na kartu, kde jste zahájili připojení.",
"failed_title": "Autorizace se nezdařila",
"denied": "Autorizace byla zamítnuta.",
"failed_detail": "Autorizace se nezdařila: {error}",
"missing_params": "Chybí autorizační kód nebo stav.",
"delivery_failed": "Výsledek autorizace se nepodařilo předat kartě aplikace. Zavřete toto okno a zkuste to znovu."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Flyt",
"print": "Udskriv",
"view_source": "Vis kilde",
"fullscreen": "Fuld skærm",
"exit_fullscreen": "Afslut fuld skærm",
"export_email": "Eksportér som .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Kopier link til begivenhed",
"copied": "Link kopieret",
"copy_failed": "Kunne ikke kopiere linket"
},
"plugins": {
"oauth_callback": {
"completing": "Fuldfører godkendelse…",
"completing_message": "Vent venligst, mens forbindelsen oprettes.",
"received_title": "Godkendelse modtaget",
"received_message": "Du kan vende tilbage til den fane, hvor du startede forbindelsen.",
"failed_title": "Godkendelse mislykkedes",
"denied": "Godkendelse blev nægtet.",
"failed_detail": "Godkendelse mislykkedes: {error}",
"missing_params": "Autorisationskode eller tilstand mangler.",
"delivery_failed": "Resultatet af godkendelsen kunne ikke leveres til app-fanen. Luk dette vindue og prøv igen."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Verschieben",
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"fullscreen": "Vollbild",
"exit_fullscreen": "Vollbild beenden",
"export_email": "Als .eml exportieren",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Link zum Termin kopieren",
"copied": "Link kopiert",
"copy_failed": "Link konnte nicht kopiert werden"
},
"plugins": {
"oauth_callback": {
"completing": "Autorisierung wird abgeschlossen…",
"completing_message": "Bitte warten, während die Verbindung hergestellt wird.",
"received_title": "Autorisierung empfangen",
"received_message": "Sie können zu dem Tab zurückkehren, in dem Sie die Verbindung gestartet haben.",
"failed_title": "Autorisierung fehlgeschlagen",
"denied": "Autorisierung wurde verweigert.",
"failed_detail": "Autorisierung fehlgeschlagen: {error}",
"missing_params": "Autorisierungscode oder State fehlt.",
"delivery_failed": "Das Autorisierungsergebnis konnte nicht an den App-Tab übermittelt werden. Bitte schließen Sie dieses Fenster und versuchen Sie es erneut."
}
}
}
+14 -3
View File
@@ -303,9 +303,7 @@
"move": "Move",
"print": "Print",
"view_source": "View source",
"fullscreen": "Fullscreen",
"exit_fullscreen": "Exit fullscreen",
"export_email": "Export as .eml",
"forward_as_attachment": "Forward as attachment",
@@ -1670,7 +1668,7 @@
"disable_tooltip": "Disable encryption",
"enabled_success": "Encryption at rest enabled"
},
"public_keys":{
"public_keys": {
"name_label": "Public Key name",
"name_placeholder": "e.g. My Key",
"key_label": "Public Key (S/MIME or PGP ASCII-armored)",
@@ -3541,5 +3539,18 @@
"copy_event": "Copy link to event",
"copied": "Link copied",
"copy_failed": "Couldn't copy the link"
},
"plugins": {
"oauth_callback": {
"completing": "Completing authorization…",
"completing_message": "Please wait while the connection is established.",
"received_title": "Authorization received",
"received_message": "You can return to the tab where you started the connection.",
"failed_title": "Authorization failed",
"denied": "Authorization was denied.",
"failed_detail": "Authorization failed: {error}",
"missing_params": "Missing authorization code or state.",
"delivery_failed": "The authorization result could not be delivered to the app tab. Please close this window and try again."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Mover",
"print": "Imprimir",
"view_source": "Ver código fuente",
"fullscreen": "Pantalla completa",
"exit_fullscreen": "Salir de pantalla completa",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Copiar enlace del evento",
"copied": "Enlace copiado",
"copy_failed": "No se pudo copiar el enlace"
},
"plugins": {
"oauth_callback": {
"completing": "Completando la autorización…",
"completing_message": "Por favor, espera mientras se establece la conexión.",
"received_title": "Autorización recibida",
"received_message": "Puedes volver a la pestaña donde iniciaste la conexión.",
"failed_title": "Error de autorización",
"denied": "Se denegó la autorización.",
"failed_detail": "Error de autorización: {error}",
"missing_params": "Falta el código de autorización o el estado.",
"delivery_failed": "No se pudo entregar el resultado de la autorización a la pestaña de la aplicación. Cierre esta ventana e inténtelo de nuevo."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "انتقال",
"print": "چاپ",
"view_source": "مشاهده منبع",
"fullscreen": "تمام‌صفحه",
"exit_fullscreen": "خروج از تمام‌صفحه",
"export_email": "خروجی .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "کپی پیوند رویداد",
"copied": "پیوند کپی شد",
"copy_failed": "کپی پیوند ممکن نشد"
},
"plugins": {
"oauth_callback": {
"completing": "در حال تکمیل احراز هویت…",
"completing_message": "لطفاً تا برقراری اتصال منتظر بمانید.",
"received_title": "احراز هویت دریافت شد",
"received_message": "می‌توانید به برگه‌ای که اتصال را در آن آغاز کردید بازگردید.",
"failed_title": "احراز هویت ناموفق بود",
"denied": "احراز هویت رد شد.",
"failed_detail": "احراز هویت ناموفق بود: {error}",
"missing_params": "کد احراز هویت یا حالت وجود ندارد.",
"delivery_failed": "نتیجه احراز هویت به برگه برنامه ارسال نشد. لطفاً این پنجره را ببندید و دوباره تلاش کنید."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Déplacer",
"print": "Imprimer",
"view_source": "Voir la source",
"fullscreen": "Plein écran",
"exit_fullscreen": "Quitter le plein écran",
"export_email": "Exporter en .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Copier le lien de l'événement",
"copied": "Lien copié",
"copy_failed": "Impossible de copier le lien"
},
"plugins": {
"oauth_callback": {
"completing": "Finalisation de l'autorisation…",
"completing_message": "Veuillez patienter pendant l'établissement de la connexion.",
"received_title": "Autorisation reçue",
"received_message": "Vous pouvez revenir à l'onglet où vous avez commencé la connexion.",
"failed_title": "Échec de l'autorisation",
"denied": "Lautorisation a été refusée.",
"failed_detail": "Échec de lautorisation : {error}",
"missing_params": "Code dautorisation ou état manquant.",
"delivery_failed": "Le résultat de lautorisation na pas pu être transmis à longlet de lapplication. Fermez cette fenêtre et réessayez."
}
}
}
+13 -2
View File
@@ -250,9 +250,7 @@
"mark_read": "סמן כנקרא",
"print": "הדפס",
"view_source": "צפה במקור",
"fullscreen": "מסך מלא",
"exit_fullscreen": "יציאה ממסך מלא",
"export_email": "ייצא כ-.eml",
"forward_as_attachment": "Forward as attachment",
@@ -3542,5 +3540,18 @@
"copy_event": "העתק קישור לאירוע",
"copied": "הקישור הועתק",
"copy_failed": "לא ניתן להעתיק את הקישור"
},
"plugins": {
"oauth_callback": {
"completing": "משלים אימות…",
"completing_message": "אנא המתן בזמן יצירת החיבור.",
"received_title": "האימות התקבל",
"received_message": "באפשרותך לחזור לכרטיסייה שבה התחלת את החיבור.",
"failed_title": "האימות נכשל",
"denied": "האימות נדחה.",
"failed_detail": "האימות נכשל: {error}",
"missing_params": "חסר קוד אימות או מצב.",
"delivery_failed": "לא ניתן היה למסור את תוצאת האימות לכרטיסיית האפליקציה. סגרו חלון זה ונסו שוב."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Áthelyezés",
"print": "Nyomtatás",
"view_source": "Forrás megtekintése",
"fullscreen": "Teljes képernyő",
"exit_fullscreen": "Kilépés a teljes képernyőből",
"export_email": "Exportálás .eml-ként",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Esemény linkjének másolása",
"copied": "Link másolva",
"copy_failed": "Nem sikerült másolni a linket"
},
"plugins": {
"oauth_callback": {
"completing": "Hitelesítés befejezése…",
"completing_message": "Kérjük, várjon, amíg a kapcsolat létrejön.",
"received_title": "Hitelesítés fogadva",
"received_message": "Visszatérhet arra a lapra, ahol elindította a kapcsolódást.",
"failed_title": "A hitelesítés sikertelen",
"denied": "A hitelesítés megtagadva.",
"failed_detail": "A hitelesítés sikertelen: {error}",
"missing_params": "Hiányzik a hitelesítési kód vagy az állapot.",
"delivery_failed": "A hitelesítés eredményét nem sikerült átadni az alkalmazás lapjának. Zárja be ezt az ablakot, és próbálja újra."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Sposta",
"print": "Stampa",
"view_source": "Visualizza sorgente",
"fullscreen": "Schermo intero",
"exit_fullscreen": "Esci da schermo intero",
"export_email": "Esporta come .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Copia link all'evento",
"copied": "Link copiato",
"copy_failed": "Impossibile copiare il link"
},
"plugins": {
"oauth_callback": {
"completing": "Completamento dell'autorizzazione…",
"completing_message": "Attendi mentre viene stabilita la connessione.",
"received_title": "Autorizzazione ricevuta",
"received_message": "Puoi tornare alla scheda in cui hai avviato la connessione.",
"failed_title": "Autorizzazione non riuscita",
"denied": "Lautorizzazione è stata negata.",
"failed_detail": "Autorizzazione non riuscita: {error}",
"missing_params": "Codice di autorizzazione o stato mancante.",
"delivery_failed": "Impossibile consegnare il risultato dellautorizzazione alla scheda dellapp. Chiudi questa finestra e riprova."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "移動",
"print": "印刷",
"view_source": "ソースを表示",
"fullscreen": "全画面表示",
"exit_fullscreen": "全画面表示を終了",
"export_email": ".emlとしてエクスポート",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "予定のリンクをコピー",
"copied": "リンクをコピーしました",
"copy_failed": "リンクをコピーできませんでした"
},
"plugins": {
"oauth_callback": {
"completing": "認証を完了しています…",
"completing_message": "接続が確立されるまでお待ちください。",
"received_title": "認証を受信しました",
"received_message": "接続を開始したタブに戻ることができます。",
"failed_title": "認証に失敗しました",
"denied": "認証が拒否されました。",
"failed_detail": "認証に失敗しました: {error}",
"missing_params": "認証コードまたはステートがありません。",
"delivery_failed": "認証結果をアプリのタブに渡せませんでした。このウィンドウを閉じて、もう一度お試しください。"
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "이동",
"print": "인쇄",
"view_source": "원본 보기",
"fullscreen": "전체 화면",
"exit_fullscreen": "전체 화면 종료",
"export_email": ".eml 파일로 내보내기",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "일정 링크 복사",
"copied": "링크가 복사되었습니다",
"copy_failed": "링크를 복사하지 못했습니다"
},
"plugins": {
"oauth_callback": {
"completing": "인증 완료 중…",
"completing_message": "연결이 설정되는 동안 잠시 기다려 주세요.",
"received_title": "인증 수신됨",
"received_message": "연결을 시작했던 탭으로 돌아갈 수 있습니다.",
"failed_title": "인증 실패",
"denied": "인증이 거부되었습니다.",
"failed_detail": "인증 실패: {error}",
"missing_params": "인증 코드 또는 상태가 누락되었습니다.",
"delivery_failed": "인증 결과를 앱 탭에 전달하지 못했습니다. 이 창을 닫고 다시 시도해 주세요."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Pārvietot",
"print": "Drukāt",
"view_source": "Skatīt avota kodu",
"fullscreen": "Pilnekrāna režīms",
"exit_fullscreen": "Iziet no pilnekrāna režīma",
"export_email": "Eksportēt kā .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Kopēt saiti uz notikumu",
"copied": "Saite nokopēta",
"copy_failed": "Neizdevās nokopēt saiti"
},
"plugins": {
"oauth_callback": {
"completing": "Pabeidz autorizāciju…",
"completing_message": "Lūdzu, uzgaidiet, kamēr tiek izveidots savienojums.",
"received_title": "Autorizācija saņemta",
"received_message": "Varat atgriezties cilnē, kurā sākāt savienojumu.",
"failed_title": "Autorizācija neizdevās",
"denied": "Autorizācija tika atteikta.",
"failed_detail": "Autorizācija neizdevās: {error}",
"missing_params": "Trūkst autorizācijas kods vai stāvoklis.",
"delivery_failed": "Autorizācijas rezultātu neizdevās nodot lietotnes cilnei. Aizveriet šo logu un mēģiniet vēlreiz."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Хөдөлгөөн",
"print": "Хэвлэх",
"view_source": "Эх сурвалжийг харах",
"fullscreen": "Бүтэн дэлгэц",
"exit_fullscreen": "Бүтэн дэлгэцээс гарах",
"export_email": ".eml",
"forward_as_attachment": "Хавсралт болгон дамжуулах",
@@ -3541,5 +3539,18 @@
"copy_event": "Арга хэмжээний холбоосыг хуулах",
"copied": "Холбоос хуулагдлаа",
"copy_failed": "Холбоосыг хуулж чадсангүй"
},
"plugins": {
"oauth_callback": {
"completing": "Зөвшөөрлийг дуусгаж байна…",
"completing_message": "Холболт үүсэх хүртэл түр хүлээнэ үү.",
"received_title": "Зөвшөөрөл хүлээн авсан",
"received_message": "Та холболт эхлүүлсэн таб руугаа буцаж болно.",
"failed_title": "Зөвшөөрөл амжилтгүй болсон",
"denied": "Зөвшөөрөл олгохыг татгалзсан.",
"failed_detail": "Зөвшөөрөл амжилтгүй болсон: {error}",
"missing_params": "Зөвшөөрлийн код эсвэл төлөв байхгүй байна.",
"delivery_failed": "Зөвшөөрлийн үр дүнг аппын таб руу дамжуулж чадсангүй. Энэ цонхыг хаагаад дахин оролдоно уу."
}
}
}
+13
View File
@@ -3539,5 +3539,18 @@
"copy_event": "Kopier lenken til hendelsen",
"copied": "Lenken er kopiert",
"copy_failed": "Kunne ikke kopiere lenken"
},
"plugins": {
"oauth_callback": {
"completing": "Fullfører autorisering…",
"completing_message": "Vennligst vent mens tilkoblingen opprettes.",
"received_title": "Autorisering mottatt",
"received_message": "Du kan gå tilbake til fanen der du startet tilkoblingen.",
"failed_title": "Autorisering mislyktes",
"denied": "Autorisering ble avslått.",
"failed_detail": "Autorisering mislyktes: {error}",
"missing_params": "Mangler autorisasjonskode eller tilstand.",
"delivery_failed": "Autoriseringsresultatet kunne ikke leveres til app-fanen. Lukk dette vinduet og prøv igjen."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Verplaatsen",
"print": "Afdrukken",
"view_source": "Bron bekijken",
"fullscreen": "Volledig scherm",
"exit_fullscreen": "Volledig scherm sluiten",
"export_email": "Exporteren als .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Link naar afspraak kopiëren",
"copied": "Link gekopieerd",
"copy_failed": "Kon de link niet kopiëren"
},
"plugins": {
"oauth_callback": {
"completing": "Autorisatie voltooien…",
"completing_message": "Even geduld terwijl de verbinding tot stand wordt gebracht.",
"received_title": "Autorisatie ontvangen",
"received_message": "Je kunt terugkeren naar het tabblad waar je de verbinding bent gestart.",
"failed_title": "Autorisatie mislukt",
"denied": "Autorisatie geweigerd.",
"failed_detail": "Autorisatie mislukt: {error}",
"missing_params": "Autorisatiecode of status ontbreekt.",
"delivery_failed": "Het autorisatieresultaat kon niet worden afgeleverd bij het app-tabblad. Sluit dit venster en probeer het opnieuw."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Przenieś",
"print": "Drukuj",
"view_source": "Pokaż źródło",
"fullscreen": "Pełny ekran",
"exit_fullscreen": "Zamknij pełny ekran",
"export_email": "Eksportuj jako .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Kopiuj link do wydarzenia",
"copied": "Link skopiowany",
"copy_failed": "Nie udało się skopiować linku"
},
"plugins": {
"oauth_callback": {
"completing": "Kończenie autoryzacji…",
"completing_message": "Proszę czekać na nawiązanie połączenia.",
"received_title": "Otrzymano autoryzację",
"received_message": "Możesz wrócić do karty, w której rozpoczęto nawiązywanie połączenia.",
"failed_title": "Autoryzacja nie powiodła się",
"denied": "Autoryzacja została odrzucona.",
"failed_detail": "Autoryzacja nie powiodła się: {error}",
"missing_params": "Brak kodu autoryzacji lub stanu.",
"delivery_failed": "Nie udało się przekazać wyniku autoryzacji do karty aplikacji. Zamknij to okno i spróbuj ponownie."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Mover",
"print": "Imprimir",
"view_source": "Ver código-fonte",
"fullscreen": "Tela cheia",
"exit_fullscreen": "Sair da tela cheia",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Copiar link do evento",
"copied": "Link copiado",
"copy_failed": "Não foi possível copiar o link"
},
"plugins": {
"oauth_callback": {
"completing": "A concluir a autorização…",
"completing_message": "Aguarde enquanto a ligação é estabelecida.",
"received_title": "Autorização recebida",
"received_message": "Pode voltar ao separador onde iniciou a ligação.",
"failed_title": "Falha na autorização",
"denied": "A autorização foi negada.",
"failed_detail": "Falha na autorização: {error}",
"missing_params": "Falta o código de autorização ou o estado.",
"delivery_failed": "Não foi possível entregar o resultado da autorização ao separador da aplicação. Feche esta janela e tente novamente."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Mutare",
"print": "Imprimare",
"view_source": "Vizualizați sursa",
"fullscreen": "Ecran complet",
"exit_fullscreen": "Ieșire din ecran complet",
"export_email": "Exportați ca fișier .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Copiați linkul evenimentului",
"copied": "Link copiat",
"copy_failed": "Linkul nu a putut fi copiat"
},
"plugins": {
"oauth_callback": {
"completing": "Se finalizează autorizarea…",
"completing_message": "Vă rugăm să așteptați stabilirea conexiunii.",
"received_title": "Autorizare primită",
"received_message": "Vă puteți întoarce la fila în care ați inițiat conexiunea.",
"failed_title": "Autorizarea a eșuat",
"denied": "Autorizarea a fost refuzată.",
"failed_detail": "Autorizarea a eșuat: {error}",
"missing_params": "Lipsește codul de autorizare sau starea.",
"delivery_failed": "Rezultatul autorizării nu a putut fi transmis filei aplicației. Închideți această fereastră și încercați din nou."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Переместить",
"print": "Распечатать",
"view_source": "Просмотреть исходный код",
"fullscreen": "Во весь экран",
"exit_fullscreen": "Выйти из полноэкранного режима",
"export_email": "Экспортировать как .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Скопировать ссылку на событие",
"copied": "Ссылка скопирована",
"copy_failed": "Не удалось скопировать ссылку"
},
"plugins": {
"oauth_callback": {
"completing": "Завершение авторизации…",
"completing_message": "Пожалуйста, подождите, пока устанавливается соединение.",
"received_title": "Авторизация получена",
"received_message": "Вы можете вернуться на вкладку, где начали подключение.",
"failed_title": "Ошибка авторизации",
"denied": "В авторизации отказано.",
"failed_detail": "Ошибка авторизации: {error}",
"missing_params": "Отсутствует код авторизации или состояние.",
"delivery_failed": "Не удалось передать результат авторизации на вкладку приложения. Закройте это окно и попробуйте снова."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Presunúť",
"print": "Tlačiť",
"view_source": "Zobraziť zdrojový kód",
"fullscreen": "Celá obrazovka",
"exit_fullscreen": "Ukončiť režim celej obrazovky",
"export_email": "Exportovať ako .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Kopírovať odkaz na udalosť",
"copied": "Odkaz skopírovaný",
"copy_failed": "Odkaz sa nepodarilo skopírovať"
},
"plugins": {
"oauth_callback": {
"completing": "Dokončovanie autorizácie…",
"completing_message": "Počkajte, kým sa vytvorí spojenie.",
"received_title": "Autorizácia prijatá",
"received_message": "Môžete sa vrátiť na kartu, kde ste začali pripájanie.",
"failed_title": "Autorizácia zlyhala",
"denied": "Autorizácia bola zamietnutá.",
"failed_detail": "Autorizácia zlyhala: {error}",
"missing_params": "Chýba autorizačný kód alebo stav.",
"delivery_failed": "Výsledok autorizácie sa nepodarilo odovzdať karte aplikácie. Zavrite toto okno a skúste to znova."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Taşı",
"print": "Yazdır",
"view_source": "Kaynağı görüntüle",
"fullscreen": "Tam ekran",
"exit_fullscreen": "Tam ekrandan çık",
"export_email": ".eml olarak dışa aktar",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Etkinlik bağlantısını kopyala",
"copied": "Bağlantı kopyalandı",
"copy_failed": "Bağlantı kopyalanamadı"
},
"plugins": {
"oauth_callback": {
"completing": "Yetkilendirme tamamlanıyor…",
"completing_message": "Bağlantı kurulurken lütfen bekleyin.",
"received_title": "Yetkilendirme alındı",
"received_message": "Bağlantıyı başlattığınız sekmeye dönebilirsiniz.",
"failed_title": "Yetkilendirme başarısız oldu",
"denied": "Yetkilendirme reddedildi.",
"failed_detail": "Yetkilendirme başarısız oldu: {error}",
"missing_params": "Yetkilendirme kodu veya durum eksik.",
"delivery_failed": "Yetkilendirme sonucu uygulama sekmesine iletilemedi. Lütfen bu pencereyi kapatıp tekrar deneyin."
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "Перенести",
"print": "Роздрукувати",
"view_source": "Переглянути джерело",
"fullscreen": "На весь екран",
"exit_fullscreen": "Вийти з повноекранного режиму",
"export_email": "Експортувати як .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "Скопіювати посилання на подію",
"copied": "Посилання скопійовано",
"copy_failed": "Не вдалося скопіювати посилання"
},
"plugins": {
"oauth_callback": {
"completing": "Завершення авторизації…",
"completing_message": "Зачекайте, поки встановлюється з'єднання.",
"received_title": "Авторизацію отримано",
"received_message": "Ви можете повернутися на вкладку, де розпочали підключення.",
"failed_title": "Не вдалося виконати авторизацію",
"denied": "В авторизації відмовлено.",
"failed_detail": "Не вдалося виконати авторизацію: {error}",
"missing_params": "Відсутній код авторизації або стан.",
"delivery_failed": "Не вдалося передати результат авторизації на вкладку програми. Закрийте це вікно та спробуйте ще раз."
}
}
}
+13
View File
@@ -3534,5 +3534,18 @@
"copy_event": "複製活動連結",
"copied": "連結已複製",
"copy_failed": "無法複製連結"
},
"plugins": {
"oauth_callback": {
"completing": "正在完成授權…",
"completing_message": "請稍候,正在建立連線。",
"received_title": "已收到授權",
"received_message": "您可以返回開始連線的分頁。",
"failed_title": "授權失敗",
"denied": "授權被拒絕。",
"failed_detail": "授權失敗:{error}",
"missing_params": "缺少授權碼或狀態。",
"delivery_failed": "無法將授權結果傳遞至應用程式分頁。請關閉此視窗並再試一次。"
}
}
}
+13 -2
View File
@@ -303,9 +303,7 @@
"move": "移动",
"print": "打印",
"view_source": "查看源码",
"fullscreen": "全屏",
"exit_fullscreen": "退出全屏",
"export_email": "导出为 .eml",
"forward_as_attachment": "Forward as attachment",
@@ -3541,5 +3539,18 @@
"copy_event": "复制日程链接",
"copied": "链接已复制",
"copy_failed": "无法复制链接"
},
"plugins": {
"oauth_callback": {
"completing": "正在完成授权…",
"completing_message": "正在建立连接,请稍候。",
"received_title": "已收到授权",
"received_message": "您可以返回发起连接的标签页。",
"failed_title": "授权失败",
"denied": "授权被拒绝。",
"failed_detail": "授权失败:{error}",
"missing_params": "缺少授权码或状态。",
"delivery_failed": "无法将授权结果传递到应用标签页。请关闭此窗口并重试。"
}
}
}