parent
							
								
									1cd6ba3c1d
								
							
						
					
					
						commit
						cced83024b
					
				
					 11 changed files with 210 additions and 17 deletions
				
			
		|  | @ -10,6 +10,8 @@ | ||||||
| ## 12.x.x (unreleased) | ## 12.x.x (unreleased) | ||||||
| 
 | 
 | ||||||
| ### Improvements | ### Improvements | ||||||
|  | - ノートの翻訳機能を追加 | ||||||
|  |   - 有効にするには、サーバー管理者がDeepLの無料アカウントを登録し、取得した認証キーを「インスタンス設定 > その他 > DeepL Auth Key」に設定する必要があります。 | ||||||
| - Misskey更新時にダイアログを表示するように | - Misskey更新時にダイアログを表示するように | ||||||
| - ジョブキューウィジェットに警報音を鳴らす設定を追加 | - ジョブキューウィジェットに警報音を鳴らす設定を追加 | ||||||
| ‐ UIデザインの調整 | ‐ UIデザインの調整 | ||||||
|  |  | ||||||
|  | @ -775,6 +775,8 @@ useBlurEffect: "UIにぼかし効果を使用" | ||||||
| learnMore: "詳しく" | learnMore: "詳しく" | ||||||
| misskeyUpdated: "Misskeyが更新されました!" | misskeyUpdated: "Misskeyが更新されました!" | ||||||
| whatIsNew: "更新情報を見る" | whatIsNew: "更新情報を見る" | ||||||
|  | translate: "翻訳" | ||||||
|  | translatedFrom: "{x}から翻訳" | ||||||
| 
 | 
 | ||||||
| _docs:  | _docs:  | ||||||
|   continueReading: "続きを読む" |   continueReading: "続きを読む" | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								migration/1629024377804-deepl-integration.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								migration/1629024377804-deepl-integration.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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"`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| <template> | <template> | ||||||
| <div class="yxspomdl" :class="{ inline, colored }"> | <div class="yxspomdl" :class="{ inline, colored, mini }"> | ||||||
| 	<div class="ring"></div> | 	<div class="ring"></div> | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
|  | @ -18,7 +18,12 @@ export default defineComponent({ | ||||||
| 			type: Boolean, | 			type: Boolean, | ||||||
| 			required: false, | 			required: false, | ||||||
| 			default: true | 			default: true | ||||||
| 		} | 		}, | ||||||
|  | 		mini: { | ||||||
|  | 			type: Boolean, | ||||||
|  | 			required: false, | ||||||
|  | 			default: false | ||||||
|  | 		}, | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  | @ -38,6 +43,8 @@ export default defineComponent({ | ||||||
| 	text-align: center; | 	text-align: center; | ||||||
| 	cursor: wait; | 	cursor: wait; | ||||||
| 
 | 
 | ||||||
|  | 	--size: 48px; | ||||||
|  | 
 | ||||||
| 	&.colored { | 	&.colored { | ||||||
| 		color: var(--accent); | 		color: var(--accent); | ||||||
| 	} | 	} | ||||||
|  | @ -45,19 +52,12 @@ export default defineComponent({ | ||||||
| 	&.inline { | 	&.inline { | ||||||
| 		display: inline; | 		display: inline; | ||||||
| 		padding: 0; | 		padding: 0; | ||||||
|  | 		--size: 32px; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		> .ring:after { | 	&.mini { | ||||||
| 			width: 32px; | 		padding: 16px; | ||||||
| 			height: 32px; | 		--size: 32px; | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		> .ring { |  | ||||||
| 			&:before, |  | ||||||
| 			&:after { |  | ||||||
| 				width: 32px; |  | ||||||
| 				height: 32px; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	> .ring { | 	> .ring { | ||||||
|  | @ -70,8 +70,8 @@ export default defineComponent({ | ||||||
| 			content: " "; | 			content: " "; | ||||||
| 			display: block; | 			display: block; | ||||||
| 			box-sizing: border-box; | 			box-sizing: border-box; | ||||||
| 			width: 48px; | 			width: var(--size); | ||||||
| 			height: 48px; | 			height: var(--size); | ||||||
| 			border-radius: 50%; | 			border-radius: 50%; | ||||||
| 			border: solid 4px; | 			border: solid 4px; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -67,6 +67,13 @@ | ||||||
| 						<MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> | 						<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"/> | 						<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> | 						<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> | ||||||
| 					<div class="files" v-if="appearNote.files.length > 0"> | 					<div class="files" v-if="appearNote.files.length > 0"> | ||||||
| 						<XMediaList :media-list="appearNote.files"/> | 						<XMediaList :media-list="appearNote.files"/> | ||||||
|  | @ -178,6 +185,8 @@ export default defineComponent({ | ||||||
| 			showContent: false, | 			showContent: false, | ||||||
| 			isDeleted: false, | 			isDeleted: false, | ||||||
| 			muted: false, | 			muted: false, | ||||||
|  | 			translation: null, | ||||||
|  | 			translating: false, | ||||||
| 		}; | 		}; | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
|  | @ -619,6 +628,11 @@ export default defineComponent({ | ||||||
| 					text: this.$ts.share, | 					text: this.$ts.share, | ||||||
| 					action: this.share | 					action: this.share | ||||||
| 				}, | 				}, | ||||||
|  | 				this.$instance.translatorAvailable ? { | ||||||
|  | 					icon: 'fas fa-language', | ||||||
|  | 					text: this.$ts.translate, | ||||||
|  | 					action: this.translate | ||||||
|  | 				} : undefined, | ||||||
| 				null, | 				null, | ||||||
| 				statePromise.then(state => state.isFavorited ? { | 				statePromise.then(state => state.isFavorited ? { | ||||||
| 					icon: 'fas fa-star', | 					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() { | 		focus() { | ||||||
| 			this.$el.focus(); | 			this.$el.focus(); | ||||||
| 		}, | 		}, | ||||||
|  | @ -1050,6 +1075,13 @@ export default defineComponent({ | ||||||
| 							font-style: oblique; | 							font-style: oblique; | ||||||
| 							color: var(--renote); | 							color: var(--renote); | ||||||
| 						} | 						} | ||||||
|  | 
 | ||||||
|  | 						> .translation { | ||||||
|  | 							border: solid 0.5px var(--divider); | ||||||
|  | 							border-radius: var(--radius); | ||||||
|  | 							padding: 12px; | ||||||
|  | 							margin-top: 8px; | ||||||
|  | 						} | ||||||
| 					} | 					} | ||||||
| 
 | 
 | ||||||
| 					> .url-preview { | 					> .url-preview { | ||||||
|  |  | ||||||
|  | @ -51,6 +51,13 @@ | ||||||
| 						<MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> | 						<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"/> | 						<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> | 						<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> | ||||||
| 					<div class="files" v-if="appearNote.files.length > 0"> | 					<div class="files" v-if="appearNote.files.length > 0"> | ||||||
| 						<XMediaList :media-list="appearNote.files"/> | 						<XMediaList :media-list="appearNote.files"/> | ||||||
|  | @ -164,6 +171,8 @@ export default defineComponent({ | ||||||
| 			collapsed: false, | 			collapsed: false, | ||||||
| 			isDeleted: false, | 			isDeleted: false, | ||||||
| 			muted: false, | 			muted: false, | ||||||
|  | 			translation: null, | ||||||
|  | 			translating: false, | ||||||
| 		}; | 		}; | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
|  | @ -594,6 +603,11 @@ export default defineComponent({ | ||||||
| 					text: this.$ts.share, | 					text: this.$ts.share, | ||||||
| 					action: this.share | 					action: this.share | ||||||
| 				}, | 				}, | ||||||
|  | 				this.$instance.translatorAvailable ? { | ||||||
|  | 					icon: 'fas fa-language', | ||||||
|  | 					text: this.$ts.translate, | ||||||
|  | 					action: this.translate | ||||||
|  | 				} : undefined, | ||||||
| 				null, | 				null, | ||||||
| 				statePromise.then(state => state.isFavorited ? { | 				statePromise.then(state => state.isFavorited ? { | ||||||
| 					icon: 'fas fa-star', | 					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() { | 		focus() { | ||||||
| 			this.$el.focus(); | 			this.$el.focus(); | ||||||
| 		}, | 		}, | ||||||
|  | @ -1053,6 +1078,13 @@ export default defineComponent({ | ||||||
| 							font-style: oblique; | 							font-style: oblique; | ||||||
| 							color: var(--renote); | 							color: var(--renote); | ||||||
| 						} | 						} | ||||||
|  | 
 | ||||||
|  | 						> .translation { | ||||||
|  | 							border: solid 0.5px var(--divider); | ||||||
|  | 							border-radius: var(--radius); | ||||||
|  | 							padding: 12px; | ||||||
|  | 							margin-top: 8px; | ||||||
|  | 						} | ||||||
| 					} | 					} | ||||||
| 
 | 
 | ||||||
| 					> .url-preview { | 					> .url-preview { | ||||||
|  |  | ||||||
|  | @ -7,7 +7,12 @@ | ||||||
| 				Summaly Proxy URL | 				Summaly Proxy URL | ||||||
| 			</FormInput> | 			</FormInput> | ||||||
| 		</FormGroup> | 		</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> | 		<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> | ||||||
| 	</FormSuspense> | 	</FormSuspense> | ||||||
| </FormBase> | </FormBase> | ||||||
|  | @ -44,6 +49,7 @@ export default defineComponent({ | ||||||
| 				icon: 'fas fa-cogs' | 				icon: 'fas fa-cogs' | ||||||
| 			}, | 			}, | ||||||
| 			summalyProxy: '', | 			summalyProxy: '', | ||||||
|  | 			deeplAuthKey: '', | ||||||
| 		} | 		} | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
|  | @ -55,10 +61,12 @@ export default defineComponent({ | ||||||
| 		async init() { | 		async init() { | ||||||
| 			const meta = await os.api('meta', { detail: true }); | 			const meta = await os.api('meta', { detail: true }); | ||||||
| 			this.summalyProxy = meta.summalyProxy; | 			this.summalyProxy = meta.summalyProxy; | ||||||
|  | 			this.deeplAuthKey = meta.deeplAuthKey; | ||||||
| 		}, | 		}, | ||||||
| 		save() { | 		save() { | ||||||
| 			os.apiWithDialog('admin/update-meta', { | 			os.apiWithDialog('admin/update-meta', { | ||||||
| 				summalyProxy: this.summalyProxy, | 				summalyProxy: this.summalyProxy, | ||||||
|  | 				deeplAuthKey: this.deeplAuthKey, | ||||||
| 			}).then(() => { | 			}).then(() => { | ||||||
| 				fetchInstance(); | 				fetchInstance(); | ||||||
| 			}); | 			}); | ||||||
|  |  | ||||||
|  | @ -313,6 +313,12 @@ export class Meta { | ||||||
| 	}) | 	}) | ||||||
| 	public discordClientSecret: string | null; | 	public discordClientSecret: string | null; | ||||||
| 
 | 
 | ||||||
|  | 	@Column('varchar', { | ||||||
|  | 		length: 128, | ||||||
|  | 		nullable: true | ||||||
|  | 	}) | ||||||
|  | 	public deeplAuthKey: string | null; | ||||||
|  | 
 | ||||||
| 	@Column('varchar', { | 	@Column('varchar', { | ||||||
| 		length: 512, | 		length: 512, | ||||||
| 		nullable: true | 		nullable: true | ||||||
|  |  | ||||||
|  | @ -145,6 +145,10 @@ export const meta = { | ||||||
| 			validator: $.optional.nullable.str, | 			validator: $.optional.nullable.str, | ||||||
| 		}, | 		}, | ||||||
| 
 | 
 | ||||||
|  | 		deeplAuthKey: { | ||||||
|  | 			validator: $.optional.nullable.str, | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
| 		enableTwitterIntegration: { | 		enableTwitterIntegration: { | ||||||
| 			validator: $.optional.bool, | 			validator: $.optional.bool, | ||||||
| 		}, | 		}, | ||||||
|  | @ -562,6 +566,14 @@ export default define(meta, async (ps, me) => { | ||||||
| 		set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; | 		set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if (ps.deeplAuthKey !== undefined) { | ||||||
|  | 		if (ps.deeplAuthKey === '') { | ||||||
|  | 			set.deeplAuthKey = null; | ||||||
|  | 		} else { | ||||||
|  | 			set.deeplAuthKey = ps.deeplAuthKey; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	await getConnection().transaction(async transactionalEntityManager => { | 	await getConnection().transaction(async transactionalEntityManager => { | ||||||
| 		const meta = await transactionalEntityManager.findOne(Meta, { | 		const meta = await transactionalEntityManager.findOne(Meta, { | ||||||
| 			order: { | 			order: { | ||||||
|  |  | ||||||
|  | @ -232,6 +232,10 @@ export const meta = { | ||||||
| 				type: 'boolean' as const, | 				type: 'boolean' as const, | ||||||
| 				optional: false as const, nullable: false as const | 				optional: false as const, nullable: false as const | ||||||
| 			}, | 			}, | ||||||
|  | 			translatorAvailable: { | ||||||
|  | 				type: 'boolean' as const, | ||||||
|  | 				optional: false as const, nullable: false as const | ||||||
|  | 			}, | ||||||
| 			proxyAccountName: { | 			proxyAccountName: { | ||||||
| 				type: 'string' as const, | 				type: 'string' as const, | ||||||
| 				optional: false as const, nullable: true as const | 				optional: false as const, nullable: true as const | ||||||
|  | @ -512,6 +516,8 @@ export default define(meta, async (ps, me) => { | ||||||
| 
 | 
 | ||||||
| 		enableServiceWorker: instance.enableServiceWorker, | 		enableServiceWorker: instance.enableServiceWorker, | ||||||
| 
 | 
 | ||||||
|  | 		translatorAvailable: instance.deeplAuthKey != null, | ||||||
|  | 
 | ||||||
| 		...(ps.detail ? { | 		...(ps.detail ? { | ||||||
| 			pinnedPages: instance.pinnedPages, | 			pinnedPages: instance.pinnedPages, | ||||||
| 			pinnedClipId: instance.pinnedClipId, | 			pinnedClipId: instance.pinnedClipId, | ||||||
|  |  | ||||||
							
								
								
									
										79
									
								
								src/server/api/endpoints/notes/translate.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/server/api/endpoints/notes/translate.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||||
|  | 	}; | ||||||
|  | }); | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue