Feature: translate incoming messages into your preferred language automatically
|
@ -1,11 +0,0 @@
|
|||
## Translate
|
||||
|
||||
A package for N1 that translates draft text into other languages using the Yandex Translation API.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/nylas/nylas-mail/master/internal_packages/composer-translate/examples-screencap-translate.png"/>
|
||||
|
||||
#### Enable this plugin
|
||||
|
||||
1. Download and run N1
|
||||
|
||||
2. Navigate to Preferences > Plugins and click "Enable" beside the plugin.
|
|
@ -1,13 +0,0 @@
|
|||
@import 'ui-variables';
|
||||
@import 'ui-mixins';
|
||||
|
||||
.translate-language-picker {
|
||||
.footer-container {
|
||||
display: none;
|
||||
}
|
||||
.content-container {
|
||||
height: 185px;
|
||||
width: 170px;
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
|
@ -47,6 +47,10 @@
|
|||
padding: @padding-small-horizontal;
|
||||
color: @text-color;
|
||||
|
||||
.event-location {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.event-date {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
.autoload-images-header {
|
||||
background-color: mix(@background-primary, #ffcc11, 80%);
|
||||
border: 1px solid darken(mix(@background-primary, #ffcc11, 50%), 25%);
|
||||
border: 1px solid mix(@black, mix(@background-primary, #ffcc11, 50%), 25%);
|
||||
border-radius: @border-radius-base;
|
||||
color: mix(@text-color-subtle, #ffcc11, 40%);
|
||||
margin: @padding-base-vertical 0;
|
||||
padding: @padding-base-vertical @padding-base-horizontal;
|
||||
|
@ -14,6 +15,6 @@
|
|||
color: fade(mix(@text-color-subtle, #ffcc11, 80%), 70%);
|
||||
}
|
||||
.option:hover {
|
||||
color: mix(@text-color-subtle, #ffcc11, 80%);
|
||||
color: mix(@black, mix(@text-color-subtle, #ffcc11, 80%), 25%);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ class ParticipantProfileDataSource {
|
|||
path: `/api/info-for-email-v2/${email}?phrase=${encodeURIComponent(name)}`,
|
||||
});
|
||||
} catch (err) {
|
||||
// we don't care about errors returned by this clearbit proxy
|
||||
// we don't care about errors
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import WorkspaceSection from './workspace-section';
|
|||
import SendingSection from './sending-section';
|
||||
import LanguageSection from './language-section';
|
||||
import { ConfigLike, ConfigSchemaLike } from '../types';
|
||||
import {remote} from "electron";
|
||||
import { remote } from 'electron';
|
||||
|
||||
class PreferencesGeneral extends React.Component<{
|
||||
config: ConfigLike;
|
||||
|
@ -100,7 +100,7 @@ class PreferencesGeneral extends React.Component<{
|
|||
|
||||
<div className="local-data">
|
||||
<h6>{localized('Local Data')}</h6>
|
||||
<div className="btn" onClick={this._onResetEmailCache}>
|
||||
<div className="btn" onClick={this._onResetEmailCache} style={{ marginLeft: 0 }}>
|
||||
{localized('Reset Cache')}
|
||||
</div>
|
||||
<div className="btn" onClick={this._onResetAccountsAndSettings}>
|
||||
|
|
|
@ -126,6 +126,14 @@ const ProTourFeatures = [
|
|||
`Use the Activity tab to get a birds-eye view of your mailbox: open and click rates, subject line effectiveness, and more.`
|
||||
),
|
||||
},
|
||||
{
|
||||
link: 'https://foundry376.zendesk.com/hc/en-us/articles/360031102452',
|
||||
icon: `pro-feature-translation.png`,
|
||||
title: localized(`Automatic Translation`),
|
||||
text: localized(
|
||||
`Instantly translate messages you receive into your preferred reading language.`
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
class PreferencesIdentity extends React.Component<{}, { identity: IIdentity }> {
|
||||
|
|
11
app/internal_packages/translation/README.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
## Translate
|
||||
|
||||
A package for Mailspring that translates draft text into other languages using the Yandex Translation API.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/nylas/nylas-mail/master/internal_packages/translation/examples-screencap-translate.png"/>
|
||||
|
||||
#### Enable this plugin
|
||||
|
||||
1. Download and run Mailspring
|
||||
|
||||
2. Navigate to Preferences > Plugins and click "Enable" beside the plugin.
|
After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 336 B After Width: | Height: | Size: 336 B |
Before Width: | Height: | Size: 670 B After Width: | Height: | Size: 670 B |
Before Width: | Height: | Size: 889 B After Width: | Height: | Size: 889 B |
|
@ -1,44 +1,23 @@
|
|||
/* eslint global-require: "off" */
|
||||
|
||||
// // Translation Plugin
|
||||
// Last Revised: Feb. 29, 2016 by Ben Gotow
|
||||
|
||||
// TranslateButton is a simple React component that allows you to select
|
||||
// a language from a popup menu and translates draft text into that language.
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {
|
||||
PropTypes,
|
||||
ComponentRegistry,
|
||||
QuotedHTMLTransformer,
|
||||
localized,
|
||||
Actions,
|
||||
Message,
|
||||
DraftEditingSession,
|
||||
FeatureUsageStore,
|
||||
} from 'mailspring-exports';
|
||||
|
||||
import { Menu, RetinaImg } from 'mailspring-component-kit';
|
||||
import { TranslatePopupOptions, translateMessageBody, TranslationsUsedLexicon } from './service';
|
||||
|
||||
const YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate';
|
||||
const YandexTranslationKey =
|
||||
'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e';
|
||||
const YandexLanguages = {
|
||||
English: 'en',
|
||||
Spanish: 'es',
|
||||
Russian: 'ru',
|
||||
Chinese: 'zh',
|
||||
French: 'fr',
|
||||
German: 'de',
|
||||
Italian: 'it',
|
||||
Japanese: 'ja',
|
||||
Portuguese: 'pt',
|
||||
Korean: 'ko',
|
||||
};
|
||||
|
||||
class TranslateButton extends React.Component<{ draft: Message; session: DraftEditingSession }> {
|
||||
export class TranslateComposerButton extends React.Component<{
|
||||
draft: Message;
|
||||
session: DraftEditingSession;
|
||||
}> {
|
||||
// Adding a `displayName` makes debugging React easier
|
||||
static displayName = 'TranslateButton';
|
||||
static displayName = 'TranslateComposerButton';
|
||||
|
||||
// Since our button is being injected into the Composer Footer,
|
||||
// we receive the local id of the current draft as a `prop` (a read-only
|
||||
|
@ -54,47 +33,27 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd
|
|||
return nextProps.session !== this.props.session;
|
||||
}
|
||||
|
||||
_onError(error) {
|
||||
_onTranslate = async langName => {
|
||||
Actions.closePopover();
|
||||
const dialog = require('electron').remote.dialog;
|
||||
dialog.showErrorBox(localized('Language Conversion Failed'), error.toString());
|
||||
}
|
||||
|
||||
_onTranslate = async lang => {
|
||||
Actions.closePopover();
|
||||
try {
|
||||
await FeatureUsageStore.markUsedOrUpgrade('translation', TranslationsUsedLexicon);
|
||||
} catch (err) {
|
||||
// user does not have access to this feature
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtain the session for the current draft. The draft session provides us
|
||||
// the draft object and also manages saving changes to the local cache and
|
||||
// Nilas API as multiple parts of the application touch the draft.
|
||||
const draftHtml = this.props.draft.body;
|
||||
const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml);
|
||||
const langCode = TranslatePopupOptions[langName];
|
||||
const translated = await translateMessageBody(this.props.draft.body, langCode);
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('key', YandexTranslationKey);
|
||||
queryParams.set('lang', YandexLanguages[lang]);
|
||||
queryParams.set('text', text);
|
||||
queryParams.set('format', 'html');
|
||||
|
||||
try {
|
||||
const resp = await fetch(YandexTranslationURL, { method: 'POST', body: queryParams });
|
||||
if (!resp.ok) {
|
||||
throw new Error(localized('Sorry, we were unable to complete the translation request.'));
|
||||
}
|
||||
const json = await resp.json();
|
||||
let translated = json.text.join('');
|
||||
|
||||
// The new text of the draft is our translated response, plus any quoted text
|
||||
// that we didn't process.
|
||||
translated = QuotedHTMLTransformer.appendQuotedHTML(translated, draftHtml);
|
||||
|
||||
// To update the draft, we add the new body to it's session. The session object
|
||||
// automatically marshalls changes to the database and ensures that others accessing
|
||||
// the same draft are notified of changes.
|
||||
this.props.session.changes.add({ body: translated });
|
||||
this.props.session.changes.commit();
|
||||
} catch (error) {
|
||||
this._onError(error);
|
||||
}
|
||||
// To update the draft, we add the new body to it's session. The session object
|
||||
// automatically marshalls changes to the database and ensures that others accessing
|
||||
// the same draft are notified of changes.
|
||||
this.props.session.changes.add({ body: translated });
|
||||
this.props.session.changes.commit();
|
||||
};
|
||||
|
||||
_onClickTranslateButton = () => {
|
||||
|
@ -108,7 +67,7 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd
|
|||
return (
|
||||
<Menu
|
||||
className="translate-language-picker"
|
||||
items={Object.keys(YandexLanguages)}
|
||||
items={Object.keys(TranslatePopupOptions)}
|
||||
itemKey={item => item}
|
||||
itemContent={item => item}
|
||||
headerComponents={headerComponents}
|
||||
|
@ -141,7 +100,7 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd
|
|||
>
|
||||
<RetinaImg
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
url="mailspring://composer-translate/assets/icon-composer-translate@2x.png"
|
||||
url="mailspring://translation/assets/icon-composer-translate@2x.png"
|
||||
/>
|
||||
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
|
@ -149,25 +108,3 @@ class TranslateButton extends React.Component<{ draft: Message; session: DraftEd
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
All packages must export a basic object that has at least the following 3
|
||||
methods:
|
||||
|
||||
1. `activate` - Actions to take once the package gets turned on.
|
||||
Pre-enabled packages get activated on Mailspring bootup. They can also be
|
||||
activated manually by a user.
|
||||
|
||||
2. `deactivate` - Actions to take when a package gets turned off. This can
|
||||
happen when a user manually disables a package.
|
||||
*/
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(TranslateButton, {
|
||||
role: 'Composer:ActionButton',
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(TranslateButton);
|
||||
}
|
39
app/internal_packages/translation/lib/main.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
/* eslint global-require: "off" */
|
||||
|
||||
// // Translation Plugin
|
||||
// Last Revised: Feb. 29, 2016 by Ben Gotow
|
||||
|
||||
// TranslateButton is a simple React component that allows you to select
|
||||
// a language from a popup menu and translates draft text into that language.
|
||||
|
||||
import { ComponentRegistry, ExtensionRegistry } from 'mailspring-exports';
|
||||
import { TranslateComposerButton } from './composer-button';
|
||||
import { TranslateMessageHeader, TranslateMessageExtension } from './message-header';
|
||||
/*
|
||||
All packages must export a basic object that has at least the following 3
|
||||
methods:
|
||||
|
||||
1. `activate` - Actions to take once the package gets turned on.
|
||||
Pre-enabled packages get activated on Mailspring bootup. They can also be
|
||||
activated manually by a user.
|
||||
|
||||
2. `deactivate` - Actions to take when a package gets turned off. This can
|
||||
happen when a user manually disables a package.
|
||||
*/
|
||||
|
||||
export function activate() {
|
||||
ExtensionRegistry.MessageView.register(TranslateMessageExtension);
|
||||
|
||||
ComponentRegistry.register(TranslateComposerButton, {
|
||||
role: 'Composer:ActionButton',
|
||||
});
|
||||
ComponentRegistry.register(TranslateMessageHeader, {
|
||||
role: 'message:BodyHeader',
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ExtensionRegistry.MessageView.unregister(TranslateMessageExtension);
|
||||
ComponentRegistry.unregister(TranslateComposerButton);
|
||||
ComponentRegistry.unregister(TranslateMessageHeader);
|
||||
}
|
348
app/internal_packages/translation/lib/message-header.tsx
Normal file
|
@ -0,0 +1,348 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import cld from '@paulcbetts/cld';
|
||||
import { remote } from 'electron';
|
||||
import {
|
||||
localized,
|
||||
Message,
|
||||
MessageViewExtension,
|
||||
getCurrentLocale,
|
||||
MessageBodyProcessor,
|
||||
IdentityStore,
|
||||
FeatureUsageStore,
|
||||
} from 'mailspring-exports';
|
||||
|
||||
import { translateMessageBody, AllLanguages, TranslationsUsedLexicon } from './service';
|
||||
import { Menu, ButtonDropdown, RetinaImg } from 'mailspring-component-kit';
|
||||
|
||||
interface TranslateMessageHeaderProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
interface TranslateMessageHeaderState {
|
||||
detected: string | null;
|
||||
translating: 'manual' | 'auto' | false;
|
||||
}
|
||||
|
||||
let RecentlyTranslatedBodies: {
|
||||
id: string;
|
||||
translated: string;
|
||||
enabled: boolean;
|
||||
fromLang: string;
|
||||
toLang: string;
|
||||
}[] = [];
|
||||
|
||||
function getPrefs() {
|
||||
return {
|
||||
disabled: AppEnv.config.get('core.translation.disabled') || [],
|
||||
automatic: AppEnv.config.get('core.translation.automatic') || [],
|
||||
};
|
||||
}
|
||||
|
||||
function setPrefs(opts: { disabled: string[]; automatic: string[] }) {
|
||||
AppEnv.config.set('core.translation.disabled', opts.disabled);
|
||||
AppEnv.config.set('core.translation.automatic', opts.automatic);
|
||||
}
|
||||
|
||||
export class TranslateMessageExtension extends MessageViewExtension {
|
||||
static formatMessageBody = ({ message }) => {
|
||||
const result = RecentlyTranslatedBodies.find(o => o.id === message.id);
|
||||
if (result && result.enabled) message.body = result.translated;
|
||||
};
|
||||
}
|
||||
|
||||
export class TranslateMessageHeader extends React.Component<
|
||||
TranslateMessageHeaderProps,
|
||||
TranslateMessageHeaderState
|
||||
> {
|
||||
static displayName = 'TranslateMessageHeader';
|
||||
|
||||
_mounted: boolean = false;
|
||||
_detectionStarted: boolean = false;
|
||||
|
||||
state: TranslateMessageHeaderState = {
|
||||
detected: null,
|
||||
translating: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._mounted = true;
|
||||
this._detectLanguageIfReady();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._detectLanguageIfReady();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._mounted = false;
|
||||
}
|
||||
|
||||
_detectLanguageIfReady = async () => {
|
||||
if (this._detectionStarted) return;
|
||||
|
||||
// load the previous translation result if this message is already translated
|
||||
const result = RecentlyTranslatedBodies.find(o => o.id === this.props.message.id);
|
||||
if (result) {
|
||||
this._detectionStarted = true;
|
||||
this.setState({ detected: result.fromLang });
|
||||
return;
|
||||
}
|
||||
|
||||
// add a delay to avoid this work if the user is rapidly flipping through messages
|
||||
await Promise.delay(1000);
|
||||
|
||||
if (this._detectionStarted || !this._mounted) return;
|
||||
|
||||
// we need to trim the quoted text, convert the HTML to plain text to analyze, etc.
|
||||
// the second step is costly and we can just wait for the message to mount and read
|
||||
// the innerText which is much more efficient.
|
||||
const el = ReactDOM.findDOMNode(this) as Element;
|
||||
const messageEl = el && el.closest('.message-item-area');
|
||||
const iframeEl = messageEl && messageEl.querySelector('iframe');
|
||||
if (!iframeEl || !this.props.message.body) return;
|
||||
|
||||
let text = iframeEl.contentDocument.body.innerText;
|
||||
if (text.length > 1000) text = text.slice(0, 1000);
|
||||
if (!text) return;
|
||||
|
||||
this._detectionStarted = true;
|
||||
|
||||
cld.detect(text, (err, result) => {
|
||||
if (err || !result || !result.languages.length) {
|
||||
console.warn(`Could not detect message language: ${err.toString()}`);
|
||||
return;
|
||||
}
|
||||
const detected = result.languages[0].code;
|
||||
const current = getCurrentLocale().split('-')[0];
|
||||
|
||||
// no-op if the current and detected language are the same
|
||||
if (current === detected) return;
|
||||
|
||||
// no-op if we don't know what either of the language codes are
|
||||
if (!AllLanguages[current] || !AllLanguages[detected]) return;
|
||||
|
||||
const prefs = getPrefs();
|
||||
if (prefs.disabled.includes(detected)) return;
|
||||
this.setState({ detected });
|
||||
if (prefs.automatic.includes(detected)) {
|
||||
this._onTranslate('auto');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_onTranslate = async (mode: 'auto' | 'manual') => {
|
||||
const { message } = this.props;
|
||||
|
||||
const result = RecentlyTranslatedBodies.find(o => o.id === message.id);
|
||||
if (result) {
|
||||
if (!result.enabled) this._onToggleTranslate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IdentityStore.hasProFeatures()) {
|
||||
try {
|
||||
await FeatureUsageStore.markUsedOrUpgrade('translation', TranslationsUsedLexicon);
|
||||
} catch (err) {
|
||||
// user does not have access to this feature
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ translating: mode });
|
||||
const targetLanguage = getCurrentLocale().split('-')[0];
|
||||
const translated = await translateMessageBody(message.body, targetLanguage);
|
||||
if (this._mounted) {
|
||||
this.setState({ translating: false });
|
||||
}
|
||||
if (!translated) return;
|
||||
|
||||
if (RecentlyTranslatedBodies.length > 50) {
|
||||
RecentlyTranslatedBodies.shift();
|
||||
}
|
||||
RecentlyTranslatedBodies.push({
|
||||
id: message.id,
|
||||
translated,
|
||||
enabled: true,
|
||||
fromLang: this.state.detected,
|
||||
toLang: targetLanguage,
|
||||
});
|
||||
|
||||
MessageBodyProcessor.updateCacheForMessage(message);
|
||||
};
|
||||
|
||||
_onToggleTranslate = () => {
|
||||
const result = RecentlyTranslatedBodies.find(o => o.id === this.props.message.id);
|
||||
result.enabled = !result.enabled;
|
||||
MessageBodyProcessor.updateCacheForMessage(this.props.message);
|
||||
};
|
||||
|
||||
_onDisableAlwaysForLanguage = () => {
|
||||
const prefs = getPrefs();
|
||||
prefs.automatic = prefs.automatic.filter(p => p !== this.state.detected);
|
||||
setPrefs(prefs);
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
_onAlwaysForLanguage = () => {
|
||||
if (!IdentityStore.hasProFeatures()) {
|
||||
FeatureUsageStore.displayUpgradeModal('translation', {
|
||||
headerText: localized('Translate automatically with Mailspring Pro'),
|
||||
rechargeText: `${localized(
|
||||
"Unfortunately, translation services bill per character and we can't offer this feature for free."
|
||||
)} ${localized('Upgrade to Pro today!')}`,
|
||||
iconUrl: 'mailspring://translation/assets/ic-translation-modal@2x.png',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = getPrefs();
|
||||
prefs.disabled = prefs.disabled.filter(p => p !== this.state.detected);
|
||||
prefs.automatic = prefs.automatic.concat([this.state.detected]);
|
||||
setPrefs(prefs);
|
||||
|
||||
this.forceUpdate();
|
||||
this._onTranslate('manual');
|
||||
};
|
||||
|
||||
_onNeverForLanguage = () => {
|
||||
if (!this.state.detected) return;
|
||||
|
||||
const response = remote.dialog.showMessageBox(remote.getCurrentWindow(), {
|
||||
type: 'warning',
|
||||
buttons: [localized('Yes'), localized('Cancel')],
|
||||
message: localized('Are you sure?'),
|
||||
detail: localized(
|
||||
'Mailspring will no longer offer to translate messages written in %@.',
|
||||
AllLanguages[this.state.detected]
|
||||
),
|
||||
});
|
||||
if (response === 0) {
|
||||
const prefs = getPrefs();
|
||||
prefs.disabled = prefs.disabled.concat([this.state.detected]);
|
||||
prefs.automatic = prefs.automatic.filter(p => p !== this.state.detected);
|
||||
setPrefs(prefs);
|
||||
this.setState({ detected: null });
|
||||
}
|
||||
};
|
||||
|
||||
_onReset = () => {
|
||||
setPrefs({ automatic: [], disabled: [] });
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
render() {
|
||||
const result = RecentlyTranslatedBodies.find(o => o.id === this.props.message.id);
|
||||
|
||||
if (result && result.enabled) {
|
||||
return (
|
||||
<div className="translate-message-header">
|
||||
<div className="message with-actions">
|
||||
<div className="message-centered">
|
||||
{localized(
|
||||
'Mailspring has translated this message into %@.',
|
||||
AllLanguages[result.toLang]
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="action" tabIndex={-1} onClick={this._onToggleTranslate}>
|
||||
<span>{localized('Show Original')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.state.detected) {
|
||||
return <span />;
|
||||
}
|
||||
|
||||
const fromLanguage = AllLanguages[this.state.detected];
|
||||
const toLanguage = AllLanguages[getCurrentLocale().split('-')[0]];
|
||||
const prefs = getPrefs();
|
||||
|
||||
const spinner = (
|
||||
<RetinaImg
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentDark}
|
||||
style={{ width: 14, height: 14, mixBlendMode: 'multiply' }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.state.translating === 'auto') {
|
||||
return (
|
||||
<div className="translate-message-header">
|
||||
<div className="message">
|
||||
<div className="message-centered">
|
||||
{localized('Translating from %1$@ to %2$@.', fromLanguage, toLanguage)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<RetinaImg
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentDark}
|
||||
style={{ width: 14, height: 14, mixBlendMode: 'multiply' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="translate-message-header">
|
||||
<div className="message with-actions">
|
||||
<div className="message-centered">
|
||||
{localized('Translate from %1$@ to %2$@?', fromLanguage, toLanguage)}
|
||||
<div className="note">
|
||||
{localized('Privacy note: text below will be sent to an online translation service.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="action" tabIndex={-1} onClick={() => this._onTranslate('manual')}>
|
||||
{this.state.translating === 'manual' ? spinner : <span>{localized('Translate')}</span>}
|
||||
</div>
|
||||
<ButtonDropdown
|
||||
bordered={false}
|
||||
attachment="right"
|
||||
closeOnMenuClick={true}
|
||||
primaryItem={<span>{localized('Options')}</span>}
|
||||
className="action"
|
||||
menu={
|
||||
<Menu
|
||||
items={[
|
||||
prefs.automatic.includes(this.state.detected)
|
||||
? {
|
||||
key: 'always',
|
||||
label: localized('Stop translating %@', fromLanguage),
|
||||
select: this._onDisableAlwaysForLanguage,
|
||||
}
|
||||
: {
|
||||
key: 'always',
|
||||
label: localized('Always translate %@', fromLanguage) + ` (Pro)`,
|
||||
select: this._onAlwaysForLanguage,
|
||||
},
|
||||
{
|
||||
key: 'never',
|
||||
label: localized('Never translate %@', fromLanguage),
|
||||
select: this._onNeverForLanguage,
|
||||
},
|
||||
{ key: 'divider' },
|
||||
{
|
||||
key: 'reset',
|
||||
label: localized('Reset translation settings'),
|
||||
select: this._onReset,
|
||||
},
|
||||
]}
|
||||
itemKey={item => item.key}
|
||||
itemContent={item =>
|
||||
item.label ? item.label : <Menu.Item key={item.key} divider={true} />
|
||||
}
|
||||
onSelect={item => item.select()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
151
app/internal_packages/translation/lib/service.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import {
|
||||
QuotedHTMLTransformer,
|
||||
localized,
|
||||
Actions,
|
||||
MailspringAPIRequest,
|
||||
} from 'mailspring-exports';
|
||||
|
||||
export const TranslatePopupOptions = {
|
||||
English: 'en',
|
||||
Spanish: 'es',
|
||||
Russian: 'ru',
|
||||
Chinese: 'zh',
|
||||
Arabic: 'ar',
|
||||
French: 'fr',
|
||||
German: 'de',
|
||||
Italian: 'it',
|
||||
Japanese: 'ja',
|
||||
Portuguese: 'pt',
|
||||
Hindi: 'hi',
|
||||
Korean: 'ko',
|
||||
};
|
||||
|
||||
export const AllLanguages = {
|
||||
az: 'Azerbaijan',
|
||||
ml: 'Malayalam',
|
||||
sq: 'Albanian',
|
||||
mt: 'Maltese',
|
||||
am: 'Amharic',
|
||||
mk: 'Macedonian',
|
||||
en: 'English',
|
||||
mi: 'Maori',
|
||||
ar: 'Arabic',
|
||||
mr: 'Marathi',
|
||||
hy: 'Armenian',
|
||||
mhr: 'Mari',
|
||||
af: 'Afrikaans',
|
||||
mn: 'Mongolian',
|
||||
eu: 'Basque',
|
||||
de: 'German',
|
||||
ba: 'Bashkir',
|
||||
ne: 'Nepali',
|
||||
be: 'Belarusian',
|
||||
no: 'Norwegian',
|
||||
bn: 'Bengali',
|
||||
pa: 'Punjabi',
|
||||
my: 'Burmese',
|
||||
pap: 'Papiamento',
|
||||
bg: 'Bulgarian',
|
||||
fa: 'Persian',
|
||||
bs: 'Bosnian',
|
||||
pl: 'Polish',
|
||||
cy: 'Welsh',
|
||||
pt: 'Portuguese',
|
||||
hu: 'Hungarian',
|
||||
ro: 'Romanian',
|
||||
vi: 'Vietnamese',
|
||||
ru: 'Russian',
|
||||
ht: 'Haitian (Creole)',
|
||||
ceb: 'Cebuano',
|
||||
gl: 'Galician',
|
||||
sr: 'Serbian',
|
||||
nl: 'Dutch',
|
||||
si: 'Sinhala',
|
||||
mrj: 'Hill Mari',
|
||||
sk: 'Slovakian',
|
||||
el: 'Greek',
|
||||
sl: 'Slovenian',
|
||||
ka: 'Georgian',
|
||||
sw: 'Swahili',
|
||||
gu: 'Gujarati',
|
||||
su: 'Sundanese',
|
||||
da: 'Danish',
|
||||
tg: 'Tajik',
|
||||
he: 'Hebrew',
|
||||
th: 'Thai',
|
||||
yi: 'Yiddish',
|
||||
tl: 'Tagalog',
|
||||
id: 'Indonesian',
|
||||
ta: 'Tamil',
|
||||
ga: 'Irish',
|
||||
tt: 'Tatar',
|
||||
it: 'Italian',
|
||||
te: 'Telugu',
|
||||
is: 'Icelandic',
|
||||
tr: 'Turkish',
|
||||
es: 'Spanish',
|
||||
udm: 'Udmurt',
|
||||
kk: 'Kazakh',
|
||||
uz: 'Uzbek',
|
||||
kn: 'Kannada',
|
||||
uk: 'Ukrainian',
|
||||
ca: 'Catalan',
|
||||
ur: 'Urdu',
|
||||
ky: 'Kyrgyz',
|
||||
fi: 'Finnish',
|
||||
zh: 'Chinese',
|
||||
fr: 'French',
|
||||
ko: 'Korean',
|
||||
hi: 'Hindi',
|
||||
xh: 'Xhosa',
|
||||
hr: 'Croatian',
|
||||
km: 'Khmer',
|
||||
cs: 'Czech',
|
||||
lo: 'Laotian',
|
||||
sv: 'Swedish',
|
||||
la: 'Latin',
|
||||
gd: 'Scottish',
|
||||
lv: 'Latvian',
|
||||
et: 'Estonian',
|
||||
lt: 'Lithuanian',
|
||||
eo: 'Esperanto',
|
||||
lb: 'Luxembourgish',
|
||||
jv: 'Javanese',
|
||||
mg: 'Malagasy',
|
||||
ja: 'Japanese',
|
||||
ms: 'Malay',
|
||||
};
|
||||
|
||||
export const TranslationsUsedLexicon = {
|
||||
headerText: localized('All Translations Used'),
|
||||
rechargeText: `${localized(
|
||||
'You can translate up to %1$@ emails each %2$@ with Mailspring Basic.'
|
||||
)} ${localized('Upgrade to Pro today!')}`,
|
||||
iconUrl: 'mailspring://translation/assets/ic-translation-modal@2x.png',
|
||||
};
|
||||
|
||||
export async function translateMessageBody(
|
||||
html: string,
|
||||
targetLang?: string
|
||||
): Promise<string | false> {
|
||||
const text = QuotedHTMLTransformer.removeQuotedHTML(html);
|
||||
|
||||
let response = null;
|
||||
try {
|
||||
response = await MailspringAPIRequest.makeRequest({
|
||||
server: 'identity',
|
||||
method: 'POST',
|
||||
path: `/api/translate`,
|
||||
json: true,
|
||||
body: { lang: targetLang, text, format: 'html' },
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
Actions.closePopover();
|
||||
const dialog = require('electron').remote.dialog;
|
||||
dialog.showErrorBox(localized('Language Conversion Failed'), error.toString());
|
||||
return false;
|
||||
}
|
||||
|
||||
return QuotedHTMLTransformer.appendQuotedHTML(response.result, html);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "composer-translate",
|
||||
"name": "translation",
|
||||
"version": "0.2.0",
|
||||
"main": "./lib/main",
|
||||
|
94
app/internal_packages/translation/styles/translate.less
Normal file
|
@ -0,0 +1,94 @@
|
|||
@import 'ui-variables';
|
||||
@import 'ui-mixins';
|
||||
|
||||
@feature-tint: #0095ff;
|
||||
|
||||
.translate-language-picker {
|
||||
.footer-container {
|
||||
display: none;
|
||||
}
|
||||
.content-container {
|
||||
height: 185px;
|
||||
width: 170px;
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.translate-message-header {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background-color: mix(@background-primary, @feature-tint, 90%);
|
||||
color: mix(@text-color-subtle, @feature-tint, 40%);
|
||||
margin: 10px 0;
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
border: 1px solid darken(mix(@background-primary, @feature-tint, 50%), 25%);
|
||||
border-radius: @border-radius-base;
|
||||
|
||||
&.with-actions {
|
||||
border-right: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
padding: 2px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.message-centered {
|
||||
.note {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.action {
|
||||
padding: 0 8px;
|
||||
min-height: 27px;
|
||||
background: mix(@background-primary, @feature-tint, 80%);
|
||||
border: 1px solid darken(mix(@background-primary, @feature-tint, 50%), 25%);
|
||||
border-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 90px;
|
||||
flex: 1;
|
||||
&:active {
|
||||
background: mix(@background-primary, @feature-tint, 70%);
|
||||
}
|
||||
}
|
||||
|
||||
.action.button-dropdown {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
.only-item {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
|
||||
img.content-mask {
|
||||
background: mix(@text-color-subtle, @feature-tint, 40%);
|
||||
}
|
||||
}
|
||||
}
|
||||
.action:first-child {
|
||||
border-top-right-radius: @border-radius-base;
|
||||
}
|
||||
.action:last-child {
|
||||
border-bottom-right-radius: @border-radius-base;
|
||||
border-bottom: 1px solid darken(mix(@background-primary, @feature-tint, 50%), 25%);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@
|
|||
"license": "GPL-3.0",
|
||||
"main": "./src/browser/main.js",
|
||||
"dependencies": {
|
||||
"@paulcbetts/cld": "2.4.6",
|
||||
"@bengotow/slate-edit-list": "github:bengotow/slate-edit-list#b868e108",
|
||||
"better-sqlite3": "bengotow/better-sqlite3#dcd5b6e73c9a5329fd72c85be3316131fcfb83ab",
|
||||
"chromium-net-errors": "1.0.3",
|
||||
|
|
|
@ -9,7 +9,7 @@ import { localized } from '../../intl';
|
|||
|
||||
class NoProAccessError extends Error {}
|
||||
|
||||
const UsageRecordedServerSide = ['contact-profiles'];
|
||||
const UsageRecordedServerSide = ['contact-profiles', 'translation'];
|
||||
|
||||
/**
|
||||
* FeatureUsageStore is backed by the IdentityStore
|
||||
|
|
2
app/src/global/mailspring-exports.d.ts
vendored
|
@ -1,6 +1,8 @@
|
|||
|
||||
export type localized = typeof import('../intl').localized;
|
||||
export const localized: localized;
|
||||
export type getCurrentLocale = typeof import('../intl').getCurrentLocale;
|
||||
export const getCurrentLocale: getCurrentLocale;
|
||||
export type localizedReactFragment = typeof import('../intl').localizedReactFragment;
|
||||
export const localizedReactFragment: localizedReactFragment;
|
||||
export type getAvailableLanguages = typeof import('../intl').getAvailableLanguages;
|
||||
|
|
|
@ -50,6 +50,7 @@ const lazyLoadAndRegisterTask = (klassName, path) => {
|
|||
lazyLoadWithGetter(`localized`, () => require('../intl').localized);
|
||||
lazyLoadWithGetter(`localizedReactFragment`, () => require('../intl').localizedReactFragment);
|
||||
lazyLoadWithGetter(`getAvailableLanguages`, () => require('../intl').getAvailableLanguages);
|
||||
lazyLoadWithGetter(`getCurrentLocale`, () => require('../intl').getCurrentLocale);
|
||||
lazyLoadWithGetter(`isRTL`, () => require('../intl').isRTL);
|
||||
|
||||
// Actions
|
||||
|
|
|
@ -245,6 +245,10 @@ export function localizedReactFragment(en, ...subs) {
|
|||
|
||||
// For Preferences UI:
|
||||
|
||||
export function getCurrentLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
export function getAvailableLanguages() {
|
||||
const localeToItem = f => ({ key: f, name: LANG_NAMES[f] || f });
|
||||
|
||||
|
|
|
@ -12,12 +12,14 @@
|
|||
padding-bottom: 30px;
|
||||
color: @white;
|
||||
border-radius: 5px 5px 0 0;
|
||||
background: linear-gradient(to top, #777ff0, lighten(#777ff0, 10%));
|
||||
background: linear-gradient(to top, #777ff0, lighten(#777ff0, 10%));
|
||||
}
|
||||
.header-text {
|
||||
color: @white;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 11px;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.recharge-text {
|
||||
margin: 0 30px;
|
||||
|
|
After Width: | Height: | Size: 2 KiB |