aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js75
-rw-r--r--src/i18n.js9
-rw-r--r--src/lang-guess.js5
3 files changed, 76 insertions, 13 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index be5f785..ff02304 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4149,6 +4149,7 @@ export function messagesFeature(ctx) {
const TRANSLATE_API = 'https://names.coinos.io/translate';
const TRANSLATE_KEEP = 300;
const AUTO_TRANSLATE_KEY = 'coinos-auto-translate';
+ let deeplWarned = false;
const autoTranslateOn = () => { try { return localStorage.getItem(AUTO_TRANSLATE_KEY) !== '0'; } catch { return true; } };
const translations = new Map(); // key -> { text, source } (done) — remembered
const translating = new Map(); // key -> 'loading' | 'fail'
@@ -4168,16 +4169,35 @@ export function messagesFeature(ctx) {
// in those get no offer rather than one that always fails
const TRANSLATE_NO = new Set(['sw', 'mr', 'ta', 'te']);
const canTranslateTo = (lang) => !!lang && !TRANSLATE_NO.has(lang);
- const transKey = (ev) => ev.id + '|' + getLang() + '|' + (editOf(ev)?.at || 0);
+ // The language posts are translated INTO: the reader's choice (Settings),
+ // else the phone's own language when the app has no such UI language
+ // (an Italian phone reads coinos in English, but wants posts in Italian),
+ // else the app's.
+ const TRANSLATE_TO_KEY = 'coinos-translate-to';
+ const TRANSLATE_TARGETS = ['ar', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh'];
+ const APP_LANGS = new Set(['en', 'zh', 'hi', 'es', 'fr', 'ar', 'bn', 'pt', 'ru', 'ur', 'id', 'de', 'ja', 'sw', 'mr', 'te', 'tr', 'ta', 'vi', 'ko']);
+ function transLang() {
+ try { const v = localStorage.getItem(TRANSLATE_TO_KEY); if (v) return v; } catch {}
+ const phone = (typeof navigator !== 'undefined' && (navigator.languages || [navigator.language])[0] || '').toLowerCase().split(/[-_]/)[0];
+ if (phone && !APP_LANGS.has(phone) && TRANSLATE_TARGETS.includes(phone)) return phone;
+ return getLang();
+ }
+ // A DeepL API key of the reader's own (Jumble offers the same): kept on
+ // this device, sent with each request — DeepL won't answer a browser, so
+ // the registrar passes it on, never storing or logging it.
+ const DEEPL_KEY = 'coinos-deepl-key';
+ const deeplKey = () => { try { return (localStorage.getItem(DEEPL_KEY) || '').trim(); } catch { return ''; } };
+ const transKey = (ev) => ev.id + '|' + transLang() + (deeplKey() ? '|d' : '') + '|' + (editOf(ev)?.at || 0);
const translatable = (ev) => !!ev && !ev.pending && (ev.kind === 1 || ev.kind === COMMENT);
// foreign as far as the cheap guess can tell (a script, or a clear other
// language); unknown is never translated unasked
// A post that is only hashtags is judged by them: its tags in a script
// the reader can't read are what wants translating (as glosses).
const looksForeign = (ev) => {
- if (!translatable(ev) || !canTranslateTo(getLang())) return false;
- const text = noteText(ev), { prose, tags } = transParts(text, getLang());
- return prose.length ? !inLanguage(text, getLang()) : tags.length > 0;
+ const to = transLang();
+ if (!translatable(ev) || !(deeplKey() || canTranslateTo(to))) return false;
+ const text = noteText(ev), { prose, tags } = transParts(text, to);
+ return prose.length ? !inLanguage(text, to) : tags.length > 0;
};
const langName = (code) => { try { return new Intl.DisplayNames([getLang()], { type: 'language' }).of(code) || code; } catch { return code; } };
// the translation to SHOW for this post now, or null
@@ -4214,18 +4234,24 @@ export function messagesFeature(ctx) {
translating.set(k, 'loading');
scheduleRepaint();
const text = noteText(ev);
- const to = getLang();
+ const to = transLang();
+ const dk = deeplKey();
try {
let out = null;
const guess = guessLang(text);
- try { const t2 = await browserTranslate(text, guess, to); if (t2) out = { text: t2, source: guess }; } catch {}
+ // with a DeepL key, DeepL is what the reader chose: not the browser's
+ if (!dk) { try { const t2 = await browserTranslate(text, guess, to); if (t2) out = { text: t2, source: guess }; } catch {} }
if (!out) {
const r = await fetch(TRANSLATE_API, {
- method: 'POST', headers: { 'content-type': 'application/json' },
+ method: 'POST', headers: { 'content-type': 'application/json', ...(dk ? { 'x-deepl-key': dk } : {}) },
body: JSON.stringify({ text, target: to }), signal: AbortSignal.timeout(20_000),
});
const j = await r.json().catch(() => ({}));
- if (!r.ok) throw new Error(j.error || 'translate ' + r.status);
+ if (!r.ok) {
+ // a key DeepL refused, or out of quota: say so once, not "couldn't translate" on every post
+ if (dk && (r.status === 401 || r.status === 402) && !deeplWarned) { deeplWarned = true; toast(t(r.status === 401 ? 'deeplBadKey' : 'deeplQuota')); }
+ throw new Error(j.error || 'translate ' + r.status);
+ }
out = { text: String(j.text || ''), source: j.source || null, nothing: !!j.nothing };
}
// already in the reader's language after all: nothing to show, and
@@ -9233,14 +9259,37 @@ export function messagesFeature(ctx) {
}
function translateCard() {
- if (!canTranslateTo(getLang())) return null;
+ const to = transLang();
+ const e = ui.deeplEdit || (ui.deeplEdit = { key: '' });
+ const names = TRANSLATE_TARGETS.map((c) => [c, langName(c)]).sort((a, b) => a[1].localeCompare(b[1]));
+ const dk = deeplKey();
return h('div', { class: 'card col translate-card', style: 'gap:8px' },
h('h3', {}, t('translateTitle')),
+ h('label', { class: 'row gap6', style: 'align-items:center' },
+ h('span', { class: 'grow' }, t('translateTo')),
+ h('select', { class: 'translate-to', style: 'width:auto',
+ onChange: (ev) => { try { localStorage.setItem(TRANSLATE_TO_KEY, ev.target.value); } catch {} render(); } },
+ ...names.map(([c, n]) => h('option', { value: c, selected: c === to }, n)))),
h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
h('input', { type: 'checkbox', class: 'auto-translate', checked: autoTranslateOn(), style: 'width:18px;height:18px;accent-color:var(--accent);margin:0',
- onChange: (e) => { try { localStorage.setItem(AUTO_TRANSLATE_KEY, e.target.checked ? '1' : '0'); } catch {} render(); } }),
- h('span', {}, t('translateAutoToggle', { lang: langName(getLang()) }))),
- h('div', { class: 'small faint' }, t('translateHelp')));
+ onChange: (ev) => { try { localStorage.setItem(AUTO_TRANSLATE_KEY, ev.target.checked ? '1' : '0'); } catch {} render(); } }),
+ h('span', {}, t('translateAutoToggle', { lang: langName(to) }))),
+ h('div', { class: 'small faint' }, t(dk ? 'translateHelpDeepl' : 'translateHelp')),
+ // DeepL, with the reader's own key
+ dk
+ ? h('div', { class: 'row gap6', style: 'align-items:center' },
+ h('span', { class: 'small grow' }, t('deeplOn', { key: '…' + dk.slice(-6) })),
+ h('button', { class: 'btn-sm deepl-remove', onClick: () => { try { localStorage.removeItem(DEEPL_KEY); } catch {} deeplWarned = false; render(); } }, t('deeplRemove')))
+ : h('div', { class: 'row gap6' },
+ h('input', { type: 'password', class: 'grow deepl-key', placeholder: t('deeplKeyPh'), value: e.key, autocomplete: 'off', spellcheck: 'false',
+ onInput: (ev) => { e.key = ev.target.value.trim(); } }),
+ h('button', { class: 'btn-sm deepl-save', onClick: () => {
+ if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(e.key)) { toast(t('deeplBadKey')); return; }
+ try { localStorage.setItem(DEEPL_KEY, e.key); } catch {}
+ e.key = ''; deeplWarned = false;
+ toast(t('deeplSaved'));
+ render();
+ } }, t('deeplSave'))));
}
function moderationCard() {
@@ -9369,7 +9418,7 @@ export function messagesFeature(ctx) {
typeof navigator !== 'undefined' && navigator.share
? item('\u2197', t('postShare'), () => { navigator.share({ url: noteLink(ev) }).catch(() => {}); })
: null,
- translatable(ev) && canTranslateTo(getLang())
+ translatable(ev) && (deeplKey() || canTranslateTo(transLang()))
? shownTranslation(ev)
? item('\u{1F310}', t('translateShowOriginal'), () => { const k = transKey(ev); transOriginal.add(k); transAsked.delete(k); render(); })
: item('\u{1F310}', t('translateBtn'), () => translateNote(ev, { asked: true }))
diff --git a/src/i18n.js b/src/i18n.js
index 2d2c976..17f1107 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -445,6 +445,15 @@ const STR = {
translateNothing: 'There are no words in this post to translate',
translateFailed: 'Couldn’t translate this post',
translateTitle: 'Translation',
+ translateTo: 'Translate posts into',
+ translateHelpDeepl: 'Posts that look like they’re in another language are translated by DeepL with your own key. The key stays on this device; coinos’s server passes each request on to DeepL (DeepL doesn’t accept requests from browsers) and never keeps it.',
+ deeplKeyPh: 'DeepL API key (optional)',
+ deeplSave: 'Use DeepL',
+ deeplSaved: 'Translations now come from DeepL',
+ deeplOn: 'Using your DeepL key {key}',
+ deeplRemove: 'Remove',
+ deeplBadKey: 'That DeepL key wasn’t accepted',
+ deeplQuota: 'Your DeepL key is out of quota for this month',
translateAutoToggle: 'Translate posts into {lang} automatically',
translateHelp: 'Posts that look like they’re in another language are shown in yours, with a way back to the original. Translations come from your browser when it can, otherwise from coinos’s own LibreTranslate server — posts are public, but the text passes through it.',
postEditTitle: 'Edit post',
diff --git a/src/lang-guess.js b/src/lang-guess.js
index 1d60c59..6d62e30 100644
--- a/src/lang-guess.js
+++ b/src/lang-guess.js
@@ -19,6 +19,8 @@ export const LANG_SCRIPT = {
en: 'latin', es: 'latin', fr: 'latin', de: 'latin', pt: 'latin', id: 'latin', sw: 'latin', tr: 'latin', vi: 'latin',
ru: 'cyrillic', ar: 'arabic', ur: 'arabic', hi: 'devanagari', mr: 'devanagari', bn: 'bengali', ta: 'tamil', te: 'telugu',
ja: 'kana', ko: 'hangul', zh: 'han',
+ // not app languages, but languages people read posts in (a translation target)
+ it: 'latin', nl: 'latin', pl: 'latin',
};
const STOP = {
en: 'the and is to of in that it for you with this on are be have not was but they'.split(' '),
@@ -30,6 +32,9 @@ const STOP = {
tr: 've bir bu için ile de da çok ne gibi daha ama var ben sen mi değil kadar her olan'.split(' '),
vi: 'và của là có không được một trong cho những này với các để người đã tôi khi cũng như'.split(' '),
sw: 'na ya wa kwa ni za la katika hii kuwa kama hata lakini sana yake wote sasa tu huo hapa'.split(' '),
+ it: 'il di che è e la un una per non sono con del della gli anche ma questo mi più nel'.split(' '),
+ nl: 'de het een en van is dat niet op te zijn met voor ik je die maar ook wat'.split(' '),
+ pl: 'i w nie na się z to że jest do co jak ale tak o po czy już'.split(' '),
};
const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new Set(v)]));