From cced83024bfb578ee802ab13fc8af72a1be9a1e1 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
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<void> {
+        await queryRunner.query(`ALTER TABLE "meta" ADD "deeplAuthKey" character varying(128)`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        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 @@
 <template>
-<div class="yxspomdl" :class="{ inline, colored }">
+<div class="yxspomdl" :class="{ inline, colored, mini }">
 	<div class="ring"></div>
 </div>
 </template>
@@ -18,7 +18,12 @@ export default defineComponent({
 			type: Boolean,
 			required: false,
 			default: true
-		}
+		},
+		mini: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
 	}
 });
 </script>
@@ -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 @@
 						<MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
 						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
 						<a class="rp" v-if="appearNote.renote != null">RN:</a>
+						<div class="translation" v-if="translating || translation">
+							<MkLoading v-if="translating" mini/>
+							<div class="translated" v-else>
+								<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
+								{{ translation.text }}
+							</div>
+						</div>
 					</div>
 					<div class="files" v-if="appearNote.files.length > 0">
 						<XMediaList :media-list="appearNote.files"/>
@@ -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 @@
 						<MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
 						<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
 						<a class="rp" v-if="appearNote.renote != null">RN:</a>
+						<div class="translation" v-if="translating || translation">
+							<MkLoading v-if="translating" mini/>
+							<div class="translated" v-else>
+								<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
+								{{ translation.text }}
+							</div>
+						</div>
 					</div>
 					<div class="files" v-if="appearNote.files.length > 0">
 						<XMediaList :media-list="appearNote.files"/>
@@ -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
 			</FormInput>
 		</FormGroup>
-
+		<FormGroup>
+			<FormInput v-model:value="deeplAuthKey">
+				<template #prefix><i class="fas fa-key"></i></template>
+				DeepL Auth Key
+			</FormInput>
+		</FormGroup>
 		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
 	</FormSuspense>
 </FormBase>
@@ -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
+	};
+});