From cced83024bfb578ee802ab13fc8af72a1be9a1e1 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 15 Aug 2021 20:26:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=8E=E3=83=BC=E3=83=88=E3=81=AE?= =?UTF-8?q?=E7=BF=BB=E8=A8=B3=E6=A9=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve #5213 --- CHANGELOG.md | 2 + locales/ja-JP.yml | 2 + migration/1629024377804-deepl-integration.ts | 14 ++++ src/client/components/global/loading.vue | 32 ++++---- src/client/components/note-detailed.vue | 32 ++++++++ src/client/components/note.vue | 32 ++++++++ src/client/pages/instance/other-settings.vue | 10 ++- src/models/entities/meta.ts | 6 ++ src/server/api/endpoints/admin/update-meta.ts | 12 +++ src/server/api/endpoints/meta.ts | 6 ++ src/server/api/endpoints/notes/translate.ts | 79 +++++++++++++++++++ 11 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 migration/1629024377804-deepl-integration.ts create mode 100644 src/server/api/endpoints/notes/translate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55446cf37d..c936c5e6e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ## 12.x.x (unreleased) ### Improvements +- ノートの翻訳機能を追加 + - 有効にするには、サーバー管理者がDeepLの無料アカウントを登録し、取得した認証キーを「インスタンス設定 > その他 > DeepL Auth Key」に設定する必要があります。 - Misskey更新時にダイアログを表示するように - ジョブキューウィジェットに警報音を鳴らす設定を追加 ‐ UIデザインの調整 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2d18d4325a..7499523b08 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -775,6 +775,8 @@ useBlurEffect: "UIにぼかし効果を使用" learnMore: "詳しく" misskeyUpdated: "Misskeyが更新されました!" whatIsNew: "更新情報を見る" +translate: "翻訳" +translatedFrom: "{x}から翻訳" _docs: continueReading: "続きを読む" diff --git a/migration/1629024377804-deepl-integration.ts b/migration/1629024377804-deepl-integration.ts new file mode 100644 index 0000000000..639f947c7d --- /dev/null +++ b/migration/1629024377804-deepl-integration.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class deeplIntegration1629024377804 implements MigrationInterface { + name = 'deeplIntegration1629024377804' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" ADD "deeplAuthKey" character varying(128)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deeplAuthKey"`); + } + +} diff --git a/src/client/components/global/loading.vue b/src/client/components/global/loading.vue index 9b810f0a16..7bde53c12e 100644 --- a/src/client/components/global/loading.vue +++ b/src/client/components/global/loading.vue @@ -1,5 +1,5 @@ @@ -18,7 +18,12 @@ export default defineComponent({ type: Boolean, required: false, default: true - } + }, + mini: { + type: Boolean, + required: false, + default: false + }, } }); @@ -38,6 +43,8 @@ export default defineComponent({ text-align: center; cursor: wait; + --size: 48px; + &.colored { color: var(--accent); } @@ -45,19 +52,12 @@ export default defineComponent({ &.inline { display: inline; padding: 0; + --size: 32px; + } - > .ring:after { - width: 32px; - height: 32px; - } - - > .ring { - &:before, - &:after { - width: 32px; - height: 32px; - } - } + &.mini { + padding: 16px; + --size: 32px; } > .ring { @@ -70,8 +70,8 @@ export default defineComponent({ content: " "; display: block; box-sizing: border-box; - width: 48px; - height: 48px; + width: var(--size); + height: var(--size); border-radius: 50%; border: solid 4px; } diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue index d601052927..a2460950cd 100644 --- a/src/client/components/note-detailed.vue +++ b/src/client/components/note-detailed.vue @@ -67,6 +67,13 @@ RN: +
+ +
+ {{ $t('translatedFrom', { x: translation.sourceLang }) }}: + {{ translation.text }} +
+
@@ -178,6 +185,8 @@ export default defineComponent({ showContent: false, isDeleted: false, muted: false, + translation: null, + translating: false, }; }, @@ -619,6 +628,11 @@ export default defineComponent({ text: this.$ts.share, action: this.share }, + this.$instance.translatorAvailable ? { + icon: 'fas fa-language', + text: this.$ts.translate, + action: this.translate + } : undefined, null, statePromise.then(state => state.isFavorited ? { icon: 'fas fa-star', @@ -852,6 +866,17 @@ export default defineComponent({ }); }, + async translate() { + if (this.translation != null) return; + this.translating = true; + const res = await os.api('notes/translate', { + noteId: this.appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + this.translating = false; + this.translation = res; + }, + focus() { this.$el.focus(); }, @@ -1050,6 +1075,13 @@ export default defineComponent({ font-style: oblique; color: var(--renote); } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } } > .url-preview { diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 873b96030a..38b529dd91 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -51,6 +51,13 @@ RN: +
+ +
+ {{ $t('translatedFrom', { x: translation.sourceLang }) }}: + {{ translation.text }} +
+
@@ -164,6 +171,8 @@ export default defineComponent({ collapsed: false, isDeleted: false, muted: false, + translation: null, + translating: false, }; }, @@ -594,6 +603,11 @@ export default defineComponent({ text: this.$ts.share, action: this.share }, + this.$instance.translatorAvailable ? { + icon: 'fas fa-language', + text: this.$ts.translate, + action: this.translate + } : undefined, null, statePromise.then(state => state.isFavorited ? { icon: 'fas fa-star', @@ -827,6 +841,17 @@ export default defineComponent({ }); }, + async translate() { + if (this.translation != null) return; + this.translating = true; + const res = await os.api('notes/translate', { + noteId: this.appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + this.translating = false; + this.translation = res; + }, + focus() { this.$el.focus(); }, @@ -1053,6 +1078,13 @@ export default defineComponent({ font-style: oblique; color: var(--renote); } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } } > .url-preview { diff --git a/src/client/pages/instance/other-settings.vue b/src/client/pages/instance/other-settings.vue index b3954149a8..8002528931 100644 --- a/src/client/pages/instance/other-settings.vue +++ b/src/client/pages/instance/other-settings.vue @@ -7,7 +7,12 @@ Summaly Proxy URL - + + + + DeepL Auth Key + + {{ $ts.save }} @@ -44,6 +49,7 @@ export default defineComponent({ icon: 'fas fa-cogs' }, summalyProxy: '', + deeplAuthKey: '', } }, @@ -55,10 +61,12 @@ export default defineComponent({ async init() { const meta = await os.api('meta', { detail: true }); this.summalyProxy = meta.summalyProxy; + this.deeplAuthKey = meta.deeplAuthKey; }, save() { os.apiWithDialog('admin/update-meta', { summalyProxy: this.summalyProxy, + deeplAuthKey: this.deeplAuthKey, }).then(() => { fetchInstance(); }); diff --git a/src/models/entities/meta.ts b/src/models/entities/meta.ts index d0b6ee7f2b..2a0632c87c 100644 --- a/src/models/entities/meta.ts +++ b/src/models/entities/meta.ts @@ -313,6 +313,12 @@ export class Meta { }) public discordClientSecret: string | null; + @Column('varchar', { + length: 128, + nullable: true + }) + public deeplAuthKey: string | null; + @Column('varchar', { length: 512, nullable: true diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index a18956b3f7..573f22822c 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -145,6 +145,10 @@ export const meta = { validator: $.optional.nullable.str, }, + deeplAuthKey: { + validator: $.optional.nullable.str, + }, + enableTwitterIntegration: { validator: $.optional.bool, }, @@ -562,6 +566,14 @@ export default define(meta, async (ps, me) => { set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; } + if (ps.deeplAuthKey !== undefined) { + if (ps.deeplAuthKey === '') { + set.deeplAuthKey = null; + } else { + set.deeplAuthKey = ps.deeplAuthKey; + } + } + await getConnection().transaction(async transactionalEntityManager => { const meta = await transactionalEntityManager.findOne(Meta, { order: { diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index dd75149ad2..561d473d6f 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -232,6 +232,10 @@ export const meta = { type: 'boolean' as const, optional: false as const, nullable: false as const }, + translatorAvailable: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, proxyAccountName: { type: 'string' as const, optional: false as const, nullable: true as const @@ -512,6 +516,8 @@ export default define(meta, async (ps, me) => { enableServiceWorker: instance.enableServiceWorker, + translatorAvailable: instance.deeplAuthKey != null, + ...(ps.detail ? { pinnedPages: instance.pinnedPages, pinnedClipId: instance.pinnedClipId, diff --git a/src/server/api/endpoints/notes/translate.ts b/src/server/api/endpoints/notes/translate.ts new file mode 100644 index 0000000000..bbc11274ab --- /dev/null +++ b/src/server/api/endpoints/notes/translate.ts @@ -0,0 +1,79 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import fetch from 'node-fetch'; +import config from '@/config'; +import { getAgentByUrl } from '@/misc/fetch'; +import { URLSearchParams } from 'url'; +import { fetchMeta } from '@/misc/fetch-meta'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + targetLang: { + validator: $.str, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'bea9b03f-36e0-49c5-a4db-627a029f8971' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + if (note.text == null) { + return 204; + } + + const instance = await fetchMeta(); + + if (instance.deeplAuthKey == null) { + return 204; // TODO: 良い感じのエラー返す + } + + const params = new URLSearchParams(); + params.append('auth_key', instance.deeplAuthKey); + params.append('text', note.text); + params.append('target_lang', ps.targetLang); + + const res = await fetch('https://api-free.deepl.com/v2/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': config.userAgent, + Accept: 'application/json, */*' + }, + body: params, + timeout: 10000, + agent: getAgentByUrl, + }); + + const json = await res.json(); + + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text + }; +});